Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
This commit is contained in:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import { ConnectorGrantsService } from "./composio/connector-grants";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
/** Company connector visibility for any agent: what is connected, who holds it. */
|
||||
export function listConnectorsTool(deps: AgentToolDeps, ctx: AgentToolContext): string {
|
||||
const grantsService = new ConnectorGrantsService(deps.db);
|
||||
const accounts = grantsService.activeAccounts(ctx.orgId);
|
||||
if (accounts.length === 0) {
|
||||
return "The company has no connected external toolkits yet. The board connects them on the Connectors page.";
|
||||
}
|
||||
const grants = grantsService.grantsForOrg(ctx.orgId);
|
||||
const agentNames = new Map(
|
||||
(deps.db.prepare("select id, name from agents where org_id = ?").all(ctx.orgId) as Array<{ id: string; name: string }>)
|
||||
.map((row) => [row.id, row.name]),
|
||||
);
|
||||
const lines = accounts.map((account) => {
|
||||
const holders = grants
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter(Boolean);
|
||||
return `- ${account.toolkitName} (${account.toolkitSlug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||
});
|
||||
return ["Connected toolkits:", ...lines].join("\n");
|
||||
}
|
||||
|
||||
/** CEO-only: hand a connected toolkit to a teammate, or take it back. */
|
||||
export function grantConnectorTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, mode: "grant" | "revoke"): string {
|
||||
const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined;
|
||||
if (acting?.role_level !== "c_suite") {
|
||||
return "ERROR: Only the CEO can manage connector grants. Ask them to run this.";
|
||||
}
|
||||
const agentName = requireString(args.agent_name, "agent_name");
|
||||
const toolkit = requireString(args.toolkit, "toolkit");
|
||||
const target = deps.db.prepare(
|
||||
"select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1",
|
||||
).get(ctx.orgId, agentName) as { id: string; name: string } | undefined;
|
||||
if (!target) return `ERROR: No active agent named "${agentName}".`;
|
||||
const grantsService = new ConnectorGrantsService(deps.db);
|
||||
if (mode === "grant") {
|
||||
grantsService.grant({
|
||||
orgId: ctx.orgId,
|
||||
agentId: target.id,
|
||||
toolkitSlug: toolkit,
|
||||
grantedByType: "agent",
|
||||
grantedById: ctx.agent.id,
|
||||
});
|
||||
return `${target.name} now has access to the ${toolkit.toLowerCase()} connector; its tools join their next run.`;
|
||||
}
|
||||
grantsService.revoke({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit });
|
||||
return `${target.name} no longer has access to the ${toolkit.toLowerCase()} connector.`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { writeDeliverable } from "./deliverable-writer";
|
||||
import { DocumentService } from "./document-service";
|
||||
import { recordWorkspaceFile } from "./files/file-object-store";
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
export function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables.");
|
||||
const path = requireString(args.path, "path");
|
||||
const content = requireString(args.content, "content");
|
||||
const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content });
|
||||
const fileId = recordWorkspaceFile(deps.db, {
|
||||
orgId: ctx.orgId,
|
||||
ownerType: "agent",
|
||||
ownerId: ctx.agent.id,
|
||||
workspaceRoot: ctx.workspace.localPath,
|
||||
relativePath: written,
|
||||
});
|
||||
const review = requestDeliverableReview(deps, ctx, {
|
||||
title: `Review deliverable: ${written}`,
|
||||
details: `Deliverable ${written} (${content.length} chars) produced by an agent run.`,
|
||||
sourceEntityType: "file",
|
||||
sourceEntityId: fileId,
|
||||
});
|
||||
return `Wrote deliverable ${written} (file_id=${fileId}, chars=${content.length}).${review ? " It is queued for board review in Approvals." : ""}`;
|
||||
}
|
||||
|
||||
export function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const title = requireString(args.title, "title");
|
||||
const docType = requireString(args.doc_type, "doc_type");
|
||||
const content = requireString(args.content, "content");
|
||||
const documentService = new DocumentService(deps.db);
|
||||
const docId = documentService.create({
|
||||
orgId: ctx.orgId,
|
||||
title,
|
||||
docType,
|
||||
content,
|
||||
authorAgentId: ctx.agent.id,
|
||||
reviewerAgentId: null,
|
||||
projectId: ctx.taskProjectId,
|
||||
goalId: ctx.taskGoalId,
|
||||
taskId: null,
|
||||
});
|
||||
const review = requestDeliverableReview(deps, ctx, {
|
||||
title: `Review ${docType}: ${title}`,
|
||||
details: `Document "${title}" (${docType}, ${content.length} chars) authored by an agent run.`,
|
||||
sourceEntityType: "document",
|
||||
sourceEntityId: docId,
|
||||
});
|
||||
return `Created document "${title}" (doc_id=${docId}, type=${docType}, chars=${content.length}).${review ? " It is queued for board review in Approvals." : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* In AI Co-Founder mode the operator reviews what the company produces, so
|
||||
* every deliverable and document lands in the Approvals queue. AI CEO mode
|
||||
* ships without review — that autonomy is the mode's contract.
|
||||
*/
|
||||
function requestDeliverableReview(
|
||||
deps: AgentToolDeps,
|
||||
ctx: AgentToolContext,
|
||||
input: { title: string; details: string; sourceEntityType: "file" | "document"; sourceEntityId: string },
|
||||
): boolean {
|
||||
const org = deps.db.prepare("select review_deliverables from organizations where id = ?")
|
||||
.get(ctx.orgId) as { review_deliverables: number } | undefined;
|
||||
if (!org) throw new Error("Organization was not found.");
|
||||
if (org.review_deliverables !== 1) return false;
|
||||
new ApprovalService(deps.db).create(
|
||||
{
|
||||
orgId: ctx.orgId,
|
||||
kind: "deliverable_review",
|
||||
title: input.title,
|
||||
details: input.details,
|
||||
risk: RISK_LEVEL.LOW,
|
||||
requestedByType: "agent",
|
||||
requestedById: ctx.agent.id,
|
||||
},
|
||||
{ sourceEntityType: input.sourceEntityType, sourceEntityId: input.sourceEntityId },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* What the sending agent hears back: who is in the channel and who they just
|
||||
* addressed — the presence of real colleagues, not a transaction id. Hearing
|
||||
* the room is what lets an agent hold a conversation instead of posting into a
|
||||
* void, while the thread_id keeps the next reply on the same thread.
|
||||
*/
|
||||
export function describeSend(db: DatabaseSync, selfAgentId: string, channel: string, spaceId: string, messageId: string, threadId: string): string {
|
||||
const addressed = mentionedTeammates(db, selfAgentId, messageId);
|
||||
const present = channelTeammates(db, selfAgentId, spaceId, addressed);
|
||||
const lines = [`Your message is up in #${channel}.`];
|
||||
const replyReady = addressed.filter((teammate) => teammate.canReply).map((teammate) => teammate.name);
|
||||
const mentionedOnly = addressed.filter((teammate) => !teammate.canReply).map((teammate) => teammate.name);
|
||||
if (replyReady.length > 0) lines.push(`${joinNames(replyReady)} can see it and reply here.`);
|
||||
if (mentionedOnly.length > 0) lines.push(`${joinNames(mentionedOnly)} was mentioned in this thread.`);
|
||||
if (present.length > 0) lines.push(`Also in the room: ${joinNames(present)}.`);
|
||||
lines.push(`To stay in this conversation, reply with thread_id="${threadId}".`);
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
interface MentionedTeammate {
|
||||
name: string;
|
||||
canReply: boolean;
|
||||
}
|
||||
|
||||
function mentionedTeammates(db: DatabaseSync, selfAgentId: string, messageId: string): MentionedTeammate[] {
|
||||
const wakePattern = `%"sourceMessageId":"${messageId}"%`;
|
||||
const rows = db.prepare(`
|
||||
select coalesce(a.name, e.display_name) as name,
|
||||
case
|
||||
when m.target_type = 'employee' then 1
|
||||
when exists (
|
||||
select 1
|
||||
from task_runs r
|
||||
where r.agent_id = m.target_id
|
||||
and r.trigger = 'chat_mention'
|
||||
and r.secret_scope_json like ?
|
||||
) then 1
|
||||
else 0
|
||||
end as can_reply
|
||||
from chat_mentions m
|
||||
left join agents a on a.id = m.target_id and m.target_type = 'agent'
|
||||
left join employees e on e.id = m.target_id and m.target_type = 'employee'
|
||||
where m.message_id = ? and m.target_type in ('agent', 'employee') and m.target_id <> ?
|
||||
`).all(wakePattern, messageId, selfAgentId) as Array<{ name: string | null; can_reply: number }>;
|
||||
return dedupeTeammates(rows);
|
||||
}
|
||||
|
||||
function channelTeammates(db: DatabaseSync, selfAgentId: string, spaceId: string, addressed: MentionedTeammate[]): string[] {
|
||||
const alreadyNamed = new Set(addressed.map((teammate) => teammate.name));
|
||||
const rows = db.prepare(`
|
||||
select coalesce(a.name, e.display_name) as name
|
||||
from chat_memberships cm
|
||||
left join agents a on a.id = cm.principal_id and cm.principal_type = 'agent'
|
||||
left join employees e on e.id = cm.principal_id and cm.principal_type = 'employee'
|
||||
where cm.space_id = ? and cm.principal_id <> ?
|
||||
`).all(spaceId, selfAgentId) as Array<{ name: string | null }>;
|
||||
return dedupeNames(rows).filter((name) => !alreadyNamed.has(name)).slice(0, 6);
|
||||
}
|
||||
|
||||
function dedupeNames(rows: Array<{ name: string | null }>): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const row of rows) if (row.name) names.add(row.name);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function dedupeTeammates(rows: Array<{ name: string | null; can_reply: number }>): MentionedTeammate[] {
|
||||
const teammates = new Map<string, MentionedTeammate>();
|
||||
for (const row of rows) {
|
||||
if (!row.name) continue;
|
||||
const existing = teammates.get(row.name);
|
||||
const canReply = row.can_reply === 1;
|
||||
teammates.set(row.name, { name: row.name, canReply: canReply || existing?.canReply === true });
|
||||
}
|
||||
return [...teammates.values()];
|
||||
}
|
||||
|
||||
function joinNames(names: string[]): string {
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { optionalString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const channel = optionalString(args.channel);
|
||||
const withTeammate = optionalString(args.with);
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 30);
|
||||
if (channel && withTeammate) throw new Error('Pass either "channel" or "with", not both.');
|
||||
if (withTeammate) {
|
||||
const counterpart = deps.db.prepare(`
|
||||
select id, name, 'agent' as kind from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active'
|
||||
union all
|
||||
select id, display_name as name, 'employee' as kind from employees where org_id = ? and lower(display_name) = lower(?)
|
||||
limit 1
|
||||
`).get(ctx.orgId, withTeammate, ctx.orgId, withTeammate) as { id: string; name: string; kind: "agent" | "employee" } | undefined;
|
||||
if (!counterpart) throw new Error(`No teammate named "${withTeammate}".`);
|
||||
const spaceId = deps.chat.ensureDirectSpace(
|
||||
ctx.orgId,
|
||||
{ type: "agent", id: ctx.agent.id },
|
||||
{ type: counterpart.kind, id: counterpart.id },
|
||||
);
|
||||
const rows = recentMessagesIn(deps, ctx.orgId, spaceId, limit);
|
||||
if (rows.length === 0) return `No messages yet in your direct conversation with ${counterpart.name}.`;
|
||||
return rows.map((m) => `[${m.sender_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n");
|
||||
}
|
||||
if (channel) {
|
||||
const space = deps.db.prepare(
|
||||
"select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1",
|
||||
).get(ctx.orgId, channel) as { id: string } | undefined;
|
||||
if (!space) throw new Error(`No channel named "${channel}".`);
|
||||
const rows = recentMessagesIn(deps, ctx.orgId, space.id, limit);
|
||||
if (rows.length === 0) return `No recent messages in #${channel}.`;
|
||||
return rows.map((m) => `[${m.sender_name}] (thread=${m.thread_id}): ${m.body_md}`).join("\n");
|
||||
}
|
||||
const rows = deps.db.prepare(`
|
||||
select m.body_md,
|
||||
coalesce(a.name, e.display_name, m.sender_type) as sender_name,
|
||||
m.thread_id, s.name as channel_name, s.space_type
|
||||
from chat_mentions cm
|
||||
join chat_messages m on m.id = cm.message_id
|
||||
join chat_spaces s on s.id = m.space_id
|
||||
left join agents a on a.id = m.sender_id and m.sender_type = 'agent'
|
||||
left join employees e on e.id = m.sender_id and m.sender_type = 'employee'
|
||||
where cm.target_type = 'agent' and cm.target_id = ? and m.org_id = ? and m.deleted_at is null
|
||||
order by m.created_at desc
|
||||
limit ?
|
||||
`).all(ctx.agent.id, ctx.orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string; channel_name: string; space_type: string }>;
|
||||
if (rows.length === 0) return "No recent mentions.";
|
||||
return rows.reverse().map((m) => {
|
||||
const where = m.space_type === "direct" ? "in your direct conversation" : `in #${m.channel_name}`;
|
||||
return `[${m.sender_name} ${where}] (thread=${m.thread_id}): ${m.body_md}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function recentMessagesIn(
|
||||
deps: AgentToolDeps,
|
||||
orgId: string,
|
||||
spaceId: string,
|
||||
limit: number,
|
||||
): Array<{ body_md: string; sender_name: string; thread_id: string }> {
|
||||
const rows = deps.db.prepare(`
|
||||
select m.body_md,
|
||||
coalesce(a.name, e.display_name, m.sender_type) as sender_name,
|
||||
m.thread_id
|
||||
from chat_messages m
|
||||
left join agents a on a.id = m.sender_id and m.sender_type = 'agent'
|
||||
left join employees e on e.id = m.sender_id and m.sender_type = 'employee'
|
||||
where m.space_id = ? and m.org_id = ? and m.deleted_at is null
|
||||
order by m.created_at desc
|
||||
limit ?
|
||||
`).all(spaceId, orgId, limit) as Array<{ body_md: string; sender_name: string; thread_id: string }>;
|
||||
return rows.reverse();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ProviderService } from "./provider-service";
|
||||
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn";
|
||||
import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge";
|
||||
|
||||
const MAX_ITERATIONS = 12;
|
||||
|
||||
export interface AgentRunLoopInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
workspaceId: string | null;
|
||||
instructions: string;
|
||||
initialUserTurn: string;
|
||||
toolContext: AgentToolContext;
|
||||
externalTools?: ToolDefinition[];
|
||||
}
|
||||
|
||||
export interface AgentRunLoopResult {
|
||||
finalText: string;
|
||||
iterations: number;
|
||||
toolCallCount: number;
|
||||
lastRequestId: string;
|
||||
}
|
||||
|
||||
export interface AgentRunLoopDeps {
|
||||
providers: Pick<ProviderService, "runToolTurn">;
|
||||
tools: AgentToolDeps;
|
||||
/** Called after each iteration's tool calls have written their effects. */
|
||||
onToolResults?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a real tool-use loop against the live provider: prompt the model with
|
||||
* the agent tools, execute every tool call it makes, feed results back, and
|
||||
* repeat until the model returns a turn with no tool calls (it is done) or the
|
||||
* iteration cap is hit (which throws rather than silently truncating).
|
||||
*/
|
||||
export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise<AgentRunLoopResult> {
|
||||
const allTools = input.externalTools ? [...AGENT_TOOLS, ...input.externalTools] : AGENT_TOOLS;
|
||||
const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }];
|
||||
let toolCallCount = 0;
|
||||
let lastRequestId = "";
|
||||
|
||||
for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration += 1) {
|
||||
const turn = await deps.providers.runToolTurn({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
workspaceId: input.workspaceId,
|
||||
instructions: input.instructions,
|
||||
messages,
|
||||
tools: allTools,
|
||||
});
|
||||
lastRequestId = turn.requestId;
|
||||
|
||||
if (turn.toolCalls.length === 0) {
|
||||
if (toolCallCount === 0 && !turn.text.trim()) {
|
||||
// A first turn with neither text nor tool calls means the provider
|
||||
// produced nothing actionable (e.g. output truncated mid-reasoning);
|
||||
// completing the run would silently mark a no-op as done.
|
||||
throw new Error("Provider returned an empty turn: no text and no tool calls.");
|
||||
}
|
||||
return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId };
|
||||
}
|
||||
|
||||
messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls });
|
||||
const results: Array<{ toolCallId: string; toolName: string; output: string }> = [];
|
||||
for (const call of turn.toolCalls) {
|
||||
toolCallCount += 1;
|
||||
let output: string;
|
||||
if (isComposioTool(call.name)) {
|
||||
output = await executeComposioTool(deps.tools.db, input.orgId, input.toolContext.agent.id, call);
|
||||
} else {
|
||||
output = dispatchAgentTool(deps.tools, input.toolContext, call, {
|
||||
announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls),
|
||||
});
|
||||
}
|
||||
results.push({ toolCallId: call.id, toolName: call.name, output });
|
||||
}
|
||||
messages.push({ role: "tool", results });
|
||||
deps.onToolResults?.();
|
||||
}
|
||||
|
||||
throw new Error(`Agent run did not converge within ${MAX_ITERATIONS} iterations.`);
|
||||
}
|
||||
|
||||
function shouldAnnounceTaskAssignment(call: { name: string; arguments: Record<string, unknown> }, toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>): boolean {
|
||||
if (call.name !== "create_task") return true;
|
||||
const assigneeName = stringArg(call.arguments.assignee_agent_name);
|
||||
if (!assigneeName) return true;
|
||||
const handle = `@${canonicalMentionHandle(assigneeName)}`;
|
||||
return !toolCalls.some((toolCall) => {
|
||||
if (toolCall.name !== "send_message") return false;
|
||||
return stringArg(toolCall.arguments.body).toLowerCase().includes(handle);
|
||||
});
|
||||
}
|
||||
|
||||
function stringArg(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Shared argument validation for agent tool implementations. */
|
||||
|
||||
export function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`"${field}" is required.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function optionalStringArray(value: unknown, field: string): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error(`"${field}" must be an array.`);
|
||||
return value.map((item, index) => requireString(item, `${field}[${index}]`));
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain";
|
||||
import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service";
|
||||
import type { OrganizationService } from "./organization-service";
|
||||
import type { ChatService } from "./chat-service";
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
import { readMessagesTool } from "./agent-read-messages";
|
||||
import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools";
|
||||
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
|
||||
import { describeSend } from "./agent-message-presence";
|
||||
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
|
||||
|
||||
const HIRE_BUDGET_CENTS = 25000;
|
||||
const HEARTBEAT_INTERVAL_SEC = 120;
|
||||
|
||||
export interface AgentToolContext {
|
||||
orgId: string;
|
||||
/** The acting agent (the CEO during team-building). */
|
||||
agent: { id: string; modelProviderId: string | null; modelId: string | null };
|
||||
taskProjectId: string | null;
|
||||
taskGoalId: string | null;
|
||||
workspace: { id: string; localPath: string } | null;
|
||||
}
|
||||
|
||||
export interface AgentToolDeps {
|
||||
db: DatabaseSync;
|
||||
organization: OrganizationService;
|
||||
chat: ChatService;
|
||||
}
|
||||
|
||||
export interface AgentToolDispatchOptions {
|
||||
announceTaskAssignments?: boolean;
|
||||
}
|
||||
|
||||
/** The tools the acting agent may call during a run. */
|
||||
export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: "hire_agent",
|
||||
description:
|
||||
"Add one direct report to your team. During team building, call this 3-5 times to staff the roles you need before delegating work.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: "string", description: "Full name of the new agent." },
|
||||
role: { type: "string", description: "Their role/title, e.g. 'Growth Marketer'." },
|
||||
department: { type: "string", enum: [...ORGANIZATION_DEPARTMENTS], description: "The company department the hire belongs to." },
|
||||
model: { type: "string", description: "Optional model id; defaults to your own model." },
|
||||
},
|
||||
required: ["name", "role", "department"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a task for the team and optionally assign it to one of your reports by name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
assignee_agent_name: { type: "string", description: "Name of the agent to assign, if any." },
|
||||
risk: { type: "string", enum: ["low", "medium", "high"] },
|
||||
},
|
||||
required: ["title", "description"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_task_status",
|
||||
description: "Move a task to a new status.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
task_id: { type: "string" },
|
||||
status: { type: "string", enum: ["backlog", "in_progress", "review", "done"] },
|
||||
},
|
||||
required: ["task_id", "status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_deliverable",
|
||||
description: "Write a deliverable file into the company workspace folder (relative path). Use this to produce real output.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: "string", description: "Relative path inside the workspace, e.g. 'plan/business-plan.md'." },
|
||||
content: { type: "string" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send_message",
|
||||
description: "Talk to your teammates the way colleagues do. For anything meant for one person — task updates, questions, reports to your manager, hand-offs — send a direct message with `to` so each conversation stays separate. Use a `channel` only for genuinely team-wide topics: 'engineering' for engineering, 'product' for product/design, 'general' for company-wide. Pass exactly one of `to` or `channel`. @mention people you address in channels, attach deliverable file ids with attachment_file_ids, and use /handoff @Name when passing a file. Reply inside a thread by passing its thread_id to keep one conversation together.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
to: { type: "string", description: "Teammate name for a direct 1:1 message, e.g. 'CEO' or 'Ava Patel'. Preferred for anything addressed to one person." },
|
||||
channel: { type: "string", description: "Channel for team-wide topics: 'engineering', 'product', or 'general'." },
|
||||
body: { type: "string", description: "The message. Use @mentions for teammates and slash commands such as /handoff @Name when passing files." },
|
||||
thread_id: { type: "string", description: "Reply inside an existing thread — pass the thread_id a prior send_message returned. Omit to start a new thread." },
|
||||
attachment_file_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Approved file object ids to attach to this chat message.",
|
||||
},
|
||||
},
|
||||
required: ["body"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_messages",
|
||||
description: "Read recent messages from a channel, a direct conversation, or your direct mentions. Check what your teammates have been saying before you respond or start work.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
channel: { type: "string", description: "Channel name: 'engineering', 'product', 'general'." },
|
||||
with: { type: "string", description: "Teammate name to read your direct conversation with, e.g. 'CEO'." },
|
||||
limit: { type: "number", description: "Number of recent messages (default 10, max 30)." },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_document",
|
||||
description: "Create a company document (PRD, spec, memo, report, runbook, proposal, policy, guide). Use this for structured deliverables that the company should keep versioned.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
doc_type: { type: "string", enum: ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide"] },
|
||||
content: { type: "string", description: "Full document content in markdown." },
|
||||
},
|
||||
required: ["title", "doc_type", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_connectors",
|
||||
description: "See which external connectors (GitHub, Slack, Gmail, ...) the company has connected and who holds a grant for each. Check this before asking for access or planning work that needs an external service.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: "grant_connector",
|
||||
description: "CEO only: grant a teammate access to one of the company's connected external toolkits. Match the tool to the job — engineers get code tools, marketers get social tools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent_name: { type: "string", description: "The teammate receiving access." },
|
||||
toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." },
|
||||
},
|
||||
required: ["agent_name", "toolkit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revoke_connector",
|
||||
description: "CEO only: withdraw a teammate's access to a connected external toolkit.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent_name: { type: "string", description: "The teammate losing access." },
|
||||
toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." },
|
||||
},
|
||||
required: ["agent_name", "toolkit"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function dispatchAgentTool(
|
||||
deps: AgentToolDeps,
|
||||
ctx: AgentToolContext,
|
||||
call: ToolCall,
|
||||
options: AgentToolDispatchOptions = {},
|
||||
): string {
|
||||
try {
|
||||
switch (call.name) {
|
||||
case "hire_agent":
|
||||
return hireAgent(deps, ctx, call.arguments);
|
||||
case "create_task":
|
||||
return createTask(deps, ctx, call.arguments, options);
|
||||
case "update_task_status":
|
||||
return updateTaskStatus(deps, ctx, call.arguments);
|
||||
case "write_deliverable":
|
||||
return writeDeliverableTool(deps, ctx, call.arguments);
|
||||
case "send_message":
|
||||
return sendMessageTool(deps, ctx, call.arguments);
|
||||
case "read_messages":
|
||||
return readMessagesTool(deps, ctx, call.arguments);
|
||||
case "create_document":
|
||||
return createDocumentTool(deps, ctx, call.arguments);
|
||||
case "list_connectors":
|
||||
return listConnectorsTool(deps, ctx);
|
||||
case "grant_connector":
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "grant");
|
||||
case "revoke_connector":
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "revoke");
|
||||
default:
|
||||
return `ERROR: unknown tool "${call.name}".`;
|
||||
}
|
||||
} catch (error) {
|
||||
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
if (!ctx.agent.modelProviderId) throw new Error("Acting agent has no model provider to assign hires.");
|
||||
const name = requireString(args.name, "name");
|
||||
const role = requireString(args.role, "role");
|
||||
const department = requireString(args.department, "department");
|
||||
const model = optionalString(args.model) ?? ctx.agent.modelId;
|
||||
const agentId = deps.organization.createAgent({
|
||||
orgId: ctx.orgId,
|
||||
name,
|
||||
role,
|
||||
department,
|
||||
modelProviderId: ctx.agent.modelProviderId,
|
||||
modelId: model,
|
||||
budgetCents: HIRE_BUDGET_CENTS,
|
||||
managerAgentId: ctx.agent.id,
|
||||
roleLevel: "staff",
|
||||
workspaceId: ctx.workspace?.id ?? null,
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: HEARTBEAT_INTERVAL_SEC,
|
||||
});
|
||||
return `Hired ${name} as ${role} in ${department} (agent_id=${agentId}).`;
|
||||
}
|
||||
|
||||
function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, options: AgentToolDispatchOptions): string {
|
||||
const title = requireString(args.title, "title");
|
||||
const description = requireString(args.description, "description");
|
||||
const assigneeName = optionalString(args.assignee_agent_name);
|
||||
const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null;
|
||||
const create = () => {
|
||||
const taskId = deps.organization.createTask({
|
||||
orgId: ctx.orgId,
|
||||
title,
|
||||
description,
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: assignee?.id ?? null,
|
||||
risk: normalizeRisk(args.risk),
|
||||
projectId: ctx.taskProjectId,
|
||||
goalId: ctx.taskGoalId,
|
||||
workspaceId: ctx.workspace?.id ?? null,
|
||||
priority: TASK_PRIORITY.NORMAL,
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id
|
||||
? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee })
|
||||
: null;
|
||||
return { taskId, assignmentThread };
|
||||
};
|
||||
const { taskId, assignmentThread } = assignee ? withSavepoint(deps.db, "agent_create_task_assignment", create) : create();
|
||||
return `Created task "${title}" (task_id=${taskId})${assignee ? ` assigned to ${assignee.name}` : " (unassigned)"}${assignmentThread ? ` in thread_id="${assignmentThread}"` : ""}.`;
|
||||
}
|
||||
|
||||
function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const taskId = requireString(args.task_id, "task_id");
|
||||
const status = normalizeStatus(args.status);
|
||||
deps.organization.updateTaskStatus(ctx.orgId, taskId, status);
|
||||
return `Task ${taskId} moved to ${status}.`;
|
||||
}
|
||||
|
||||
|
||||
function sendMessageTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const channel = optionalString(args.channel);
|
||||
const to = optionalString(args.to);
|
||||
const body = requireString(args.body, "body");
|
||||
const threadId = optionalString(args.thread_id);
|
||||
const attachmentFileIds = optionalStringArray(args.attachment_file_ids, "attachment_file_ids");
|
||||
if (Boolean(channel) === Boolean(to)) throw new Error('Pass exactly one of "to" (direct message) or "channel".');
|
||||
|
||||
const destination = to
|
||||
? directSpaceWith(deps, ctx, to)
|
||||
: channelSpace(deps.db, ctx.orgId, channel!);
|
||||
|
||||
const sent = withSavepoint(deps.db, "agent_send_message", () => {
|
||||
const messageId = deps.chat.sendMessage({
|
||||
orgId: ctx.orgId,
|
||||
spaceId: destination.spaceId,
|
||||
threadId,
|
||||
senderType: "agent",
|
||||
senderId: ctx.agent.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds,
|
||||
});
|
||||
const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string };
|
||||
return { messageId, threadId: row.thread_id };
|
||||
});
|
||||
if (destination.kind === "direct") {
|
||||
return [
|
||||
`Your direct message to ${destination.counterpartName} is delivered; they can see it and reply in your conversation.`,
|
||||
`To stay in this conversation, reply with to="${destination.counterpartName}" and thread_id="${sent.threadId}".`,
|
||||
].join(" ");
|
||||
}
|
||||
return describeSend(deps.db, ctx.agent.id, destination.label, destination.spaceId, sent.messageId, sent.threadId);
|
||||
}
|
||||
|
||||
interface ChannelDestination {
|
||||
kind: "channel";
|
||||
spaceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DirectDestination {
|
||||
kind: "direct";
|
||||
spaceId: string;
|
||||
counterpartName: string;
|
||||
}
|
||||
|
||||
function channelSpace(db: DatabaseSync, orgId: string, channel: string): ChannelDestination {
|
||||
const space = db.prepare(
|
||||
"select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1",
|
||||
).get(orgId, channel) as { id: string } | undefined;
|
||||
if (!space) throw new Error(`No channel named "${channel}".`);
|
||||
return { kind: "channel", spaceId: space.id, label: channel };
|
||||
}
|
||||
|
||||
function directSpaceWith(deps: AgentToolDeps, ctx: AgentToolContext, teammateName: string): DirectDestination {
|
||||
const counterpart = resolveTeammateByName(deps.db, ctx.orgId, teammateName);
|
||||
if (counterpart.type === "agent" && counterpart.id === ctx.agent.id) {
|
||||
throw new Error("You cannot open a direct conversation with yourself.");
|
||||
}
|
||||
const spaceId = deps.chat.ensureDirectSpace(
|
||||
ctx.orgId,
|
||||
{ type: "agent", id: ctx.agent.id },
|
||||
{ type: counterpart.type, id: counterpart.id },
|
||||
);
|
||||
return { kind: "direct", spaceId, counterpartName: counterpart.name };
|
||||
}
|
||||
|
||||
function resolveTeammateByName(
|
||||
db: DatabaseSync,
|
||||
orgId: string,
|
||||
name: string,
|
||||
): { type: "agent" | "employee"; id: string; name: string } {
|
||||
const trimmed = name.trim();
|
||||
const agent = db.prepare(
|
||||
"select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1",
|
||||
).get(orgId, trimmed) as { id: string; name: string } | undefined;
|
||||
if (agent) return { type: "agent", id: agent.id, name: agent.name };
|
||||
const employee = db.prepare(
|
||||
"select id, display_name from employees where org_id = ? and lower(display_name) = lower(?) limit 1",
|
||||
).get(orgId, trimmed) as { id: string; display_name: string } | undefined;
|
||||
if (employee) return { type: "employee", id: employee.id, name: employee.display_name };
|
||||
throw new Error(`No teammate named "${name}".`);
|
||||
}
|
||||
|
||||
|
||||
interface RunnableAssignmentAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
function requireRunnableAgentByName(db: DatabaseSync, orgId: string, name: string): RunnableAssignmentAgent {
|
||||
const row = db.prepare(`
|
||||
select a.id, a.name, a.department
|
||||
from agents a
|
||||
join providers p on p.id = a.model_provider_id and p.org_id = a.org_id
|
||||
join service_identities si on si.agent_id = a.id and si.org_id = a.org_id
|
||||
where a.org_id = ?
|
||||
and lower(a.name) = lower(?)
|
||||
and a.lifecycle = 'active'
|
||||
and p.enabled = 1
|
||||
limit 1
|
||||
`).get(orgId, name.trim()) as RunnableAssignmentAgent | undefined;
|
||||
if (!row) throw new Error(`No runnable agent named "${name}" with an enabled provider and service identity.`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function postAssignmentMessage(
|
||||
deps: AgentToolDeps,
|
||||
ctx: AgentToolContext,
|
||||
input: { taskId: string; title: string; description: string; assignee: RunnableAssignmentAgent },
|
||||
): string {
|
||||
const spaceId = deps.chat.ensureDirectSpace(
|
||||
ctx.orgId,
|
||||
{ type: "agent", id: ctx.agent.id },
|
||||
{ type: "agent", id: input.assignee.id },
|
||||
);
|
||||
const body = [
|
||||
`@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`,
|
||||
input.description,
|
||||
].join(" ");
|
||||
const messageId = deps.chat.sendMessage({
|
||||
orgId: ctx.orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: ctx.agent.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
const row = deps.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string };
|
||||
return row.thread_id;
|
||||
}
|
||||
|
||||
function withSavepoint<T>(db: DatabaseSync, name: string, operation: () => T): T {
|
||||
db.exec(`savepoint ${name}`);
|
||||
try {
|
||||
const result = operation();
|
||||
db.exec(`release savepoint ${name}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec(`rollback to savepoint ${name}`);
|
||||
db.exec(`release savepoint ${name}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRisk(value: unknown): RiskLevel {
|
||||
if (value === RISK_LEVEL.MEDIUM) return RISK_LEVEL.MEDIUM;
|
||||
if (value === RISK_LEVEL.HIGH) return RISK_LEVEL.HIGH;
|
||||
return RISK_LEVEL.LOW;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): string {
|
||||
const allowed = [TASK_STATUS.BACKLOG, TASK_STATUS.IN_PROGRESS, TASK_STATUS.REVIEW, TASK_STATUS.DONE] as string[];
|
||||
if (typeof value === "string" && allowed.includes(value)) return value;
|
||||
throw new Error(`Invalid status. Use one of: ${allowed.join(", ")}.`);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands";
|
||||
import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
|
||||
interface ApprovalAuditContext {
|
||||
sourceMessageId?: string;
|
||||
sourceThreadId?: string;
|
||||
sourceEntityType?: string;
|
||||
sourceEntityId?: string;
|
||||
}
|
||||
|
||||
export class ApprovalService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
private readonly governance: OrgGovernanceService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
this.governance = new OrgGovernanceService(db);
|
||||
}
|
||||
|
||||
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
||||
this.assertRequesterBelongsToOrg(input);
|
||||
const kind = input.kind.trim();
|
||||
const title = input.title.trim();
|
||||
const details = input.details.trim();
|
||||
if (!kind) throw new Error("Approval kind is required.");
|
||||
if (!title) throw new Error("Approval title is required.");
|
||||
if (!details) throw new Error("Approval details are required.");
|
||||
const id = createId("app");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
|
||||
source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
kind,
|
||||
title,
|
||||
details,
|
||||
input.risk,
|
||||
input.requestedByType,
|
||||
input.requestedById,
|
||||
context.sourceEntityType ?? null,
|
||||
context.sourceEntityId ?? null,
|
||||
APPROVAL_STATE.PENDING,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
actorType: input.requestedByType,
|
||||
actorId: input.requestedById,
|
||||
action: "requested",
|
||||
payload: { kind, risk: input.risk, ...context },
|
||||
});
|
||||
this.governance.routeApproval({ orgId: input.orgId, approvalId: id, kind, risk: input.risk });
|
||||
return id;
|
||||
}
|
||||
|
||||
decide(input: DecideApprovalInput): void {
|
||||
this.identityAccess.assertEmployeeRole(
|
||||
input.orgId,
|
||||
input.decidedByEmployeeId,
|
||||
ROLE_KEY.APPROVAL_DECIDER,
|
||||
"approval_decide",
|
||||
"approval",
|
||||
input.approvalId,
|
||||
"Deciding employee was not found.",
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const notes = input.notes?.trim() || null;
|
||||
const result = this.db.prepare(`
|
||||
update approvals
|
||||
set state = ?, decided_by_employee_id = ?, decided_at = ?, decision_notes = ?
|
||||
where id = ? and org_id = ? and state = ?
|
||||
`).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
||||
if (result.changes === 0) throw new Error("Pending approval was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: input.approvalId,
|
||||
actorType: "employee",
|
||||
actorId: input.decidedByEmployeeId,
|
||||
action: input.state,
|
||||
payload: { decidedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
private assertRequesterBelongsToOrg(input: CreateApprovalInput): void {
|
||||
if (input.requestedByType === "employee") {
|
||||
if (!input.requestedById) throw new Error("Requesting employee is required.");
|
||||
this.assertEmployee(input.orgId, input.requestedById, "Requesting employee was not found.");
|
||||
return;
|
||||
}
|
||||
if (input.requestedByType === "agent") {
|
||||
if (!input.requestedById) throw new Error("Requesting agent is required.");
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(input.requestedById, input.orgId);
|
||||
if (!agent) throw new Error("Requesting agent was not found.");
|
||||
return;
|
||||
}
|
||||
if (input.requestedByType === "system" && input.requestedById) {
|
||||
throw new Error("System approvals cannot include a requester id.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateAuditReplayInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
interface AuditEventRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
action: string;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ChatThreadRow {
|
||||
id: string;
|
||||
root_message_id: string | null;
|
||||
}
|
||||
|
||||
interface ReplayEvent {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
record(input: {
|
||||
orgId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: unknown;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert into audit_events(id, org_id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("aud"),
|
||||
input.orgId,
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
input.actorType,
|
||||
input.actorId,
|
||||
input.action,
|
||||
JSON.stringify(input.payload),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
createThreadReplay(input: CreateAuditReplayInput): string {
|
||||
const thread = this.requireThread(input.orgId, input.threadId);
|
||||
this.requireEmployee(input.orgId, input.createdByEmployeeId);
|
||||
const messageIds = this.threadMessageIds(input.orgId, input.threadId, thread.root_message_id);
|
||||
const events = this.threadEvents(input.orgId, input.threadId, messageIds);
|
||||
if (events.length === 0) throw new Error("Thread replay requires audit events.");
|
||||
const replay = {
|
||||
threadId: input.threadId,
|
||||
rootMessageId: thread.root_message_id,
|
||||
messageIds: [...messageIds].sort(),
|
||||
events,
|
||||
};
|
||||
const replayJson = JSON.stringify(replay);
|
||||
const eventDigest = createHash("sha256").update(replayJson).digest("hex");
|
||||
const id = createId("arp");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into audit_replay_runs(
|
||||
id, org_id, thread_id, event_count, event_digest, replay_json,
|
||||
created_by_employee_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.threadId, events.length, eventDigest, replayJson, input.createdByEmployeeId, now);
|
||||
this.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "audit_replay_run",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { threadId: input.threadId, eventCount: events.length, eventDigest },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
private requireThread(orgId: string, threadId: string): ChatThreadRow {
|
||||
const thread = this.db.prepare("select id, root_message_id from chat_threads where id = ? and org_id = ?")
|
||||
.get(threadId, orgId) as ChatThreadRow | undefined;
|
||||
if (!thread) throw new Error("Chat thread was not found.");
|
||||
return thread;
|
||||
}
|
||||
|
||||
private requireEmployee(orgId: string, employeeId: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error("Audit replay employee was not found.");
|
||||
}
|
||||
|
||||
private threadMessageIds(orgId: string, threadId: string, rootMessageId: string | null): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (rootMessageId) ids.add(rootMessageId);
|
||||
const rows = this.db.prepare("select id from chat_messages where org_id = ? and thread_id = ? order by created_at asc")
|
||||
.all(orgId, threadId) as Array<{ id: string }>;
|
||||
for (const row of rows) ids.add(row.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
private threadEvents(orgId: string, threadId: string, messageIds: Set<string>): ReplayEvent[] {
|
||||
const rows = this.db.prepare("select * from audit_events where org_id = ? order by created_at asc, id asc")
|
||||
.all(orgId) as unknown as AuditEventRow[];
|
||||
return rows
|
||||
.filter((row) => eventMatchesThread(row, threadId, messageIds))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
actorType: row.actor_type,
|
||||
actorId: row.actor_id,
|
||||
action: row.action,
|
||||
payload: parsePayload(row.payload_json),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function eventMatchesThread(row: AuditEventRow, threadId: string, messageIds: Set<string>): boolean {
|
||||
if (row.entity_type === "audit_replay_run") return false;
|
||||
if (row.entity_type === "chat_thread" && row.entity_id === threadId) return true;
|
||||
if (row.entity_type === "chat_message" && messageIds.has(row.entity_id)) return true;
|
||||
const payload = parsePayload(row.payload_json);
|
||||
return stringField(payload, "threadId") === threadId
|
||||
|| stringField(payload, "sourceThreadId") === threadId
|
||||
|| messageIds.has(stringField(payload, "messageId"))
|
||||
|| messageIds.has(stringField(payload, "sourceMessageId"));
|
||||
}
|
||||
|
||||
function parsePayload(payloadJson: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(payloadJson) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Audit event payload is invalid.");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringField(payload: Record<string, unknown>, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ProviderAuthType } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { SecretVault } from "./secret-vault";
|
||||
|
||||
export interface ProviderSecretVault {
|
||||
encryptSecret(secret: string): Buffer;
|
||||
decryptSecret(ciphertext: Buffer): string;
|
||||
}
|
||||
|
||||
export interface CreateProviderAuthProfileInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
authType: ProviderAuthType;
|
||||
secret: string;
|
||||
profileKind: string;
|
||||
sourceType: string;
|
||||
sourceRef: string | null;
|
||||
capabilityTags: string[];
|
||||
}
|
||||
|
||||
export interface ProviderAuthProfileRef {
|
||||
authProfileId: string;
|
||||
credentialId: string;
|
||||
displayHint: string;
|
||||
}
|
||||
|
||||
export interface RevokeProviderAuthProfileInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
}
|
||||
|
||||
export class AuthBrokerService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly vault: ProviderSecretVault = new SecretVault(),
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createProviderProfile(input: CreateProviderAuthProfileInput): ProviderAuthProfileRef {
|
||||
const secret = input.secret.trim();
|
||||
if (!secret) throw new Error("Credential is required.");
|
||||
const credentialId = createId("cred");
|
||||
const authProfileId = createId("authp");
|
||||
const displayHint = credentialDisplayHint(secret);
|
||||
const now = new Date().toISOString();
|
||||
const capabilityJson = JSON.stringify({
|
||||
providerKey: input.providerKey,
|
||||
authType: input.authType,
|
||||
capabilityTags: input.capabilityTags,
|
||||
});
|
||||
|
||||
this.db.prepare(`
|
||||
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(credentialId, input.orgId, input.providerKey, input.authType, this.vault.encryptSecret(secret), displayHint, now);
|
||||
this.db.prepare(`
|
||||
update provider_auth_profiles
|
||||
set active = 0
|
||||
where org_id = ? and provider_id = ? and active = 1
|
||||
`).run(input.orgId, input.providerId);
|
||||
this.db.prepare(`
|
||||
insert into provider_auth_profiles(
|
||||
id, org_id, provider_id, credential_id, provider_key, auth_type,
|
||||
profile_kind, display_hint, source_type, source_ref, capability_json,
|
||||
active, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
`).run(
|
||||
authProfileId,
|
||||
input.orgId,
|
||||
input.providerId,
|
||||
credentialId,
|
||||
input.providerKey,
|
||||
input.authType,
|
||||
input.profileKind,
|
||||
displayHint,
|
||||
input.sourceType,
|
||||
input.sourceRef,
|
||||
capabilityJson,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider_auth_profile",
|
||||
entityId: authProfileId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: {
|
||||
providerId: input.providerId,
|
||||
providerKey: input.providerKey,
|
||||
authType: input.authType,
|
||||
profileKind: input.profileKind,
|
||||
},
|
||||
});
|
||||
return { authProfileId, credentialId, displayHint };
|
||||
}
|
||||
|
||||
loadProviderSecret(input: {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
credentialId: string;
|
||||
}): string {
|
||||
const row = this.db.prepare(`
|
||||
select credentials.ciphertext
|
||||
from provider_auth_profiles
|
||||
join credentials on credentials.id = provider_auth_profiles.credential_id
|
||||
where provider_auth_profiles.org_id = ?
|
||||
and provider_auth_profiles.provider_id = ?
|
||||
and provider_auth_profiles.provider_key = ?
|
||||
and provider_auth_profiles.credential_id = ?
|
||||
and provider_auth_profiles.active = 1
|
||||
and credentials.org_id = provider_auth_profiles.org_id
|
||||
and credentials.provider_key = provider_auth_profiles.provider_key
|
||||
`).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { ciphertext: Uint8Array } | undefined;
|
||||
if (!row) throw new Error("Provider credential was not found.");
|
||||
return this.vault.decryptSecret(Buffer.from(row.ciphertext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypts a refreshed OAuth credential into its existing credential row,
|
||||
* keeping the same credential id (and active auth profile) so request-time
|
||||
* token refresh persists without re-binding the provider.
|
||||
*/
|
||||
updateProviderSecret(input: { orgId: string; providerKey: string; credentialId: string; secret: string }): void {
|
||||
const secret = input.secret.trim();
|
||||
if (!secret) throw new Error("Credential is required.");
|
||||
const displayHint = credentialDisplayHint(secret);
|
||||
const result = this.db.prepare(`
|
||||
update credentials
|
||||
set ciphertext = ?, display_hint = ?
|
||||
where id = ? and org_id = ? and provider_key = ?
|
||||
`).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey);
|
||||
if (result.changes === 0) throw new Error("Provider credential was not found.");
|
||||
this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?")
|
||||
.run(displayHint, input.credentialId, input.orgId);
|
||||
}
|
||||
|
||||
revokeProviderProfile(input: RevokeProviderAuthProfileInput): void {
|
||||
const row = this.db.prepare(`
|
||||
select id, credential_id
|
||||
from provider_auth_profiles
|
||||
where org_id = ?
|
||||
and provider_id = ?
|
||||
and provider_key = ?
|
||||
and active = 1
|
||||
`).get(input.orgId, input.providerId, input.providerKey) as { id: string; credential_id: string } | undefined;
|
||||
if (!row) throw new Error("Provider credential was not found.");
|
||||
this.db.prepare("update provider_auth_profiles set active = 0 where id = ? and org_id = ?")
|
||||
.run(row.id, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider_auth_profile",
|
||||
entityId: row.id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "revoked",
|
||||
payload: {
|
||||
providerId: input.providerId,
|
||||
providerKey: input.providerKey,
|
||||
credentialId: row.credential_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function credentialDisplayHint(secret: string): string {
|
||||
const clean = secret.trim();
|
||||
if (clean.startsWith("{")) return `json:${createHash("sha256").update(clean).digest("hex").slice(0, 10)}`;
|
||||
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
|
||||
return `${clean.slice(0, 6)}...${clean.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateAutomationRuleInput, UpdateAutomationRuleInput, DeleteAutomationRuleInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class AutomationService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateAutomationRuleInput): string {
|
||||
const id = createId("aut");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into automation_rules(id, org_id, name, trigger_event, condition_json, action_type, action_config_json, priority, enabled, fire_count, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).run(id, input.orgId, input.name.trim(), input.triggerEvent, JSON.stringify(input.conditions), input.actionType, JSON.stringify(input.actionConfig), input.priority, input.enabled ? 1 : 0, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "automation_rule",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim(), triggerEvent: input.triggerEvent },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
update(input: UpdateAutomationRuleInput): void {
|
||||
const result = this.db.prepare(`
|
||||
update automation_rules set name = ?, trigger_event = ?, condition_json = ?, action_type = ?, action_config_json = ?, priority = ?, enabled = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.name.trim(), input.triggerEvent, JSON.stringify(input.conditions), input.actionType, JSON.stringify(input.actionConfig), input.priority, input.enabled ? 1 : 0, new Date().toISOString(), input.ruleId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Automation rule was not found.");
|
||||
}
|
||||
|
||||
delete(input: DeleteAutomationRuleInput): void {
|
||||
const result = this.db.prepare("delete from automation_rules where id = ? and org_id = ?").run(input.ruleId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Automation rule was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import {
|
||||
BOARD_INTERVENTION_ACTION,
|
||||
BOARD_INTERVENTION_MODE,
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
RISK_LEVEL,
|
||||
type BoardInterventionAction,
|
||||
type BoardInterventionMode,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { ResolvedMention } from "./chat-mentions";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface PrincipalRef {
|
||||
type: "employee" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequester {
|
||||
type: "employee" | "agent" | "system";
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
interface BoardCommand {
|
||||
mode: BoardInterventionMode;
|
||||
action: BoardInterventionAction;
|
||||
details: string;
|
||||
}
|
||||
|
||||
interface BoardTarget {
|
||||
type: "employee" | "agent" | "board";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RecordBoardInterventionInput {
|
||||
messageInput: SendMessageInput;
|
||||
sender: PrincipalRef | null;
|
||||
mentions: ResolvedMention[];
|
||||
args: string;
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
spaceId: string;
|
||||
now: string;
|
||||
}
|
||||
|
||||
export class BoardInterventionService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
}
|
||||
|
||||
record(input: RecordBoardInterventionInput): string {
|
||||
if (input.messageInput.senderType !== "board") throw new Error("Board intervention requires board sender.");
|
||||
const command = parseBoardCommand(input.args);
|
||||
const target = resolveBoardTarget(command, input.messageInput.orgId, input.mentions);
|
||||
const approvalId = command.action === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL
|
||||
? this.createBoardApproval(input, command)
|
||||
: null;
|
||||
const interventionId = createId("bdi");
|
||||
this.db.prepare(`
|
||||
insert into board_interventions(
|
||||
id, org_id, thread_id, message_id, mode, action, details, target_type, target_id,
|
||||
approval_id, created_by_type, created_by_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
interventionId,
|
||||
input.messageInput.orgId,
|
||||
input.threadId,
|
||||
input.messageId,
|
||||
command.mode,
|
||||
command.action,
|
||||
command.details,
|
||||
target.type,
|
||||
target.id,
|
||||
approvalId,
|
||||
input.messageInput.senderType,
|
||||
input.messageInput.senderId,
|
||||
input.now,
|
||||
);
|
||||
this.applyBoardThreadState(input, command, target);
|
||||
this.audit.record({
|
||||
orgId: input.messageInput.orgId,
|
||||
entityType: "board_intervention",
|
||||
entityId: interventionId,
|
||||
actorType: input.messageInput.senderType,
|
||||
actorId: input.messageInput.senderId,
|
||||
action: "recorded",
|
||||
payload: {
|
||||
threadId: input.threadId,
|
||||
messageId: input.messageId,
|
||||
mode: command.mode,
|
||||
action: command.action,
|
||||
targetType: target.type,
|
||||
targetId: target.id,
|
||||
approvalId,
|
||||
},
|
||||
});
|
||||
return interventionId;
|
||||
}
|
||||
|
||||
private createBoardApproval(input: RecordBoardInterventionInput, command: BoardCommand): string {
|
||||
const requester = approvalRequester(input.messageInput.senderType, input.sender);
|
||||
return this.approvals.create({
|
||||
orgId: input.messageInput.orgId,
|
||||
kind: "board_force_approval",
|
||||
title: `Board approval ${spaceName(this.db, input.spaceId)}`,
|
||||
details: command.details,
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: requester.type,
|
||||
requestedById: requester.id,
|
||||
}, {
|
||||
sourceMessageId: input.messageId,
|
||||
sourceThreadId: input.threadId,
|
||||
sourceEntityType: "chat_message",
|
||||
sourceEntityId: input.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
private applyBoardThreadState(input: RecordBoardInterventionInput, command: BoardCommand, target: BoardTarget): void {
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.FREEZE) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.FROZEN, null, null);
|
||||
return;
|
||||
}
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.TAKEOVER) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.TAKEOVER, target.type, target.id);
|
||||
return;
|
||||
}
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.ROLLBACK) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.OPEN, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private updateBoardThreadState(
|
||||
input: RecordBoardInterventionInput,
|
||||
boardState: string,
|
||||
controlledByType: BoardTarget["type"] | null,
|
||||
controlledById: string | null,
|
||||
): void {
|
||||
this.db.prepare(`
|
||||
update chat_threads
|
||||
set board_state = ?, controlled_by_type = ?, controlled_by_id = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(boardState, controlledByType, controlledById, input.now, input.threadId, input.messageInput.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.messageInput.orgId,
|
||||
entityType: "chat_thread",
|
||||
entityId: input.threadId,
|
||||
actorType: "board",
|
||||
actorId: input.messageInput.senderId,
|
||||
action: "board_state_changed",
|
||||
payload: { threadId: input.threadId, boardState, controlledByType, controlledById },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoardCommand(args: string): BoardCommand {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) throw new Error("Slash command /board requires an action.");
|
||||
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
||||
const action = normalizeBoardAction(rawAction);
|
||||
const details = rest.join(" ").trim();
|
||||
if (!details) throw new Error(`Slash command /board ${rawAction} requires details.`);
|
||||
return {
|
||||
mode: boardModeFor(action, rawAction),
|
||||
action,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBoardTarget(command: BoardCommand, orgId: string, mentions: ResolvedMention[]): BoardTarget {
|
||||
if (command.action !== BOARD_INTERVENTION_ACTION.TAKEOVER) return { type: "board", id: orgId };
|
||||
const principal = mentions.find((mention) => mention.target.type === "employee" || mention.target.type === "agent");
|
||||
if (!principal) return { type: "board", id: orgId };
|
||||
if (principal.target.type === "employee" || principal.target.type === "agent") {
|
||||
return { type: principal.target.type, id: principal.target.id };
|
||||
}
|
||||
return { type: "board", id: orgId };
|
||||
}
|
||||
|
||||
function normalizeBoardAction(value: string): BoardInterventionAction {
|
||||
const normalized = value.toLowerCase().replace(/-/g, "_");
|
||||
if (normalized === "observe") return BOARD_INTERVENTION_ACTION.DECISION;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.DECISION) return BOARD_INTERVENTION_ACTION.DECISION;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.FREEZE) return BOARD_INTERVENTION_ACTION.FREEZE;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_ACTION.TAKEOVER;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.ROLLBACK) return BOARD_INTERVENTION_ACTION.ROLLBACK;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL) return BOARD_INTERVENTION_ACTION.FORCE_APPROVAL;
|
||||
throw new Error(`Unsupported board action: ${value}.`);
|
||||
}
|
||||
|
||||
function boardModeFor(action: BoardInterventionAction, rawAction: string): BoardInterventionMode {
|
||||
if (rawAction.toLowerCase() === "observe") return BOARD_INTERVENTION_MODE.OBSERVE;
|
||||
if (action === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_MODE.TAKEOVER;
|
||||
return BOARD_INTERVENTION_MODE.INTERVENE;
|
||||
}
|
||||
|
||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||
if (senderType === "system") return { type: "system", id: null };
|
||||
if (!sender) throw new Error("Approval requester is missing.");
|
||||
if (senderType === "agent") return { type: "agent", id: sender.id };
|
||||
return { type: "employee", id: sender.id };
|
||||
}
|
||||
|
||||
function spaceName(db: DatabaseSync, spaceId: string): string {
|
||||
const row = db.prepare("select name from chat_spaces where id = ?").get(spaceId) as { name: string } | undefined;
|
||||
if (!row) throw new Error("Chat space was not found.");
|
||||
return row.name;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { ResolvedMention } from "./chat-mentions";
|
||||
import type { SlashInvocation } from "./chat-service-internal";
|
||||
import { OrganizationService } from "./organization-service";
|
||||
|
||||
interface RunnableAgentRow {
|
||||
id: string;
|
||||
name: string;
|
||||
model_provider_id: string | null;
|
||||
workspace_id: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceContext {
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string;
|
||||
repo_ref: string;
|
||||
project_id: string | null;
|
||||
goal_id: string | null;
|
||||
}
|
||||
|
||||
export interface AgentWakeResult {
|
||||
targetId: string;
|
||||
taskId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export class ChatAgentWakeService {
|
||||
private readonly organization: OrganizationService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.organization = new OrganizationService(db);
|
||||
}
|
||||
|
||||
wakeFromMention(input: SendMessageInput, agentId: string, messageId: string, threadId: string, now: string): AgentWakeResult {
|
||||
const agent = this.requireRunnableAgent(input.orgId, agentId, "Agent mention");
|
||||
const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id);
|
||||
const from = this.principalName(input.orgId, input.senderType, input.senderId);
|
||||
const space = this.spaceRow(input.orgId, input.spaceId);
|
||||
const direct = space.space_type === "direct";
|
||||
const taskId = this.organization.createTask({
|
||||
orgId: input.orgId,
|
||||
title: direct ? `Reply to ${from}` : `Reply to ${from} in #${space.name}`,
|
||||
description: this.mentionTaskDescription(input, from, space.name, direct),
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: agent.id,
|
||||
risk: RISK_LEVEL.LOW,
|
||||
projectId: workspace?.project_id ?? null,
|
||||
goalId: workspace?.goal_id ?? null,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
priority: TASK_PRIORITY.HIGH,
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, now });
|
||||
this.recordPresence(agent.id, { command: "mention", args: "" }, now);
|
||||
return { targetId: agent.id, taskId, runId };
|
||||
}
|
||||
|
||||
wakeFromSlash(input: SendMessageInput, mentions: ResolvedMention[], slash: SlashInvocation, messageId: string, threadId: string, now: string): AgentWakeResult {
|
||||
const targetId = this.requireAgentMention(mentions);
|
||||
const agent = this.requireRunnableAgent(input.orgId, targetId, "Slash command /agent");
|
||||
const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id);
|
||||
const taskId = this.organization.createTask({
|
||||
orgId: input.orgId,
|
||||
title: this.taskTitle(agent.name, slash),
|
||||
description: this.taskDescription(input, slash, messageId, threadId),
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: agent.id,
|
||||
risk: RISK_LEVEL.LOW,
|
||||
projectId: workspace?.project_id ?? null,
|
||||
goalId: workspace?.goal_id ?? null,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
priority: TASK_PRIORITY.HIGH,
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, now });
|
||||
this.recordPresence(agent.id, slash, now);
|
||||
return { targetId: agent.id, taskId, runId };
|
||||
}
|
||||
|
||||
private requireAgentMention(mentions: ResolvedMention[]): string {
|
||||
const target = mentions.find((mention) => mention.target.type === "agent")?.target;
|
||||
if (!target || target.type !== "agent") throw new Error("Slash command /agent requires an agent mention.");
|
||||
return target.id;
|
||||
}
|
||||
|
||||
private requireRunnableAgent(orgId: string, agentId: string, label: string): RunnableAgentRow {
|
||||
const agent = this.db.prepare(`
|
||||
select a.id, a.name, a.model_provider_id, a.workspace_id
|
||||
from agents a
|
||||
join providers p on p.id = a.model_provider_id and p.org_id = a.org_id
|
||||
where a.id = ?
|
||||
and a.org_id = ?
|
||||
and a.lifecycle = 'active'
|
||||
and p.enabled = 1
|
||||
limit 1
|
||||
`).get(agentId, orgId) as RunnableAgentRow | undefined;
|
||||
if (!agent) throw new Error(`${label} requires an active agent with an enabled provider.`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private workspaceForAgent(orgId: string, workspaceId: string | null): WorkspaceContext | null {
|
||||
if (!workspaceId) return null;
|
||||
const workspace = this.workspaceById(orgId, workspaceId);
|
||||
if (!workspace) throw new Error("Agent workspace was not found.");
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private workspaceById(orgId: string, workspaceId: string): WorkspaceContext | null {
|
||||
return (this.db.prepare(`
|
||||
select w.id, w.name, w.local_path, w.repo_ref, w.project_id, p.goal_id
|
||||
from workspaces w
|
||||
left join projects p on p.id = w.project_id and p.org_id = w.org_id
|
||||
where w.org_id = ? and w.id = ?
|
||||
limit 1
|
||||
`).get(orgId, workspaceId) as WorkspaceContext | undefined) ?? null;
|
||||
}
|
||||
|
||||
private createQueuedRun(input: {
|
||||
orgId: string;
|
||||
taskId: string;
|
||||
agent: RunnableAgentRow;
|
||||
workspace: WorkspaceContext | null;
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
now: string;
|
||||
}): string {
|
||||
const providerId = input.agent.model_provider_id;
|
||||
if (!providerId) throw new Error("Slash command /agent requires an enabled provider.");
|
||||
const serviceIdentityId = this.requireServiceIdentity(input.orgId, input.agent.id);
|
||||
const runId = createId("run");
|
||||
const executionContextId = createId("ctx");
|
||||
const workspaceMountJson = JSON.stringify(input.workspace ? [{
|
||||
workspaceId: input.workspace.id,
|
||||
name: input.workspace.name,
|
||||
localPath: input.workspace.local_path,
|
||||
repoRef: input.workspace.repo_ref,
|
||||
mode: "read_only",
|
||||
}] : []);
|
||||
const secretScopeJson = JSON.stringify({
|
||||
serviceIdentityId,
|
||||
providerIds: [providerId],
|
||||
scope: "chat_mention",
|
||||
credentialAccess: "provider_runtime_only",
|
||||
sourceMessageId: input.messageId,
|
||||
sourceThreadId: input.threadId,
|
||||
});
|
||||
this.db.prepare(`
|
||||
insert into task_runs(
|
||||
id, org_id, task_id, agent_id, provider_id, provider_request_id,
|
||||
execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json,
|
||||
trigger, state, created_at, started_at, completed_at, error_message
|
||||
)
|
||||
values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, null, null, null)
|
||||
`).run(
|
||||
runId,
|
||||
input.orgId,
|
||||
input.taskId,
|
||||
input.agent.id,
|
||||
providerId,
|
||||
executionContextId,
|
||||
serviceIdentityId,
|
||||
workspaceMountJson,
|
||||
secretScopeJson,
|
||||
"chat_mention",
|
||||
"queued",
|
||||
input.now,
|
||||
);
|
||||
return runId;
|
||||
}
|
||||
|
||||
private requireServiceIdentity(orgId: string, agentId: string): string {
|
||||
const row = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
|
||||
.get(orgId, agentId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Agent service identity was not found.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private recordPresence(agentId: string, slash: SlashInvocation, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into chat_presence(principal_type, principal_id, status, status_text, last_seen_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(principal_type, principal_id)
|
||||
do update set status = excluded.status, status_text = excluded.status_text, last_seen_at = excluded.last_seen_at
|
||||
`).run("agent", agentId, "thinking", slash.args.trim().slice(0, 160), now);
|
||||
}
|
||||
|
||||
private taskTitle(agentName: string, slash: SlashInvocation): string {
|
||||
const details = slash.args.replace(/^@[^\s]+\s*/, "").trim();
|
||||
const title = details || `reply as ${agentName}`;
|
||||
return `Chat mention: ${title.length > 64 ? `${title.slice(0, 61)}...` : title}`;
|
||||
}
|
||||
|
||||
private mentionTaskDescription(input: SendMessageInput, from: string, channel: string, direct: boolean): string {
|
||||
const said = input.bodyMd.trim();
|
||||
const where = direct ? `${from} messaged you directly:` : `${from} addressed you in #${channel}:`;
|
||||
const how = direct
|
||||
? `Reply to ${from} in your direct conversation: call send_message with to="${from}" and this thread_id.`
|
||||
: `Reply to ${from} in this thread.`;
|
||||
return [
|
||||
where,
|
||||
`"${said.length > 400 ? `${said.slice(0, 397)}...` : said}"`,
|
||||
"",
|
||||
`${how} Answer what they asked, give your honest take, push back if you disagree, or hand work over — speak as yourself, the way a colleague would, not as a status report.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private principalName(orgId: string, senderType: string, senderId: string | null): string {
|
||||
if (senderId && senderType === "agent") {
|
||||
const row = this.db.prepare("select name from agents where id = ? and org_id = ?").get(senderId, orgId) as { name: string } | undefined;
|
||||
if (row) return row.name;
|
||||
}
|
||||
if (senderId && (senderType === "employee" || senderType === "board")) {
|
||||
const row = this.db.prepare("select display_name from employees where id = ? and org_id = ?").get(senderId, orgId) as { display_name: string } | undefined;
|
||||
if (row) return senderType === "board" ? `${row.display_name} (board)` : row.display_name;
|
||||
}
|
||||
return senderType === "board" ? "The board" : "A teammate";
|
||||
}
|
||||
|
||||
private spaceRow(orgId: string, spaceId: string): { name: string; space_type: string } {
|
||||
const row = this.db.prepare("select name, space_type from chat_spaces where id = ? and org_id = ?")
|
||||
.get(spaceId, orgId) as { name: string; space_type: string } | undefined;
|
||||
if (!row) throw new Error("Chat space was not found.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private taskDescription(input: SendMessageInput, slash: SlashInvocation, messageId: string, threadId: string): string {
|
||||
return [
|
||||
`Chat command: /agent ${slash.args.trim()}`,
|
||||
`Source message: ${messageId}`,
|
||||
`Source thread: ${threadId}`,
|
||||
`Source space: ${input.spaceId}`,
|
||||
`Sender: ${input.senderType}${input.senderId ? `:${input.senderId}` : ""}`,
|
||||
"",
|
||||
"Reply in the source thread with send_message and pass the thread_id above.",
|
||||
].join("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
|
||||
export interface MentionTarget {
|
||||
type: "employee" | "agent" | "board" | "org_group";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MentionCandidate extends MentionTarget {
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface ResolvedMention {
|
||||
target: MentionTarget;
|
||||
offsetStart: number;
|
||||
offsetEnd: number;
|
||||
}
|
||||
|
||||
export function resolveChatMentions(db: DatabaseSync, orgId: string, spaceId: string, body: string): ResolvedMention[] {
|
||||
const mentions: ResolvedMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
||||
const token = match[2];
|
||||
const target = resolveMention(db, orgId, spaceId, token);
|
||||
const offsetStart = match.index + match[1].length;
|
||||
const key = `${target.type}:${target.id}:${offsetStart}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
export function recordChatMentions(db: DatabaseSync, messageId: string, mentions: ResolvedMention[]): void {
|
||||
for (const mention of mentions) {
|
||||
db.prepare(`
|
||||
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMention(db: DatabaseSync, orgId: string, spaceId: string, token: string): MentionTarget {
|
||||
const handles = mentionLookupHandles(token);
|
||||
if (handles.has("board")) return { type: "board", id: orgId };
|
||||
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
|
||||
const candidates = mentionCandidates(db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
|
||||
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
|
||||
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
|
||||
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
|
||||
const target = [...unique.values()][0];
|
||||
return { type: target.type, id: target.id };
|
||||
}
|
||||
|
||||
function mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
|
||||
const employees = db.prepare(`
|
||||
select employees.id, employees.display_name
|
||||
from employees
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'employee'
|
||||
and memberships.principal_id = employees.id
|
||||
and memberships.space_id = ?
|
||||
where employees.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
|
||||
const agents = db.prepare(`
|
||||
select agents.id, agents.name
|
||||
from agents
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'agent'
|
||||
and memberships.principal_id = agents.id
|
||||
and memberships.space_id = ?
|
||||
where agents.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
|
||||
return [
|
||||
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
|
||||
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
|
||||
];
|
||||
}
|
||||
|
||||
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
|
||||
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
|
||||
return [...handles].map((handle) => ({ type, id, handle }));
|
||||
}
|
||||
|
||||
function mentionLookupHandles(token: string): Set<string> {
|
||||
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { DeleteMessageInput, EditMessageInput, SendMessageInput } from "../../shared/commands";
|
||||
import { buildChatMessageStructure } from "./chat-message-structure";
|
||||
import { recordChatMentions, resolveChatMentions } from "./chat-mentions";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface MessageMutationRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
space_id: string;
|
||||
sender_type: SendMessageInput["senderType"];
|
||||
sender_id: string | null;
|
||||
body_md: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
interface PrincipalRef {
|
||||
type: "employee" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class ChatMessageMutationService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
editMessage(input: EditMessageInput): void {
|
||||
const body = input.bodyMd.trim();
|
||||
if (!body) throw new Error("Message body is required.");
|
||||
if (parseSlashInvocation(body)) throw new Error("Slash command messages are immutable.");
|
||||
const message = this.requireMutableMessage(input.orgId, input.messageId);
|
||||
if (parseSlashInvocation(message.body_md)) throw new Error("Slash command messages are immutable.");
|
||||
this.assertMessageMutationActor(input, message);
|
||||
const attachmentFileIds = this.messageAttachmentFileIds(message.id);
|
||||
const mentions = resolveChatMentions(this.db, input.orgId, message.space_id, body);
|
||||
const structuredBody = buildChatMessageStructure({ slash: null, mentions, attachmentFileIds });
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
update chat_messages
|
||||
set body_md = ?, body_structured_json = ?, edited_at = ?
|
||||
where id = ? and org_id = ? and deleted_at is null
|
||||
`).run(body, structuredBody, now, message.id, input.orgId);
|
||||
this.db.prepare("delete from chat_mentions where message_id = ?").run(message.id);
|
||||
recordChatMentions(this.db, message.id, mentions);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_message",
|
||||
entityId: message.id,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: "edited",
|
||||
payload: { editedAt: now, previousBodySha256: sha256(message.body_md), bodySha256: sha256(body) },
|
||||
});
|
||||
}
|
||||
|
||||
deleteMessage(input: DeleteMessageInput): void {
|
||||
const message = this.requireMutableMessage(input.orgId, input.messageId);
|
||||
this.assertMessageMutationActor(input, message);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare("update chat_messages set deleted_at = ? where id = ? and org_id = ? and deleted_at is null")
|
||||
.run(now, message.id, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_message",
|
||||
entityId: message.id,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: "deleted",
|
||||
payload: { deletedAt: now, bodySha256: sha256(message.body_md) },
|
||||
});
|
||||
}
|
||||
|
||||
private requireMutableMessage(orgId: string, messageId: string): MessageMutationRow {
|
||||
const message = this.db.prepare(`
|
||||
select id, org_id, space_id, sender_type, sender_id, body_md, deleted_at
|
||||
from chat_messages
|
||||
where id = ? and org_id = ?
|
||||
`).get(messageId, orgId) as MessageMutationRow | undefined;
|
||||
if (!message || message.deleted_at) throw new Error("Mutable chat message was not found.");
|
||||
return message;
|
||||
}
|
||||
|
||||
private assertMessageMutationActor(input: EditMessageInput | DeleteMessageInput, message: MessageMutationRow): void {
|
||||
const actor = this.requireSender(input.orgId, input.actorType, input.actorId);
|
||||
if (actor) this.requireMembership(message.space_id, actor);
|
||||
if (input.actorType === "system") {
|
||||
if (message.sender_type === "system") return;
|
||||
throw new Error("Message actor is not allowed to mutate this message.");
|
||||
}
|
||||
if (input.actorType === "board") return;
|
||||
if (message.sender_type === input.actorType && message.sender_id === input.actorId) return;
|
||||
throw new Error("Message actor is not allowed to mutate this message.");
|
||||
}
|
||||
|
||||
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
|
||||
if (senderType === "system") return null;
|
||||
if (senderType === "employee") return this.requirePrincipal("employees", orgId, senderId, "Employee actor");
|
||||
if (senderType === "agent") return this.requirePrincipal("agents", orgId, senderId, "Agent actor");
|
||||
return this.requirePrincipal("employees", orgId, senderId, "Board actor");
|
||||
}
|
||||
|
||||
private requirePrincipal(table: "employees" | "agents", orgId: string, id: string | null, label: string): PrincipalRef {
|
||||
if (!id) throw new Error(`${label} is required.`);
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error(`${label} was not found.`);
|
||||
return { type: table === "employees" ? "employee" : "agent", id };
|
||||
}
|
||||
|
||||
private requireMembership(spaceId: string, principal: PrincipalRef): void {
|
||||
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?")
|
||||
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;
|
||||
if (!row) throw new Error("Chat membership is required.");
|
||||
}
|
||||
|
||||
private messageAttachmentFileIds(messageId: string): string[] {
|
||||
const rows = this.db.prepare("select file_id from chat_attachments where message_id = ? order by file_id asc")
|
||||
.all(messageId) as Array<{ file_id: string }>;
|
||||
return rows.map((row) => row.file_id);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSlashInvocation(body: string): { command: string; args: string } | null {
|
||||
const match = body.match(/^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i);
|
||||
if (!match) return null;
|
||||
return { command: match[1].toLowerCase(), args: match[2] ?? "" };
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ResolvedMention } from "./chat-mentions";
|
||||
|
||||
export interface ChatMessageStructureInput {
|
||||
slash: { command: string; args: string } | null;
|
||||
mentions: ResolvedMention[];
|
||||
attachmentFileIds: string[];
|
||||
}
|
||||
|
||||
export function buildChatMessageStructure(input: ChatMessageStructureInput): string {
|
||||
return JSON.stringify({
|
||||
format: "markdown",
|
||||
slashInvocation: input.slash ? { command: input.slash.command, args: input.slash.args } : null,
|
||||
mentions: input.mentions.map((mention) => ({
|
||||
targetType: mention.target.type,
|
||||
targetId: mention.target.id,
|
||||
offsetStart: mention.offsetStart,
|
||||
offsetEnd: mention.offsetEnd,
|
||||
})),
|
||||
attachmentFileIds: input.attachmentFileIds,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, SendMessageInput } from "../../shared/commands";
|
||||
import type { FileScanState } from "../../shared/domain";
|
||||
import { parseBoardCommand } from "./board-intervention-service";
|
||||
|
||||
export const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
|
||||
|
||||
export const ALLOWED_SLASH_COMMANDS = new Set([
|
||||
"agent",
|
||||
"handoff",
|
||||
"approve",
|
||||
"deny",
|
||||
"skill",
|
||||
"runbook",
|
||||
"file",
|
||||
"escalate",
|
||||
"board",
|
||||
]);
|
||||
|
||||
export interface FileAttachmentRow {
|
||||
id: string;
|
||||
sha256: string;
|
||||
scan_state: FileScanState;
|
||||
scan_labels_json: string;
|
||||
storage_path: string;
|
||||
owner_type: string;
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export interface SpaceRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
visibility: string;
|
||||
space_type: string;
|
||||
}
|
||||
|
||||
export interface ThreadRow {
|
||||
id: string;
|
||||
board_state: string;
|
||||
controlled_by_type: "employee" | "agent" | "board" | null;
|
||||
controlled_by_id: string | null;
|
||||
}
|
||||
|
||||
export interface PrincipalRef {
|
||||
type: "employee" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SlashInvocation {
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequester {
|
||||
type: "employee" | "agent" | "system";
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
export function parseSlashInvocation(body: string): SlashInvocation | null {
|
||||
const match = body.match(/^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i);
|
||||
if (!match) return null;
|
||||
return { command: match[1].toLowerCase(), args: match[2] ?? "" };
|
||||
}
|
||||
|
||||
export function validateSlashInvocation(slash: SlashInvocation): void {
|
||||
if (slash.command === "escalate" && slash.args.trim().length === 0) {
|
||||
throw new Error("Slash command /escalate requires details.");
|
||||
}
|
||||
if (slash.command === "approve" || slash.command === "deny") parseApprovalDecisionTarget(slash);
|
||||
if (slash.command === "board") parseBoardCommand(slash.args);
|
||||
}
|
||||
|
||||
export function parseApprovalDecisionTarget(slash: SlashInvocation): string {
|
||||
const approvalId = slash.args.trim().split(/\s+/, 1)[0] ?? "";
|
||||
if (!approvalId) throw new Error(`Slash command /${slash.command} requires an approval id.`);
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
export function firstToken(value: string): string {
|
||||
return value.trim().split(/\s+/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
export function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||
if (senderType === "system") return { type: "system", id: null };
|
||||
if (!sender) throw new Error("Approval requester is missing.");
|
||||
if (senderType === "agent") return { type: "agent", id: sender.id };
|
||||
return { type: "employee", id: sender.id };
|
||||
}
|
||||
|
||||
export function spaceName(db: DatabaseSync, spaceId: string): string {
|
||||
const row = db.prepare("select name from chat_spaces where id = ?").get(spaceId) as { name: string } | undefined;
|
||||
if (!row) throw new Error("Chat space was not found.");
|
||||
return row.name;
|
||||
}
|
||||
|
||||
export function createThreadTitle(body: string): string {
|
||||
const firstLine = body.split(/\r?\n/, 1)[0].trim();
|
||||
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
||||
}
|
||||
|
||||
export function retentionPolicy(value: string | undefined, visibility: CreateChatSpaceInput["visibility"]): string {
|
||||
const policy = value ?? (visibility === "restricted" ? "restricted_365d" : "standard_365d");
|
||||
if (!RETENTION_POLICIES.has(policy)) throw new Error("Chat retention policy is invalid.");
|
||||
return policy;
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
import {
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
FILE_SCAN_STATE,
|
||||
ROLE_KEY,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import {
|
||||
ALLOWED_SLASH_COMMANDS,
|
||||
createThreadTitle,
|
||||
parseSlashInvocation,
|
||||
retentionPolicy,
|
||||
validateSlashInvocation,
|
||||
type FileAttachmentRow,
|
||||
type PrincipalRef,
|
||||
type SpaceRow,
|
||||
type ThreadRow,
|
||||
} from "./chat-service-internal";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { buildChatMessageStructure } from "./chat-message-structure";
|
||||
import { ChatMessageMutationService } from "./chat-message-mutation-service";
|
||||
import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions";
|
||||
import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service";
|
||||
import { ChatAgentWakeService } from "./chat-agent-wake-service";
|
||||
import { ChatSlashRouter } from "./chat-slash-router";
|
||||
|
||||
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "division", "department", "project", "pod", "ad_hoc", "direct"]);
|
||||
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
||||
|
||||
/**
|
||||
* How many agent-authored messages a thread may accumulate after the last human
|
||||
* message before agent-triggered @mention wake-ups stop. Two agents replying to
|
||||
* each other each post a message, so this bounds an autonomous exchange to a
|
||||
* finite length and hands control back to a person instead of letting the
|
||||
* company talk to itself forever. A human (or board) message in the thread
|
||||
* always wakes the mentioned agents and resets this budget.
|
||||
*/
|
||||
const MAX_AUTONOMOUS_THREAD_AGENT_TURNS = 16;
|
||||
|
||||
export class ChatService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly handoffs: FileHandoffService;
|
||||
private readonly messageMutations: ChatMessageMutationService;
|
||||
private readonly identity: IdentityAccessService;
|
||||
private readonly agentWake: ChatAgentWakeService;
|
||||
private readonly slashRouter: ChatSlashRouter;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.handoffs = new FileHandoffService(db);
|
||||
this.messageMutations = new ChatMessageMutationService(db);
|
||||
this.identity = new IdentityAccessService(db);
|
||||
this.agentWake = new ChatAgentWakeService(db);
|
||||
this.slashRouter = new ChatSlashRouter(db, this.audit, this.agentWake);
|
||||
}
|
||||
|
||||
createSpace(input: CreateChatSpaceInput): string {
|
||||
const name = input.name.trim();
|
||||
const topic = input.topic.trim();
|
||||
if (!name) throw new Error("Chat space name is required.");
|
||||
if (!topic) throw new Error("Chat space topic is required.");
|
||||
if (!SPACE_TYPES.has(input.spaceType)) throw new Error(`Unsupported chat space type: ${input.spaceType}`);
|
||||
if (!SPACE_VISIBILITIES.has(input.visibility)) throw new Error(`Unsupported chat space visibility: ${input.visibility}`);
|
||||
const explicitMembers = this.resolveExplicitMembers(input);
|
||||
const broadSpace = input.visibility === "workspace" && input.spaceType !== "direct";
|
||||
if (!broadSpace && explicitMembers.length === 0) throw new Error("Explicit members are required for this chat space.");
|
||||
|
||||
const id = createId("spc");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, retention_policy, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.spaceType, name, topic, input.visibility, retentionPolicy(input.retentionPolicy, input.visibility), now);
|
||||
|
||||
const members = broadSpace ? this.resolveWorkspaceMembers(input.orgId) : explicitMembers;
|
||||
for (const member of members) this.addMembership(id, member, input.visibility === "restricted" ? "approver" : "member");
|
||||
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_space",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { spaceType: input.spaceType, visibility: input.visibility },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds or creates the private direct conversation between two principals,
|
||||
* so 1:1 traffic (task hand-offs, questions, reports) lives in its own
|
||||
* WeChat-style conversation instead of piling into shared channels.
|
||||
*/
|
||||
ensureDirectSpace(orgId: string, a: PrincipalRef, b: PrincipalRef): string {
|
||||
if (a.type === b.type && a.id === b.id) throw new Error("A direct conversation needs two different principals.");
|
||||
const existing = this.db.prepare(`
|
||||
select cs.id
|
||||
from chat_spaces cs
|
||||
where cs.org_id = ? and cs.space_type = 'direct'
|
||||
and exists (select 1 from chat_memberships m where m.space_id = cs.id and m.principal_type = ? and m.principal_id = ?)
|
||||
and exists (select 1 from chat_memberships m where m.space_id = cs.id and m.principal_type = ? and m.principal_id = ?)
|
||||
and (select count(*) from chat_memberships m where m.space_id = cs.id) = 2
|
||||
limit 1
|
||||
`).get(orgId, a.type, a.id, b.type, b.id) as { id: string } | undefined;
|
||||
if (existing) return existing.id;
|
||||
return this.createSpace({
|
||||
orgId,
|
||||
spaceType: "direct",
|
||||
name: `${this.principalDisplayName(orgId, a)} · ${this.principalDisplayName(orgId, b)}`,
|
||||
topic: "Direct conversation",
|
||||
visibility: "restricted",
|
||||
memberEmployeeIds: [a, b].filter((p) => p.type === "employee").map((p) => p.id),
|
||||
memberAgentIds: [a, b].filter((p) => p.type === "agent").map((p) => p.id),
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(input: SendMessageInput): string {
|
||||
const body = input.bodyMd.trim();
|
||||
if (!body) throw new Error("Message body is required.");
|
||||
const space = this.requireSpace(input.orgId, input.spaceId);
|
||||
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
||||
if (sender) this.requireMembership(space.id, sender);
|
||||
const thread = input.threadId ? this.requireThread(input.orgId, input.spaceId, input.threadId) : null;
|
||||
this.assertThreadControl(input, thread, sender);
|
||||
const slash = parseSlashInvocation(body);
|
||||
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
|
||||
if (slash) validateSlashInvocation(slash);
|
||||
const attachmentFileIds = input.attachmentFileIds ?? [];
|
||||
const attachments = this.requireAttachments(input.orgId, attachmentFileIds, sender);
|
||||
const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body);
|
||||
const structuredBody = buildChatMessageStructure({ slash, mentions, attachmentFileIds });
|
||||
|
||||
const id = createId("msg");
|
||||
const threadId = input.threadId ?? createId("thread");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chat_messages(
|
||||
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, body_structured_json,
|
||||
delivery_state, created_at, edited_at, deleted_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.spaceId,
|
||||
threadId,
|
||||
input.senderType,
|
||||
input.senderId,
|
||||
body,
|
||||
structuredBody,
|
||||
"sent",
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
if (!input.threadId) {
|
||||
this.db.prepare(`
|
||||
insert into chat_threads(id, org_id, space_id, root_message_id, title, state, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(threadId, input.orgId, input.spaceId, id, createThreadTitle(body), "open", now, now);
|
||||
} else {
|
||||
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
||||
}
|
||||
recordChatMentions(this.db, id, mentions);
|
||||
const slashResultRef = slash ? this.slashRouter.execute(input, space, sender, mentions, attachments, slash, id, threadId, now) : null;
|
||||
if (slash) this.slashRouter.recordInvocation(id, slash, slashResultRef, now);
|
||||
if (!slash || slash.command !== "agent") {
|
||||
this.wakeAgentMentions(input, mentions, id, threadId, now);
|
||||
if (space.space_type === "direct") this.wakeDirectCounterparts(input, mentions, id, threadId, now);
|
||||
}
|
||||
if (input.senderType === "employee" || input.senderType === "board") {
|
||||
this.wakeThreadParticipants(input, mentions, id, threadId, now);
|
||||
}
|
||||
this.handoffs.record({
|
||||
orgId: input.orgId,
|
||||
spaceId: input.spaceId,
|
||||
messageId: id,
|
||||
threadId,
|
||||
senderType: input.senderType,
|
||||
senderId: input.senderId,
|
||||
attachments,
|
||||
mentions,
|
||||
now,
|
||||
});
|
||||
if (input.senderId) {
|
||||
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
||||
this.db.prepare(`
|
||||
insert into chat_presence(principal_type, principal_id, status, status_text, last_seen_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(principal_type, principal_id)
|
||||
do update set status = excluded.status, status_text = excluded.status_text, last_seen_at = excluded.last_seen_at
|
||||
`).run(input.senderType, input.senderId, presenceStatus, "", now);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_message",
|
||||
entityId: id,
|
||||
actorType: input.senderType,
|
||||
actorId: input.senderId,
|
||||
action: "sent",
|
||||
payload: { spaceId: input.spaceId, threadId, attachmentCount: input.attachmentFileIds?.length ?? 0 },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
markSpaceRead(input: MarkChatSpaceReadInput): void {
|
||||
const space = this.db.prepare("select id from chat_spaces where id = ? and org_id = ?")
|
||||
.get(input.spaceId, input.orgId) as { id: string } | undefined;
|
||||
if (!space) throw new Error("Chat space was not found.");
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
|
||||
.get(input.employeeId, input.orgId) as { id: string } | undefined;
|
||||
if (!employee) throw new Error("Employee was not found.");
|
||||
// Read state is per-employee bookkeeping, not access control: the board
|
||||
// observes every conversation in its org (agent↔agent directs included),
|
||||
// so opening one clears its unread count even without membership.
|
||||
const latest = this.db.prepare(`
|
||||
select id from chat_messages
|
||||
where space_id = ? and org_id = ?
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`).get(input.spaceId, input.orgId) as { id: string } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id, last_read_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(employee_id, space_id)
|
||||
do update set last_read_message_id = excluded.last_read_message_id, last_read_at = excluded.last_read_at
|
||||
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
|
||||
}
|
||||
|
||||
editMessage(input: EditMessageInput): void {
|
||||
this.messageMutations.editMessage(input);
|
||||
}
|
||||
|
||||
deleteMessage(input: DeleteMessageInput): void {
|
||||
this.messageMutations.deleteMessage(input);
|
||||
}
|
||||
|
||||
private wakeAgentMentions(input: SendMessageInput, mentions: ResolvedMention[], messageId: string, threadId: string, now: string): void {
|
||||
const agentMentions = mentions.filter(
|
||||
(m) => m.target.type === "agent" && !(input.senderType === "agent" && input.senderId === m.target.id),
|
||||
);
|
||||
if (agentMentions.length === 0) return;
|
||||
if (input.senderType === "agent" && this.autonomousThreadTurnsExhausted(input.orgId, threadId)) return;
|
||||
for (const mention of agentMentions) {
|
||||
try {
|
||||
this.agentWake.wakeFromMention(input, mention.target.id, messageId, threadId, now);
|
||||
this.db.prepare(
|
||||
"update chat_mentions set wake_state = 'woken' where message_id = ? and target_type = 'agent' and target_id = ?",
|
||||
).run(messageId, mention.target.id);
|
||||
} catch {
|
||||
this.db.prepare(
|
||||
"update chat_mentions set wake_state = 'failed' where message_id = ? and target_type = 'agent' and target_id = ?",
|
||||
).run(messageId, mention.target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private wakeThreadParticipants(input: SendMessageInput, alreadyMentioned: ResolvedMention[], messageId: string, threadId: string, now: string): void {
|
||||
if (!input.threadId) return;
|
||||
const mentionedAgentIds = new Set(
|
||||
alreadyMentioned.filter((m) => m.target.type === "agent").map((m) => m.target.id),
|
||||
);
|
||||
const participants = this.db.prepare(`
|
||||
select distinct m.sender_id as agent_id
|
||||
from chat_messages m
|
||||
where m.thread_id = ? and m.org_id = ? and m.sender_type = 'agent' and m.deleted_at is null
|
||||
`).all(input.threadId, input.orgId) as Array<{ agent_id: string }>;
|
||||
for (const row of participants) {
|
||||
if (mentionedAgentIds.has(row.agent_id)) continue;
|
||||
try {
|
||||
this.agentWake.wakeFromMention(input, row.agent_id, messageId, threadId, now);
|
||||
} catch {
|
||||
// Agent may not have an enabled provider.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In a direct conversation the counterpart is addressed by definition, so
|
||||
* agent members wake on every message without needing an explicit @mention
|
||||
* — the same social contract as any messenger. Mentioned agents were
|
||||
* already woken; the autonomous turn budget still bounds agent↔agent runs.
|
||||
*/
|
||||
private wakeDirectCounterparts(input: SendMessageInput, mentions: ResolvedMention[], messageId: string, threadId: string, now: string): void {
|
||||
if (input.senderType === "agent" && this.autonomousThreadTurnsExhausted(input.orgId, threadId)) return;
|
||||
const mentionedAgentIds = new Set(mentions.filter((m) => m.target.type === "agent").map((m) => m.target.id));
|
||||
const members = this.db.prepare(`
|
||||
select principal_id as agent_id
|
||||
from chat_memberships
|
||||
where space_id = ? and principal_type = 'agent'
|
||||
`).all(input.spaceId) as Array<{ agent_id: string }>;
|
||||
for (const member of members) {
|
||||
if (mentionedAgentIds.has(member.agent_id)) continue;
|
||||
if (input.senderType === "agent" && input.senderId === member.agent_id) continue;
|
||||
try {
|
||||
this.agentWake.wakeFromMention(input, member.agent_id, messageId, threadId, now);
|
||||
} catch {
|
||||
// Agent may not have an enabled provider.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True once a thread holds MAX_AUTONOMOUS_THREAD_AGENT_TURNS agent messages
|
||||
* since the last human or board message — the point past which agent↔agent
|
||||
* wake-ups would loop without human involvement. Counting only the messages
|
||||
* after the last human turn lets a person re-engage and grant a fresh budget.
|
||||
*/
|
||||
private autonomousThreadTurnsExhausted(orgId: string, threadId: string): boolean {
|
||||
const row = this.db.prepare(`
|
||||
select count(*) as turns
|
||||
from chat_messages
|
||||
where org_id = ? and thread_id = ? and sender_type = 'agent'
|
||||
and created_at > coalesce((
|
||||
select max(created_at) from chat_messages
|
||||
where org_id = ? and thread_id = ? and sender_type in ('employee', 'board')
|
||||
), '')
|
||||
`).get(orgId, threadId, orgId, threadId) as { turns: number };
|
||||
return row.turns >= MAX_AUTONOMOUS_THREAD_AGENT_TURNS;
|
||||
}
|
||||
|
||||
private principalDisplayName(orgId: string, principal: PrincipalRef): string {
|
||||
if (principal.type === "agent") {
|
||||
const row = this.db.prepare("select name from agents where id = ? and org_id = ?")
|
||||
.get(principal.id, orgId) as { name: string } | undefined;
|
||||
if (row) return row.name;
|
||||
}
|
||||
if (principal.type === "employee") {
|
||||
const row = this.db.prepare("select display_name from employees where id = ? and org_id = ?")
|
||||
.get(principal.id, orgId) as { display_name: string } | undefined;
|
||||
if (row) return row.display_name;
|
||||
}
|
||||
throw new Error("Direct conversation principal was not found.");
|
||||
}
|
||||
|
||||
private requireSpace(orgId: string, spaceId: string): SpaceRow {
|
||||
const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?")
|
||||
.get(spaceId, orgId) as SpaceRow | undefined;
|
||||
if (!space) throw new Error("Chat space was not found.");
|
||||
return space;
|
||||
}
|
||||
|
||||
private assertThreadControl(input: SendMessageInput, thread: ThreadRow | null, sender: PrincipalRef | null): void {
|
||||
if (!thread) return;
|
||||
if (input.senderType === "board") return;
|
||||
if (thread.board_state === CHAT_THREAD_BOARD_STATE.FROZEN) {
|
||||
throw new Error("Board-frozen thread requires board sender.");
|
||||
}
|
||||
if (thread.board_state !== CHAT_THREAD_BOARD_STATE.TAKEOVER) return;
|
||||
if (!sender) throw new Error("Board takeover controls this thread.");
|
||||
if (thread.controlled_by_type === sender.type && thread.controlled_by_id === sender.id) return;
|
||||
throw new Error("Board takeover controls this thread.");
|
||||
}
|
||||
|
||||
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
|
||||
if (senderType === "system") return null;
|
||||
if (senderType === "employee") {
|
||||
if (!senderId) throw new Error("Employee sender is required.");
|
||||
this.requireOrgEntity("employees", senderId, orgId, "Employee sender");
|
||||
return { type: "employee", id: senderId };
|
||||
}
|
||||
if (senderType === "agent") {
|
||||
if (!senderId) throw new Error("Agent sender is required.");
|
||||
this.requireOrgEntity("agents", senderId, orgId, "Agent sender");
|
||||
return { type: "agent", id: senderId };
|
||||
}
|
||||
if (!senderId) throw new Error("Board sender is required.");
|
||||
this.requireOrgEntity("employees", senderId, orgId, "Board sender");
|
||||
this.identity.assertEmployeeRole(
|
||||
orgId,
|
||||
senderId,
|
||||
ROLE_KEY.APPROVAL_DECIDER,
|
||||
"chat_board_intervention",
|
||||
"organization",
|
||||
orgId,
|
||||
);
|
||||
return { type: "employee", id: senderId };
|
||||
}
|
||||
|
||||
private requireThread(orgId: string, spaceId: string, threadId: string): ThreadRow {
|
||||
const thread = this.db.prepare(`
|
||||
select id, board_state, controlled_by_type, controlled_by_id
|
||||
from chat_threads
|
||||
where id = ? and org_id = ? and space_id = ?
|
||||
`).get(threadId, orgId, spaceId) as ThreadRow | undefined;
|
||||
if (!thread) throw new Error("Chat thread was not found in this space.");
|
||||
return thread;
|
||||
}
|
||||
|
||||
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): FileHandoffAttachment[] {
|
||||
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
|
||||
return fileIds.map((fileId) => {
|
||||
const file = this.db.prepare(`
|
||||
select id, sha256, scan_state, scan_labels_json, storage_path, owner_type, owner_id
|
||||
from file_objects
|
||||
where id = ? and org_id = ?
|
||||
`).get(fileId, orgId) as FileAttachmentRow | undefined;
|
||||
if (!file) throw new Error("Attachment file was not found.");
|
||||
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
|
||||
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
|
||||
return {
|
||||
fileId,
|
||||
checksum: file.sha256,
|
||||
scanState: file.scan_state,
|
||||
scanLabelsJson: file.scan_labels_json,
|
||||
objectRef: file.storage_path,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private senderCanReadFile(orgId: string, file: FileAttachmentRow, sender: PrincipalRef | null): boolean {
|
||||
if (!sender) return true;
|
||||
if (file.owner_type === sender.type && file.owner_id === sender.id) return true;
|
||||
const workspaceGroupId = `${orgId}:workspace`;
|
||||
const grant = this.db.prepare(`
|
||||
select id from file_access_grants
|
||||
where org_id = ?
|
||||
and file_id = ?
|
||||
and access_level in ('read', 'manage')
|
||||
and (
|
||||
(granted_to_type = ? and granted_to_id = ?)
|
||||
or (granted_to_type = 'org_group' and granted_to_id = ?)
|
||||
)
|
||||
limit 1
|
||||
`).get(orgId, file.id, sender.type, sender.id, workspaceGroupId) as { id: string } | undefined;
|
||||
return Boolean(grant);
|
||||
}
|
||||
|
||||
private requireMembership(spaceId: string, principal: PrincipalRef): void {
|
||||
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?")
|
||||
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;
|
||||
if (!row) throw new Error("Chat membership is required.");
|
||||
}
|
||||
|
||||
private resolveWorkspaceMembers(orgId: string): PrincipalRef[] {
|
||||
const employees = this.db.prepare("select id from employees where org_id = ?").all(orgId) as Array<{ id: string }>;
|
||||
const agents = this.db.prepare("select id from agents where org_id = ?").all(orgId) as Array<{ id: string }>;
|
||||
return [
|
||||
...employees.map((employee) => ({ type: "employee" as const, id: employee.id })),
|
||||
...agents.map((agent) => ({ type: "agent" as const, id: agent.id })),
|
||||
];
|
||||
}
|
||||
|
||||
private resolveExplicitMembers(input: CreateChatSpaceInput): PrincipalRef[] {
|
||||
const members: PrincipalRef[] = [];
|
||||
for (const id of new Set(input.memberEmployeeIds ?? [])) {
|
||||
this.requireOrgEntity("employees", id, input.orgId, "Chat member");
|
||||
members.push({ type: "employee", id });
|
||||
}
|
||||
for (const id of new Set(input.memberAgentIds ?? [])) {
|
||||
this.requireOrgEntity("agents", id, input.orgId, "Chat member");
|
||||
members.push({ type: "agent", id });
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
private requireOrgEntity(table: "employees" | "agents", id: string, orgId: string, label: string): void {
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error(`${label} was not found.`);
|
||||
}
|
||||
|
||||
private addMembership(spaceId: string, principal: PrincipalRef, role: string): void {
|
||||
this.db.prepare(`
|
||||
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(spaceId, principal.type, principal.id, role, "unmuted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import type { AuditService } from "./audit-service";
|
||||
import { BoardInterventionService } from "./board-intervention-service";
|
||||
import type { ChatAgentWakeService } from "./chat-agent-wake-service";
|
||||
import {
|
||||
approvalRequester,
|
||||
firstToken,
|
||||
parseApprovalDecisionTarget,
|
||||
spaceName,
|
||||
type PrincipalRef,
|
||||
type SlashInvocation,
|
||||
type SpaceRow,
|
||||
} from "./chat-service-internal";
|
||||
import type { ResolvedMention } from "./chat-mentions";
|
||||
import type { FileHandoffAttachment } from "./file-handoff-service";
|
||||
|
||||
/**
|
||||
* Routes the slash commands a chat message can carry — /board, /approve,
|
||||
* /deny, /agent, /skill, /file, /runbook, /handoff, /escalate — to the right
|
||||
* domain action and records the routing. Lifted out of ChatService so message
|
||||
* delivery and command dispatch each stay a readable, single-purpose unit.
|
||||
*/
|
||||
export class ChatSlashRouter {
|
||||
private readonly approvals: ApprovalService;
|
||||
private readonly boardInterventions: BoardInterventionService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly audit: AuditService,
|
||||
private readonly agentWake: ChatAgentWakeService,
|
||||
) {
|
||||
this.approvals = new ApprovalService(db);
|
||||
this.boardInterventions = new BoardInterventionService(db);
|
||||
}
|
||||
|
||||
execute(
|
||||
input: SendMessageInput,
|
||||
space: SpaceRow,
|
||||
sender: PrincipalRef | null,
|
||||
mentions: ResolvedMention[],
|
||||
attachments: FileHandoffAttachment[],
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
now: string,
|
||||
): string | null {
|
||||
if (slash.command === "board") {
|
||||
return this.boardInterventions.record({
|
||||
messageInput: input,
|
||||
sender,
|
||||
mentions,
|
||||
args: slash.args,
|
||||
messageId,
|
||||
threadId,
|
||||
spaceId: space.id,
|
||||
now,
|
||||
});
|
||||
}
|
||||
if (slash.command === "approve" || slash.command === "deny") {
|
||||
return this.decideApprovalFromSlash(input, sender, slash);
|
||||
}
|
||||
if (slash.command === "agent") return this.routeAgentSlash(input, mentions, slash, messageId, threadId, now);
|
||||
if (slash.command === "skill") return this.routeSkillSlash(input, slash, messageId);
|
||||
if (slash.command === "file") return this.routeFileSlash(input, attachments, slash, messageId);
|
||||
if (slash.command === "runbook") return this.routeRunbookSlash(input, attachments, slash, messageId);
|
||||
if (slash.command === "handoff") return this.routeHandoffSlash(input, attachments, messageId);
|
||||
if (slash.command !== "escalate") return null;
|
||||
const requester = approvalRequester(input.senderType, sender);
|
||||
return this.approvals.create({
|
||||
orgId: input.orgId,
|
||||
kind: "chat_escalation",
|
||||
title: `Escalate ${spaceName(this.db, space.id)}`,
|
||||
details: slash.args.trim(),
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: requester.type,
|
||||
requestedById: requester.id,
|
||||
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
|
||||
}
|
||||
|
||||
recordInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
|
||||
}
|
||||
|
||||
private routeAgentSlash(
|
||||
input: SendMessageInput,
|
||||
mentions: ResolvedMention[],
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
now: string,
|
||||
): string {
|
||||
const wake = this.agentWake.wakeFromSlash(input, mentions, slash, messageId, threadId, now);
|
||||
this.recordSlashRoute(input, messageId, slash.command, wake.targetId, {
|
||||
targetType: "agent",
|
||||
targetId: wake.targetId,
|
||||
taskId: wake.taskId,
|
||||
runId: wake.runId,
|
||||
});
|
||||
return wake.targetId;
|
||||
}
|
||||
|
||||
private routeSkillSlash(input: SendMessageInput, slash: SlashInvocation, messageId: string): string {
|
||||
const target = firstToken(slash.args);
|
||||
if (!target) throw new Error("Slash command /skill requires a skill slug.");
|
||||
const row = this.db.prepare(`
|
||||
select id from skill_definitions
|
||||
where org_id = ?
|
||||
and (lower(slug) = lower(?) or lower(name) = lower(?))
|
||||
limit 1
|
||||
`).get(input.orgId, target, target.replace(/-/g, " ")) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Slash command /skill target was not found.");
|
||||
this.recordSlashRoute(input, messageId, slash.command, row.id, { skillId: row.id, target });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private routeFileSlash(
|
||||
input: SendMessageInput,
|
||||
attachments: FileHandoffAttachment[],
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
): string {
|
||||
const fileId = attachments[0]?.fileId ?? this.resolveFileSlashTarget(input.orgId, slash);
|
||||
this.recordSlashRoute(input, messageId, slash.command, fileId, { fileId });
|
||||
return fileId;
|
||||
}
|
||||
|
||||
private routeRunbookSlash(
|
||||
input: SendMessageInput,
|
||||
attachments: FileHandoffAttachment[],
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
): string {
|
||||
if (!slash.args.trim() && attachments.length === 0) throw new Error("Slash command /runbook requires details or a file attachment.");
|
||||
const resultRef = attachments[0]?.fileId ?? messageId;
|
||||
this.recordSlashRoute(input, messageId, slash.command, resultRef, { resultRef, attachmentCount: attachments.length });
|
||||
return resultRef;
|
||||
}
|
||||
|
||||
private routeHandoffSlash(input: SendMessageInput, attachments: FileHandoffAttachment[], messageId: string): string {
|
||||
const fileId = attachments[0]?.fileId;
|
||||
if (!fileId) throw new Error("Slash command /handoff requires a file attachment.");
|
||||
this.recordSlashRoute(input, messageId, "handoff", fileId, { fileId, attachmentCount: attachments.length });
|
||||
return fileId;
|
||||
}
|
||||
|
||||
private resolveFileSlashTarget(orgId: string, slash: SlashInvocation): string {
|
||||
const target = firstToken(slash.args);
|
||||
if (!target) throw new Error("Slash command /file requires a file attachment or file reference.");
|
||||
const row = this.db.prepare(`
|
||||
select id from file_objects
|
||||
where org_id = ?
|
||||
and (id = ? or name = ?)
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`).get(orgId, target, target) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Slash command /file target was not found.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private recordSlashRoute(
|
||||
input: SendMessageInput,
|
||||
messageId: string,
|
||||
command: string,
|
||||
resultRef: string,
|
||||
route: Record<string, unknown>,
|
||||
): void {
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_message",
|
||||
entityId: messageId,
|
||||
actorType: input.senderType,
|
||||
actorId: input.senderId,
|
||||
action: "slash_routed",
|
||||
payload: { command, resultRef, route },
|
||||
});
|
||||
}
|
||||
|
||||
private decideApprovalFromSlash(input: SendMessageInput, sender: PrincipalRef | null, slash: SlashInvocation): string {
|
||||
if (!sender || sender.type !== "employee") throw new Error("Approval decision requires employee sender.");
|
||||
const approvalId = parseApprovalDecisionTarget(slash);
|
||||
this.approvals.decide({
|
||||
orgId: input.orgId,
|
||||
approvalId,
|
||||
state: slash.command === "approve" ? APPROVAL_STATE.APPROVED : APPROVAL_STATE.DECLINED,
|
||||
decidedByEmployeeId: sender.id,
|
||||
});
|
||||
return approvalId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CreateComplianceExportInput,
|
||||
CreateLegalHoldInput,
|
||||
CreateRetentionFreezeInput,
|
||||
SetLegalHoldStateInput,
|
||||
SetRetentionFreezeStateInput,
|
||||
} from "../../shared/commands";
|
||||
import { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export class ComplianceService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createExport(input: CreateComplianceExportInput): string {
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.requestedByEmployeeId, "Requesting employee was not found.");
|
||||
this.assertSubject(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.assertDeleteAllowed(input);
|
||||
const payload = this.subjectPayload(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId);
|
||||
const requestedByEmployeeId = deletedEmployeeRequestor(input) ? null : input.requestedByEmployeeId;
|
||||
const id = createId("cmp");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into compliance_exports(
|
||||
id, org_id, subject_type, subject_id, export_type, state, payload_json,
|
||||
requested_by_employee_id, created_at, completed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.exportType,
|
||||
COMPLIANCE_EXPORT_STATE.COMPLETED,
|
||||
JSON.stringify(payload),
|
||||
requestedByEmployeeId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "compliance_export",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.requestedByEmployeeId,
|
||||
action: input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE ? "gdpr_deleted" : "created",
|
||||
payload: { subjectType: input.subjectType, subjectId: input.subjectId, exportType: input.exportType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createLegalHold(input: CreateLegalHoldInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Legal hold reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Legal hold employee was not found.");
|
||||
this.assertHoldEntity(input.orgId, input.entityType, input.entityId);
|
||||
const id = createId("hold");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into legal_holds(id, org_id, entity_type, entity_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.entityType, input.entityId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { entityType: input.entityType, entityId: input.entityId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Legal hold employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update legal_holds
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.legalHoldId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Legal hold was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: input.legalHoldId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Retention freeze reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Retention freeze employee was not found.");
|
||||
this.assertFreezeScope(input.orgId, input.scopeType, input.scopeId);
|
||||
const id = createId("frz");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into retention_freezes(id, org_id, scope_type, scope_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.scopeType, input.scopeId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { scopeType: input.scopeType, scopeId: input.scopeId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Retention freeze employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update retention_freezes
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.retentionFreezeId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Retention freeze was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: input.retentionFreezeId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
private subjectPayload(orgId: string, subjectType: string, subjectId: string): Record<string, unknown> {
|
||||
if (subjectType === "employee") {
|
||||
return {
|
||||
profile: this.one("employees", orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'employee' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsRequested: this.db.prepare("select * from approvals where org_id = ? and requested_by_type = 'employee' and requested_by_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsDecided: this.db.prepare("select * from approvals where org_id = ? and decided_by_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
employeeIdentities: this.db.prepare("select * from employee_identities where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
roleGrants: this.db.prepare("select * from role_grants where org_id = ? and principal_type = 'employee' and principal_id = ? order by created_at").all(orgId, subjectId),
|
||||
socialAccountsAuthorized: this.db.prepare("select * from social_accounts where org_id = ? and authorized_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
socialAccountGrants: this.db.prepare("select * from social_account_grants where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
providerAuthSessions: this.db.prepare("select * from provider_auth_sessions where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
scheduledPostsCreated: this.db.prepare("select * from scheduled_posts where org_id = ? and created_by_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
complianceExportsRequested: this.db.prepare("select * from compliance_exports where org_id = ? and requested_by_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'employee' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
profile: this.one("agents", orgId, subjectId),
|
||||
tasks: this.db.prepare("select * from tasks where org_id = ? and assignee_agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
taskRuns: this.db.prepare("select * from task_runs where org_id = ? and agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'agent' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
|
||||
private applyGdprDelete(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") {
|
||||
this.db.prepare("update employees set display_name = ?, role = ? where id = ? and org_id = ?")
|
||||
.run("Deleted employee", "Deleted", subjectId, orgId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'employee' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update approvals set requested_by_id = null where org_id = ? and requested_by_type = 'employee' and requested_by_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update approvals set decided_by_employee_id = null where org_id = ? and decided_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("delete from employee_identities where org_id = ? and employee_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("delete from chat_read_state where org_id = ? and employee_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("delete from social_account_grants where org_id = ? and employee_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("delete from provider_auth_sessions where org_id = ? and employee_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update social_accounts set authorized_employee_id = null where org_id = ? and authorized_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update social_approval_routes set route_to_id = null where org_id = ? and route_to_type = 'authorized_employee' and route_to_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update scheduled_posts set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update compliance_exports set requested_by_employee_id = null where org_id = ? and requested_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update legal_holds set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update retention_freezes set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update audit_replay_runs set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update rollback_snapshots set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update migration_reconciliation_items set resolved_by_employee_id = null where org_id = ? and resolved_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("delete from approval_assignments where org_id = ? and assignee_type = 'employee' and assignee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("delete from role_grants where org_id = ? and principal_type = 'employee' and principal_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update role_grants set granted_by_employee_id = null where org_id = ? and granted_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
return;
|
||||
}
|
||||
this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null where id = ? and org_id = ?")
|
||||
.run("Deleted agent", "Deleted", "archived", subjectId, orgId);
|
||||
this.db.prepare("update tasks set assignee_agent_id = null where org_id = ? and assignee_agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update task_runs set agent_id = null where org_id = ? and agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'agent' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
}
|
||||
|
||||
private assertDeleteAllowed(input: CreateComplianceExportInput): void {
|
||||
const legalHold = this.db.prepare(`
|
||||
select id from legal_holds
|
||||
where org_id = ? and active = 1
|
||||
and (
|
||||
(entity_type = ? and entity_id = ?)
|
||||
or (entity_type = 'organization' and entity_id = ?)
|
||||
or (
|
||||
entity_type = 'chat_thread'
|
||||
and exists (
|
||||
select 1 from chat_messages
|
||||
where org_id = ?
|
||||
and thread_id = legal_holds.entity_id
|
||||
and sender_type = ?
|
||||
and sender_id = ?
|
||||
)
|
||||
)
|
||||
or (
|
||||
entity_type = 'file_object'
|
||||
and exists (
|
||||
select 1 from file_objects
|
||||
where org_id = ?
|
||||
and id = legal_holds.entity_id
|
||||
and owner_type = ?
|
||||
and owner_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
`).get(
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.orgId,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
);
|
||||
const retentionFreeze = this.db.prepare(`
|
||||
select id from retention_freezes
|
||||
where org_id = ? and active = 1
|
||||
and (
|
||||
(scope_type = 'organization' and scope_id = ?)
|
||||
or (
|
||||
scope_type = 'chat_space'
|
||||
and exists (
|
||||
select 1 from chat_messages
|
||||
where org_id = ?
|
||||
and space_id = retention_freezes.scope_id
|
||||
and sender_type = ?
|
||||
and sender_id = ?
|
||||
)
|
||||
)
|
||||
or (
|
||||
scope_type = 'file_object'
|
||||
and exists (
|
||||
select 1 from file_objects
|
||||
where org_id = ?
|
||||
and id = retention_freezes.scope_id
|
||||
and owner_type = ?
|
||||
and owner_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
`).get(
|
||||
input.orgId,
|
||||
input.orgId,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
);
|
||||
if (legalHold || retentionFreeze) throw new Error("Active compliance hold prevents GDPR delete.");
|
||||
}
|
||||
|
||||
private one(table: "employees" | "agents", orgId: string, id: string): Row {
|
||||
const row = this.db.prepare(`select * from ${table} where id = ? and org_id = ?`).get(id, orgId) as Row | undefined;
|
||||
if (!row) throw new Error("Compliance subject was not found.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private assertSubject(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") return void this.assertEmployee(orgId, subjectId, "Compliance subject was not found.");
|
||||
if (subjectType === "agent") return void this.assertAgent(orgId, subjectId, "Compliance subject was not found.");
|
||||
throw new Error(`Unsupported compliance subject type: ${subjectType}`);
|
||||
}
|
||||
|
||||
private assertHoldEntity(orgId: string, entityType: string, entityId: string): void {
|
||||
if (entityType === "organization" && entityId === orgId) return;
|
||||
if (entityType === "employee") return void this.assertEmployee(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "agent") return void this.assertAgent(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "chat_thread") return void this.assertOrgEntity("chat_threads", orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "file_object") return void this.assertOrgEntity("file_objects", orgId, entityId, "Legal hold entity was not found.");
|
||||
throw new Error(`Unsupported legal hold entity type: ${entityType}`);
|
||||
}
|
||||
|
||||
private assertFreezeScope(orgId: string, scopeType: string, scopeId: string): void {
|
||||
if (scopeType === "organization" && scopeId === orgId) return;
|
||||
if (scopeType === "chat_space") return void this.assertOrgEntity("chat_spaces", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
if (scopeType === "file_object") return void this.assertOrgEntity("file_objects", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
throw new Error(`Unsupported retention freeze scope type: ${scopeType}`);
|
||||
}
|
||||
|
||||
private assertOrg(orgId: string): void {
|
||||
const row = this.db.prepare("select id from organizations where id = ?").get(orgId);
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
this.assertOrgEntity("employees", orgId, employeeId, message);
|
||||
}
|
||||
|
||||
private assertAgent(orgId: string, agentId: string, message: string): void {
|
||||
this.assertOrgEntity("agents", orgId, agentId, message);
|
||||
}
|
||||
|
||||
private assertOrgEntity(table: "employees" | "agents" | "chat_threads" | "file_objects" | "chat_spaces", orgId: string, id: string, message: string): void {
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId);
|
||||
if (!row) throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function deletedEmployeeRequestor(input: CreateComplianceExportInput): boolean {
|
||||
return input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE
|
||||
&& input.subjectType === "employee"
|
||||
&& input.subjectId === input.requestedByEmployeeId;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { net } from "electron";
|
||||
|
||||
const BASE_URL = "https://backend.composio.dev/api/v3";
|
||||
|
||||
export interface ComposioToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
toolkitSlug: string | null;
|
||||
parameters: {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposioToolkitSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
categories: string[];
|
||||
authSchemes: string[];
|
||||
noAuth: boolean;
|
||||
toolsCount: number;
|
||||
}
|
||||
|
||||
export interface ComposioConnectedAccount {
|
||||
id: string;
|
||||
toolkitSlug: string;
|
||||
status: string;
|
||||
authConfigId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ComposioToolExecutionResult {
|
||||
data: Record<string, unknown>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class ComposioClient {
|
||||
constructor(private readonly apiKey: string) {}
|
||||
|
||||
/** The toolkit catalog, most popular first. */
|
||||
async listToolkits(limit: number): Promise<ComposioToolkitSummary[]> {
|
||||
const toolkits: ComposioToolkitSummary[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (toolkits.length < limit) {
|
||||
const params = new URLSearchParams({ limit: String(Math.min(100, limit - toolkits.length)) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const response = await this.request("GET", `${BASE_URL}/toolkits?${params.toString()}`);
|
||||
const items = Array.isArray(response.items) ? response.items as Array<Record<string, unknown>> : [];
|
||||
for (const item of items) {
|
||||
const meta = (item.meta ?? {}) as Record<string, unknown>;
|
||||
toolkits.push({
|
||||
slug: String(item.slug ?? ""),
|
||||
name: String(item.name ?? ""),
|
||||
description: String(meta.description ?? "").slice(0, 200),
|
||||
logo: typeof meta.logo === "string" ? meta.logo : null,
|
||||
categories: Array.isArray(meta.categories)
|
||||
? (meta.categories as Array<Record<string, unknown>>).map((c) => String(c.name ?? c.id ?? "")).filter(Boolean)
|
||||
: [],
|
||||
authSchemes: Array.isArray(item.auth_schemes) ? (item.auth_schemes as unknown[]).map(String) : [],
|
||||
noAuth: item.no_auth === true,
|
||||
toolsCount: Number(meta.tools_count ?? 0),
|
||||
});
|
||||
}
|
||||
cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null;
|
||||
if (!cursor || items.length === 0) break;
|
||||
}
|
||||
return toolkits;
|
||||
}
|
||||
|
||||
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
const all: ComposioToolSchema[] = [];
|
||||
for (const toolkit of toolkits) {
|
||||
const params = new URLSearchParams({ toolkit_slug: toolkit, limit: "20" });
|
||||
const url = `${BASE_URL}/tools?${params.toString()}`;
|
||||
const response = await this.request("GET", url);
|
||||
if (Array.isArray(response.items)) {
|
||||
all.push(...(response.items as Array<Record<string, unknown>>).map(normalizeToolSchema));
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Creates a Composio-managed auth config for a toolkit. */
|
||||
async createAuthConfig(toolkitSlug: string): Promise<string> {
|
||||
const response = await this.request("POST", `${BASE_URL}/auth_configs`, {
|
||||
toolkit: { slug: toolkitSlug },
|
||||
});
|
||||
const authConfig = (response.auth_config ?? {}) as Record<string, unknown>;
|
||||
const id = String(authConfig.id ?? "");
|
||||
if (!id) throw new Error("Composio auth config creation returned no id.");
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Starts a connection for a user; the redirect URL completes it in the browser. */
|
||||
async createConnectedAccount(input: { authConfigId: string; userId: string }): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
redirectUrl: string | null;
|
||||
}> {
|
||||
const response = await this.request("POST", `${BASE_URL}/connected_accounts`, {
|
||||
auth_config: { id: input.authConfigId },
|
||||
connection: { user_id: input.userId },
|
||||
});
|
||||
return {
|
||||
id: String(response.id ?? ""),
|
||||
status: String(response.status ?? ""),
|
||||
redirectUrl: typeof response.redirect_url === "string" ? response.redirect_url : null,
|
||||
};
|
||||
}
|
||||
|
||||
async listConnectedAccounts(userId: string): Promise<ComposioConnectedAccount[]> {
|
||||
const params = new URLSearchParams({ user_ids: userId, limit: "50" });
|
||||
const response = await this.request("GET", `${BASE_URL}/connected_accounts?${params.toString()}`);
|
||||
if (!Array.isArray(response.items)) return [];
|
||||
return (response.items as Array<Record<string, unknown>>).map((item) => {
|
||||
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
|
||||
const authConfig = (item.auth_config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(item.id ?? ""),
|
||||
toolkitSlug: String(toolkit.slug ?? "").toLowerCase(),
|
||||
status: String(item.status ?? ""),
|
||||
authConfigId: String(authConfig.id ?? ""),
|
||||
createdAt: String(item.created_at ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async executeTool(input: {
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
userId: string;
|
||||
connectedAccountId?: string;
|
||||
}): Promise<ComposioToolExecutionResult> {
|
||||
const url = `${BASE_URL}/tools/execute/${encodeURIComponent(input.toolName)}`;
|
||||
const body: Record<string, unknown> = {
|
||||
user_id: input.userId,
|
||||
arguments: input.arguments,
|
||||
};
|
||||
if (input.connectedAccountId) body.connected_account_id = input.connectedAccountId;
|
||||
const response = await this.request("POST", url, body);
|
||||
return {
|
||||
data: typeof response === "object" && response !== null ? response as Record<string, unknown> : {},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async request(method: string, url: string, body?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = net.request({ method, url });
|
||||
request.setHeader("x-api-key", this.apiKey);
|
||||
request.setHeader("Accept", "application/json");
|
||||
if (body) request.setHeader("Content-Type", "application/json");
|
||||
|
||||
let responseData = "";
|
||||
request.on("response", (response) => {
|
||||
response.on("data", (chunk) => { responseData += chunk.toString(); });
|
||||
response.on("end", () => {
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
reject(new Error(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(responseData) as Record<string, unknown>);
|
||||
} catch {
|
||||
reject(new Error(`Composio API returned non-JSON response: ${responseData.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
response.on("error", reject);
|
||||
});
|
||||
request.on("error", reject);
|
||||
if (body) request.write(JSON.stringify(body));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolSchema(raw: Record<string, unknown>): ComposioToolSchema {
|
||||
const params = raw.input_parameters ?? raw.parameters ?? raw.inputSchema ?? {};
|
||||
const schema = typeof params === "object" && params !== null ? params as Record<string, unknown> : {};
|
||||
const toolkit = (raw.toolkit ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
name: String(raw.slug ?? raw.name ?? ""),
|
||||
description: String(raw.description ?? "").slice(0, 300),
|
||||
toolkitSlug: typeof toolkit.slug === "string" ? toolkit.slug : null,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: (schema.properties ?? {}) as Record<string, unknown>,
|
||||
required: Array.isArray(schema.required) ? schema.required as string[] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient, type ComposioToolkitSummary } from "./composio-client";
|
||||
import { resolveComposioApiKey } from "./composio-credentials";
|
||||
import { getComposioUserId } from "./composio-tool-bridge";
|
||||
import { normalizeSlug } from "./connector-grants";
|
||||
|
||||
const TOOLKIT_CATALOG_LIMIT = 200;
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | null = null;
|
||||
|
||||
/**
|
||||
* Company-level Composio connections. One connected account per toolkit per
|
||||
* organization, mirrored into connector_accounts so agent runs and the
|
||||
* Connectors page read local state instead of calling out on every frame.
|
||||
*/
|
||||
export class ComposioConnectionService {
|
||||
constructor(private readonly db: DatabaseSync, private readonly credentialFileDir: string) {}
|
||||
|
||||
listToolkits(orgId: string): Promise<ComposioToolkitSummaryDto[]> {
|
||||
const client = this.requireClient(orgId);
|
||||
return this.catalog(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the company's connection for a toolkit: creates a
|
||||
* Composio-managed auth config once per toolkit, initiates the connected
|
||||
* account for the company user, and records the pending account locally.
|
||||
* The returned redirect URL completes authorization in the browser.
|
||||
*/
|
||||
async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise<ConnectComposioToolkitResult> {
|
||||
const slug = normalizeSlug(toolkitSlug);
|
||||
const client = this.requireClient(orgId);
|
||||
const authConfigId = await this.ensureAuthConfig(client, orgId, slug);
|
||||
const account = await client.createConnectedAccount({
|
||||
authConfigId,
|
||||
userId: getComposioUserId(this.db, orgId),
|
||||
});
|
||||
if (!account.id) throw new Error("Composio did not return a connected account id.");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, toolkit_slug) do update set
|
||||
id = excluded.id,
|
||||
toolkit_name = excluded.toolkit_name,
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(account.id, orgId, slug, toolkitName.trim() || slug, authConfigId, account.status || "INITIATED", now, now);
|
||||
return { accountId: account.id, status: account.status, redirectUrl: account.redirectUrl };
|
||||
}
|
||||
|
||||
/** Pulls account statuses from Composio and updates the local mirror. */
|
||||
async refreshAccounts(orgId: string): Promise<void> {
|
||||
const client = this.requireClient(orgId);
|
||||
const accounts = await client.listConnectedAccounts(getComposioUserId(this.db, orgId));
|
||||
const now = new Date().toISOString();
|
||||
for (const account of accounts) {
|
||||
if (!account.toolkitSlug) continue;
|
||||
this.db.prepare(`
|
||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, toolkit_slug) do update set
|
||||
id = excluded.id,
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
account.id,
|
||||
orgId,
|
||||
account.toolkitSlug,
|
||||
account.toolkitSlug,
|
||||
account.authConfigId,
|
||||
account.status,
|
||||
account.createdAt || now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private requireClient(orgId: string): ComposioClient {
|
||||
const apiKey = resolveComposioApiKey(this.db, orgId, this.credentialFileDir);
|
||||
if (!apiKey) throw new Error("Composio API key is not configured for this company.");
|
||||
return new ComposioClient(apiKey);
|
||||
}
|
||||
|
||||
private async ensureAuthConfig(client: ComposioClient, orgId: string, slug: string): Promise<string> {
|
||||
const key = `auth_config:${slug}`;
|
||||
const existing = this.db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = ? limit 1",
|
||||
).get(orgId, key) as { value: string } | undefined;
|
||||
if (existing?.value) return existing.value;
|
||||
const authConfigId = await client.createAuthConfig(slug);
|
||||
this.db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run(orgId, key, authConfigId, new Date().toISOString());
|
||||
return authConfigId;
|
||||
}
|
||||
|
||||
private async catalog(client: ComposioClient): Promise<ComposioToolkitSummaryDto[]> {
|
||||
if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) {
|
||||
return catalogCache.toolkits;
|
||||
}
|
||||
const toolkits = await client.listToolkits(TOOLKIT_CATALOG_LIMIT);
|
||||
catalogCache = { fetchedAt: Date.now(), toolkits };
|
||||
return toolkits;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* The org's Composio API key. When the database has none, a developer
|
||||
* credential file next to the app (cred.txt, first line "Composio apikey:
|
||||
* ak_...", gitignored) seeds it once for this org — the product owner's
|
||||
* local path. Packaged installs configure the key in Settings instead; a
|
||||
* missing file simply yields null.
|
||||
*/
|
||||
export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDir: string = process.cwd()): string | null {
|
||||
const stored = db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = 'api_key' limit 1",
|
||||
).get(orgId) as { value: string } | undefined;
|
||||
if (stored?.value) return stored.value;
|
||||
|
||||
const path = join(credentialFileDir, "cred.txt");
|
||||
if (!existsSync(path)) return null;
|
||||
const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? "";
|
||||
const key = firstLine.split(":").slice(1).join(":").trim();
|
||||
if (!key) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const upsert = db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`);
|
||||
upsert.run(orgId, "api_key", key, now);
|
||||
upsert.run(orgId, "entity_id", orgId, now);
|
||||
return key;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ToolDefinition, ToolCall } from "../provider-tool-turn";
|
||||
import { ComposioClient, type ComposioToolSchema } from "./composio-client";
|
||||
import { resolveComposioApiKey } from "./composio-credentials";
|
||||
import { ConnectorGrantsService } from "./connector-grants";
|
||||
|
||||
const COMPOSIO_TOOL_PREFIX = "composio_";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CacheEntry {
|
||||
tools: ToolDefinition[];
|
||||
fetchedAt: number;
|
||||
toolkitsKey: string;
|
||||
}
|
||||
|
||||
const toolCache = new Map<string, CacheEntry>();
|
||||
/** tool slug (upper case) → toolkit slug, learned from the tools listing. */
|
||||
const toolkitByTool = new Map<string, string>();
|
||||
|
||||
export function isComposioTool(toolName: string): boolean {
|
||||
return toolName.startsWith(COMPOSIO_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
export function composioToolName(composioName: string): string {
|
||||
return `${COMPOSIO_TOOL_PREFIX}${composioName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function originalToolName(prefixedName: string): string {
|
||||
return prefixedName.slice(COMPOSIO_TOOL_PREFIX.length).toUpperCase();
|
||||
}
|
||||
|
||||
/** Toolkit a tool belongs to: learned mapping first, slug prefix as contract. */
|
||||
function toolkitForTool(toolSlug: string): string {
|
||||
return toolkitByTool.get(toolSlug) ?? toolSlug.split("_")[0].toLowerCase();
|
||||
}
|
||||
|
||||
export function composioSchemaToToolDefinition(schema: ComposioToolSchema): ToolDefinition {
|
||||
return {
|
||||
name: composioToolName(schema.name),
|
||||
description: schema.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: schema.parameters.properties,
|
||||
required: schema.parameters.required ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The connector tools one agent is allowed to carry into a run: the
|
||||
* intersection of the company's active connections and the toolkits the CEO
|
||||
* granted this agent.
|
||||
*/
|
||||
export async function loadComposioTools(db: DatabaseSync, orgId: string, agentId: string): Promise<ToolDefinition[]> {
|
||||
const apiKey = getComposioApiKey(db, orgId);
|
||||
if (!apiKey) return [];
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const toolkits = grants.toolkitsForAgent(orgId, agentId);
|
||||
if (toolkits.length === 0) return [];
|
||||
const toolkitsKey = toolkits.join(",");
|
||||
const cacheKey = `${orgId}:${toolkitsKey}`;
|
||||
const cached = toolCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.tools;
|
||||
}
|
||||
try {
|
||||
const client = new ComposioClient(apiKey);
|
||||
const schemas = await client.getTools(toolkits);
|
||||
for (const schema of schemas) {
|
||||
if (schema.toolkitSlug) toolkitByTool.set(schema.name.toUpperCase(), schema.toolkitSlug.toLowerCase());
|
||||
}
|
||||
const tools = schemas.map(composioSchemaToToolDefinition);
|
||||
toolCache.set(cacheKey, { tools, fetchedAt: Date.now(), toolkitsKey });
|
||||
return tools;
|
||||
} catch {
|
||||
return cached?.tools ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeComposioTool(
|
||||
db: DatabaseSync,
|
||||
orgId: string,
|
||||
agentId: string,
|
||||
call: ToolCall,
|
||||
): Promise<string> {
|
||||
const apiKey = getComposioApiKey(db, orgId);
|
||||
if (!apiKey) return "ERROR: Composio API key is not configured for this company.";
|
||||
const toolSlug = originalToolName(call.name);
|
||||
const toolkit = toolkitForTool(toolSlug);
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const allowed = grants.toolkitsForAgent(orgId, agentId);
|
||||
if (!allowed.includes(toolkit)) {
|
||||
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
|
||||
}
|
||||
const account = db.prepare(
|
||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
|
||||
).get(orgId, toolkit) as { id: string } | undefined;
|
||||
if (!account) {
|
||||
return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`;
|
||||
}
|
||||
const client = new ComposioClient(apiKey);
|
||||
try {
|
||||
const result = await client.executeTool({
|
||||
toolName: toolSlug,
|
||||
arguments: call.arguments,
|
||||
userId: getComposioUserId(db, orgId),
|
||||
connectedAccountId: account.id,
|
||||
});
|
||||
if (result.error) return `ERROR: ${result.error}`;
|
||||
return JSON.stringify(result.data);
|
||||
} catch (error) {
|
||||
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getComposioApiKey(db: DatabaseSync, orgId: string): string | null {
|
||||
return resolveComposioApiKey(db, orgId);
|
||||
}
|
||||
|
||||
export function getComposioUserId(db: DatabaseSync, orgId: string): string {
|
||||
const row = db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = 'entity_id' limit 1",
|
||||
).get(orgId) as { value: string } | undefined;
|
||||
return row?.value ?? orgId;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../../shared/ids";
|
||||
|
||||
export interface ConnectorAccountRow {
|
||||
id: string;
|
||||
orgId: string;
|
||||
toolkitSlug: string;
|
||||
toolkitName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ConnectorGrantRow {
|
||||
id: string;
|
||||
orgId: string;
|
||||
agentId: string;
|
||||
toolkitSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which agents may use which connected toolkits. Connections belong to the
|
||||
* company (one connected account per toolkit per org); the CEO hands
|
||||
* individual toolkits to individual agents, and both the tool loadout and
|
||||
* the execution guard read from these grants.
|
||||
*/
|
||||
export class ConnectorGrantsService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
grant(input: { orgId: string; agentId: string; toolkitSlug: string; grantedByType: "agent" | "employee"; grantedById: string | null }): void {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
|
||||
.get(input.agentId, input.orgId);
|
||||
if (!agent) throw new Error("Grant target must be an active agent in this organization.");
|
||||
const slug = normalizeSlug(input.toolkitSlug);
|
||||
const account = this.db.prepare(
|
||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
|
||||
).get(input.orgId, slug);
|
||||
if (!account) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`);
|
||||
this.db.prepare(`
|
||||
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, granted_by_type, granted_by_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, agent_id, toolkit_slug) do nothing
|
||||
`).run(createId("cgr"), input.orgId, input.agentId, slug, input.grantedByType, input.grantedById, new Date().toISOString());
|
||||
}
|
||||
|
||||
revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void {
|
||||
this.db.prepare("delete from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?")
|
||||
.run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug));
|
||||
}
|
||||
|
||||
/** Toolkits this agent may use: granted AND actively connected. */
|
||||
toolkitsForAgent(orgId: string, agentId: string): string[] {
|
||||
const rows = this.db.prepare(`
|
||||
select g.toolkit_slug as slug
|
||||
from agent_connector_grants g
|
||||
join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.status = 'ACTIVE'
|
||||
where g.org_id = ? and g.agent_id = ?
|
||||
order by g.toolkit_slug asc
|
||||
`).all(orgId, agentId) as Array<{ slug: string }>;
|
||||
return rows.map((row) => row.slug);
|
||||
}
|
||||
|
||||
activeAccounts(orgId: string): ConnectorAccountRow[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, org_id, toolkit_slug, toolkit_name, status
|
||||
from connector_accounts
|
||||
where org_id = ? and status = 'ACTIVE'
|
||||
order by toolkit_slug asc
|
||||
`).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; status: string }>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
orgId: row.org_id,
|
||||
toolkitSlug: row.toolkit_slug,
|
||||
toolkitName: row.toolkit_name,
|
||||
status: row.status,
|
||||
}));
|
||||
}
|
||||
|
||||
grantsForOrg(orgId: string): ConnectorGrantRow[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, org_id, agent_id, toolkit_slug
|
||||
from agent_connector_grants
|
||||
where org_id = ?
|
||||
order by toolkit_slug asc
|
||||
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string }>;
|
||||
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug }));
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSlug(slug: string): string {
|
||||
const normalized = slug.trim().toLowerCase();
|
||||
if (!normalized) throw new Error("Toolkit slug is required.");
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveWorkspacePath } from "./files/workspace-path";
|
||||
|
||||
/**
|
||||
* Writes an agent deliverable into the run's bound workspace folder.
|
||||
*
|
||||
* The target path is resolved against the workspace root and rejected if it
|
||||
* escapes that root (path traversal / absolute paths), so an agent can only
|
||||
* write inside the folder the operator bound to the company.
|
||||
*/
|
||||
export function writeDeliverable(input: { workspaceRoot: string; relativePath: string; content: string }): string {
|
||||
const { absolute, relativePosix } = resolveWorkspacePath(input.workspaceRoot, input.relativePath);
|
||||
mkdirSync(dirname(absolute), { recursive: true });
|
||||
writeFileSync(absolute, input.content, "utf8");
|
||||
return relativePosix;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateDocumentInput, UpdateDocumentInput, DeleteDocumentInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class DocumentService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateDocumentInput): string {
|
||||
const id = createId("doc");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into documents(id, org_id, title, doc_type, content, status, author_agent_id, reviewer_agent_id, project_id, goal_id, task_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.docType, input.content, "draft", input.authorAgentId, input.reviewerAgentId, input.projectId, input.goalId, input.taskId, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "document",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { title: input.title.trim(), docType: input.docType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
update(input: UpdateDocumentInput): void {
|
||||
const result = this.db.prepare(`
|
||||
update documents set title = ?, content = ?, status = ?, updated_at = ? where id = ? and org_id = ?
|
||||
`).run(input.title.trim(), input.content, input.status, new Date().toISOString(), input.documentId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Document was not found.");
|
||||
}
|
||||
|
||||
delete(input: DeleteDocumentInput): void {
|
||||
const result = this.db.prepare("delete from documents where id = ? and org_id = ?").run(input.documentId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Document was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import type { FileScanState } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import type { MentionTarget, ResolvedMention } from "./chat-mentions";
|
||||
|
||||
export interface FileHandoffAttachment {
|
||||
fileId: string;
|
||||
checksum: string;
|
||||
scanState: FileScanState;
|
||||
scanLabelsJson: string;
|
||||
objectRef: string;
|
||||
}
|
||||
|
||||
interface RecordFileHandoffsInput {
|
||||
orgId: string;
|
||||
spaceId: string;
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
senderType: SendMessageInput["senderType"];
|
||||
senderId: string | null;
|
||||
attachments: FileHandoffAttachment[];
|
||||
mentions: ResolvedMention[];
|
||||
now: string;
|
||||
}
|
||||
|
||||
export class FileHandoffService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
record(input: RecordFileHandoffsInput): void {
|
||||
if (input.attachments.length === 0) return;
|
||||
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, input.mentions);
|
||||
const attachmentStatement = this.db.prepare(`
|
||||
insert into chat_attachments(message_id, file_id, transfer_type, checksum, preview_key)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const grantStatement = this.db.prepare(`
|
||||
insert or ignore into file_access_grants(
|
||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||
source_message_id, source_thread_id, access_level, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const handoffStatement = this.db.prepare(`
|
||||
insert or ignore into file_handoff_records(
|
||||
id, org_id, message_id, file_id, source_principal_type, source_principal_id,
|
||||
target_principal_type, target_principal_id, transfer_type, checksum, scan_state,
|
||||
scan_labels_json, policy_decision, object_ref, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const attachment of input.attachments) {
|
||||
attachmentStatement.run(input.messageId, attachment.fileId, "handoff", attachment.checksum, null);
|
||||
for (const recipient of recipients) {
|
||||
const grant = grantStatement.run(
|
||||
createId("fgr"),
|
||||
input.orgId,
|
||||
attachment.fileId,
|
||||
recipient.type,
|
||||
recipient.id,
|
||||
input.senderType,
|
||||
input.senderId,
|
||||
input.messageId,
|
||||
input.threadId,
|
||||
"read",
|
||||
input.now,
|
||||
);
|
||||
handoffStatement.run(
|
||||
createId("fhr"),
|
||||
input.orgId,
|
||||
input.messageId,
|
||||
attachment.fileId,
|
||||
input.senderType,
|
||||
input.senderId,
|
||||
recipient.type,
|
||||
recipient.id,
|
||||
"handoff",
|
||||
attachment.checksum,
|
||||
attachment.scanState,
|
||||
attachment.scanLabelsJson,
|
||||
"approved_for_handoff",
|
||||
attachment.objectRef,
|
||||
input.now,
|
||||
);
|
||||
if (grant.changes > 0) this.recordGrantAudit(input, attachment.fileId, recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private recordGrantAudit(input: RecordFileHandoffsInput, fileId: string, recipient: MentionTarget): void {
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "file_object",
|
||||
entityId: fileId,
|
||||
actorType: input.senderType,
|
||||
actorId: input.senderId,
|
||||
action: "access_granted",
|
||||
payload: {
|
||||
messageId: input.messageId,
|
||||
threadId: input.threadId,
|
||||
grantedToType: recipient.type,
|
||||
grantedToId: recipient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] {
|
||||
const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId);
|
||||
const seen = new Set<string>();
|
||||
const recipients: MentionTarget[] = [];
|
||||
for (const target of source) {
|
||||
const key = `${target.type}:${target.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(target);
|
||||
}
|
||||
if (recipients.length === 0) throw new Error("Attachment grant recipient is required.");
|
||||
return recipients.map((recipient) => recipient.type === "org_group" ? recipient : this.requireRecipient(orgId, recipient));
|
||||
}
|
||||
|
||||
private requireRecipient(orgId: string, recipient: MentionTarget): MentionTarget {
|
||||
if (recipient.type === "board") return recipient;
|
||||
const table = recipient.type === "employee" ? "employees" : "agents";
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`)
|
||||
.get(recipient.id, orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Attachment grant recipient was not found.");
|
||||
return recipient;
|
||||
}
|
||||
|
||||
private spaceRecipients(spaceId: string): MentionTarget[] {
|
||||
const rows = this.db.prepare(`
|
||||
select principal_type, principal_id
|
||||
from chat_memberships
|
||||
where space_id = ?
|
||||
order by principal_type asc, principal_id asc
|
||||
`).all(spaceId) as Array<{ principal_type: string; principal_id: string }>;
|
||||
return rows.map((row) => {
|
||||
if (row.principal_type === "employee" || row.principal_type === "agent") {
|
||||
return { type: row.principal_type, id: row.principal_id };
|
||||
}
|
||||
throw new Error("Chat membership principal type is unsupported.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { dialog, shell } from "electron";
|
||||
import type { ImportFileInput, OpenFileInput } from "../../shared/commands";
|
||||
import { FILE_SCAN_STATE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { fileKind, scanFileContent, upsertFileObject } from "./files/file-object-store";
|
||||
|
||||
export class FileService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly artifactRoot: string,
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
mkdirSync(this.artifactRoot, { recursive: true });
|
||||
}
|
||||
|
||||
async importFile(input: ImportFileInput): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
securityScopedBookmarks: true,
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return this.importPath(input.orgId, result.filePaths[0]);
|
||||
}
|
||||
|
||||
importPath(orgId: string, sourcePath: string): string {
|
||||
const id = createId("fil");
|
||||
const now = new Date().toISOString();
|
||||
const stats = statSync(sourcePath);
|
||||
if (!stats.isFile()) throw new Error("Selected path is not a file.");
|
||||
const name = basename(sourcePath);
|
||||
const bytes = readFileSync(sourcePath);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
const scan = scanFileContent(name, bytes);
|
||||
const orgDir = join(this.artifactRoot, orgId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
const target = join(orgDir, `${id}-${name}`);
|
||||
copyFileSync(sourcePath, target);
|
||||
this.db.exec("begin immediate");
|
||||
try {
|
||||
upsertFileObject(this.db, {
|
||||
id,
|
||||
orgId,
|
||||
name,
|
||||
kind: fileKind(name),
|
||||
sizeBytes: stats.size,
|
||||
sha256: hash,
|
||||
storagePath: target,
|
||||
ownerType: "system",
|
||||
ownerId: null,
|
||||
scan,
|
||||
now,
|
||||
});
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "imported",
|
||||
payload: { sha256: hash, sizeBytes: stats.size, scanState: scan.state, scanLabels: scan.labels, objectRef: target },
|
||||
});
|
||||
this.db.exec("commit");
|
||||
} catch (error) {
|
||||
this.db.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async openFile(input: OpenFileInput): Promise<void> {
|
||||
const row = this.db.prepare(
|
||||
"select storage_path as storagePath, scan_state as scanState from file_objects where id = ? and org_id = ?",
|
||||
).get(input.fileId, input.orgId) as { storagePath: string; scanState: string } | undefined;
|
||||
if (!row) throw new Error("File was not found.");
|
||||
if (row.scanState !== FILE_SCAN_STATE.APPROVED) throw new Error("Blocked files cannot be opened.");
|
||||
const failure = await shell.openPath(row.storagePath);
|
||||
if (failure) throw new Error(failure);
|
||||
}
|
||||
|
||||
setPinned(orgId: string, fileId: string, pinned: boolean): void {
|
||||
const result = this.db.prepare(
|
||||
"update file_objects set pinned = ?, updated_at = ? where id = ? and org_id = ?",
|
||||
).run(pinned ? 1 : 0, new Date().toISOString(), fileId, orgId);
|
||||
if (result.changes === 0) throw new Error("File was not found.");
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: fileId,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: pinned ? "pinned" : "unpinned",
|
||||
payload: { fileId },
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, extname } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { FILE_SCAN_STATE, type FileScanState } from "../../../shared/domain";
|
||||
import { createId } from "../../../shared/ids";
|
||||
import { resolveWorkspacePath } from "./workspace-path";
|
||||
|
||||
export interface FileScanResult {
|
||||
state: FileScanState;
|
||||
labels: string[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
interface TextScanPattern {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
block: boolean;
|
||||
}
|
||||
|
||||
const TEXT_SCAN_PATTERNS: TextScanPattern[] = [
|
||||
{ label: "dlp:private-key", pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----/i, block: true },
|
||||
{ label: "dlp:aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/, block: true },
|
||||
{ label: "dlp:github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, block: true },
|
||||
{ label: "dlp:provider-secret", pattern: /\bsk-[A-Za-z0-9]{32,}\b/, block: true },
|
||||
{ label: "dlp:credential", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{20,}/i, block: true },
|
||||
{ label: "dlp:ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, block: true },
|
||||
{ label: "dlp:email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, block: false },
|
||||
];
|
||||
|
||||
export function fileKind(path: string): string {
|
||||
const ext = extname(path).toLowerCase().replace(".", "");
|
||||
if (!ext) return "file";
|
||||
if (["md", "markdown"].includes(ext)) return "md";
|
||||
if (["pdf"].includes(ext)) return "pdf";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img";
|
||||
if (["csv", "xlsx", "xls"].includes(ext)) return "sheet";
|
||||
if (["doc", "docx", "txt"].includes(ext)) return "doc";
|
||||
return ext;
|
||||
}
|
||||
|
||||
export function scanFileContent(name: string, bytes: Buffer): FileScanResult {
|
||||
const textSample = sampleText(bytes);
|
||||
const labels = new Set(scanLabels(name, bytes.length));
|
||||
let blocked = false;
|
||||
if (bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1").includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) {
|
||||
labels.add("av:eicar");
|
||||
blocked = true;
|
||||
}
|
||||
for (const scan of TEXT_SCAN_PATTERNS) {
|
||||
if (!scan.pattern.test(textSample)) continue;
|
||||
labels.add(scan.label);
|
||||
if (scan.block) blocked = true;
|
||||
}
|
||||
if (containsPaymentCard(textSample)) {
|
||||
labels.add("dlp:payment-card");
|
||||
blocked = true;
|
||||
}
|
||||
labels.add(blocked ? "classification:restricted" : labels.has("dlp:email") ? "classification:confidential" : "classification:internal");
|
||||
return {
|
||||
state: blocked ? FILE_SCAN_STATE.BLOCKED : FILE_SCAN_STATE.APPROVED,
|
||||
labels: [...labels].sort(),
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileObjectInput {
|
||||
/** Optional explicit id; defaults to a generated `fil` id on insert. */
|
||||
id?: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
storagePath: string;
|
||||
ownerType: string;
|
||||
ownerId: string | null;
|
||||
scan: FileScanResult;
|
||||
now: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts (or refreshes, keyed on org_id + storage_path) a file object and, on
|
||||
* first insert, grants the workspace org-group read/manage access so the file
|
||||
* surfaces on the Files page and can be attached in chat. Re-writing the same
|
||||
* path updates the existing row instead of creating a duplicate.
|
||||
*/
|
||||
export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): string {
|
||||
const existing = db.prepare("select id from file_objects where org_id = ? and storage_path = ?")
|
||||
.get(input.orgId, input.storagePath) as { id: string } | undefined;
|
||||
const labelsJson = JSON.stringify(input.scan.labels);
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
update file_objects
|
||||
set name = ?, kind = ?, size_bytes = ?, sha256 = ?, owner_type = ?, owner_id = ?,
|
||||
scan_state = ?, scan_labels_json = ?, scanned_at = ?, updated_at = ?
|
||||
where id = ?
|
||||
`).run(
|
||||
input.name,
|
||||
input.kind,
|
||||
input.sizeBytes,
|
||||
input.sha256,
|
||||
input.ownerType,
|
||||
input.ownerId,
|
||||
input.scan.state,
|
||||
labelsJson,
|
||||
input.scan.scannedAt,
|
||||
input.now,
|
||||
existing.id,
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
const id = input.id ?? createId("fil");
|
||||
db.prepare(`
|
||||
insert into file_objects(
|
||||
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
||||
owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.name,
|
||||
input.kind,
|
||||
input.sizeBytes,
|
||||
input.sha256,
|
||||
input.storagePath,
|
||||
input.ownerType,
|
||||
input.ownerId,
|
||||
null,
|
||||
0,
|
||||
input.scan.state,
|
||||
labelsJson,
|
||||
input.scan.scannedAt,
|
||||
input.now,
|
||||
input.now,
|
||||
);
|
||||
grantWorkspaceAccess(db, input.orgId, id, input.now);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a file an agent wrote into the bound workspace as a first-class file
|
||||
* object so it appears under that agent's department on the Files page.
|
||||
*/
|
||||
export function recordWorkspaceFile(db: DatabaseSync, input: {
|
||||
orgId: string;
|
||||
ownerType: string;
|
||||
ownerId: string | null;
|
||||
workspaceRoot: string;
|
||||
relativePath: string;
|
||||
}): string {
|
||||
const { absolute } = resolveWorkspacePath(input.workspaceRoot, input.relativePath);
|
||||
const bytes = readFileSync(absolute);
|
||||
const name = basename(input.relativePath);
|
||||
const scan = scanFileContent(name, bytes);
|
||||
const now = new Date().toISOString();
|
||||
db.exec("begin immediate");
|
||||
try {
|
||||
const id = upsertFileObject(db, {
|
||||
orgId: input.orgId,
|
||||
name,
|
||||
kind: fileKind(name),
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
storagePath: absolute,
|
||||
ownerType: input.ownerType,
|
||||
ownerId: input.ownerId,
|
||||
scan,
|
||||
now,
|
||||
});
|
||||
db.exec("commit");
|
||||
return id;
|
||||
} catch (error) {
|
||||
db.exec("rollback");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function grantWorkspaceAccess(db: DatabaseSync, orgId: string, fileId: string, now: string): void {
|
||||
db.prepare(`
|
||||
insert into file_access_grants(
|
||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||
source_message_id, source_thread_id, access_level, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("fgr"),
|
||||
orgId,
|
||||
fileId,
|
||||
"org_group",
|
||||
`${orgId}:workspace`,
|
||||
"system",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"manage",
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function scanLabels(name: string, sizeBytes: number): string[] {
|
||||
const labels = new Set<string>([`kind:${fileKind(name)}`]);
|
||||
if (sizeBytes === 0) labels.add("empty");
|
||||
if (sizeBytes > 100 * 1024 * 1024) labels.add("large");
|
||||
return [...labels].sort();
|
||||
}
|
||||
|
||||
function sampleText(bytes: Buffer): string {
|
||||
return bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("utf8");
|
||||
}
|
||||
|
||||
function containsPaymentCard(text: string): boolean {
|
||||
const matches = text.match(/\b(?:\d[ -]?){13,19}\b/g) ?? [];
|
||||
return matches.some((match) => luhnValid(match.replace(/[ -]/g, "")));
|
||||
}
|
||||
|
||||
function luhnValid(value: string): boolean {
|
||||
if (!/^\d{13,19}$/.test(value)) return false;
|
||||
let sum = 0;
|
||||
let doubleDigit = false;
|
||||
for (let index = value.length - 1; index >= 0; index -= 1) {
|
||||
let digit = Number(value[index]);
|
||||
if (doubleDigit) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
doubleDigit = !doubleDigit;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* Resolves a caller-supplied relative path against a workspace root and rejects
|
||||
* anything that escapes it (absolute paths or `..` traversal). Both writing a
|
||||
* deliverable and ingesting one back as a file object go through here, so an
|
||||
* agent-supplied path can only ever land inside the folder the operator bound
|
||||
* to the company — never `../../.ssh/id_rsa`.
|
||||
*/
|
||||
export function resolveWorkspacePath(workspaceRoot: string, relativePath: string): { absolute: string; relativePosix: string } {
|
||||
const root = resolve(workspaceRoot);
|
||||
const requested = relativePath.trim().replace(/^[\\/]+/, "");
|
||||
if (!requested) throw new Error("Workspace path is required.");
|
||||
if (isAbsolute(requested)) throw new Error("Workspace path must be relative to the workspace.");
|
||||
const absolute = resolve(root, requested);
|
||||
const rel = relative(root, absolute);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error("Workspace path escapes the workspace root.");
|
||||
}
|
||||
return { absolute, relativePosix: rel.split(sep).join("/") };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export interface GoalKickoffPlan {
|
||||
assigneeAgentId: string;
|
||||
workspaceId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans the kickoff work for an active goal: the chosen owner when that agent
|
||||
* is still active, otherwise the top-level active agent (the CEO), bound to
|
||||
* the org's primary workspace. Returns null when the org has no eligible
|
||||
* agent — creating the first agent then kicks off every untouched goal, so a
|
||||
* goal never stays inert.
|
||||
*/
|
||||
export function resolveGoalKickoff(db: DatabaseSync, input: {
|
||||
orgId: string;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
ownerAgentId: string | null;
|
||||
}): GoalKickoffPlan | null {
|
||||
const assigneeAgentId = activeAgentId(db, input.orgId, input.ownerAgentId) ?? topLevelActiveAgentId(db, input.orgId);
|
||||
if (!assigneeAgentId) return null;
|
||||
const organization = db.prepare("select name from organizations where id = ?")
|
||||
.get(input.orgId) as { name: string };
|
||||
return {
|
||||
assigneeAgentId,
|
||||
workspaceId: primaryWorkspaceId(db, input.orgId),
|
||||
title: goalKickoffTitle(input.goalTitle),
|
||||
description: goalKickoffDescription(organization.name, input.goalTitle, input.goalDescription),
|
||||
};
|
||||
}
|
||||
|
||||
export function goalKickoffTitle(goalTitle: string): string {
|
||||
return `Operationalize goal: ${goalTitle.trim()}`;
|
||||
}
|
||||
|
||||
export function goalKickoffDescription(companyName: string, goalTitle: string, goalDescription: string): string {
|
||||
return [
|
||||
`Company: ${companyName.trim()}`,
|
||||
`Goal: ${goalTitle.trim()}`,
|
||||
goalDescription.trim(),
|
||||
"Read the bound workspace, create the first operating plan, delegate work, and route decisions through approvals and messages.",
|
||||
].filter((line) => line.length > 0).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Waking an agent re-enables its heartbeat (same contract as the previous
|
||||
* runtime), so a kickoff assigned to an agent hired without a provider still
|
||||
* starts autonomously once a provider is configured. This also resumes the
|
||||
* agent's other backlog work — waking an agent wakes all of its assignments.
|
||||
*/
|
||||
export function ensureAgentHeartbeat(db: DatabaseSync, orgId: string, agentId: string): void {
|
||||
db.prepare(`
|
||||
update agents
|
||||
set heartbeat_enabled = 1,
|
||||
heartbeat_interval_sec = case when heartbeat_interval_sec > 0 then heartbeat_interval_sec else 120 end
|
||||
where id = ? and org_id = ?
|
||||
`).run(agentId, orgId);
|
||||
}
|
||||
|
||||
function activeAgentId(db: DatabaseSync, orgId: string, agentId: string | null): string | null {
|
||||
if (!agentId) return null;
|
||||
const row = db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
|
||||
.get(agentId, orgId) as { id: string } | undefined;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
function topLevelActiveAgentId(db: DatabaseSync, orgId: string): string | null {
|
||||
const row = db.prepare(`
|
||||
select id from agents
|
||||
where org_id = ? and manager_agent_id is null and lifecycle = 'active'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
`).get(orgId) as { id: string } | undefined;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
function primaryWorkspaceId(db: DatabaseSync, orgId: string): string | null {
|
||||
const row = db.prepare("select id from workspaces where org_id = ? and is_primary = 1 order by created_at asc limit 1")
|
||||
.get(orgId) as { id: string } | undefined;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { GrantRoleInput, SyncEmployeeIdentityInput } from "../../shared/commands";
|
||||
import { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type PrincipalType, type RoleKey } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const ROLE_KEYS = new Set(Object.values(ROLE_KEY));
|
||||
const IDENTITY_SOURCES = new Set(Object.values(IDENTITY_SOURCE));
|
||||
const SCOPE_TYPES = new Set(["organization", "workspace", "project", "department"]);
|
||||
|
||||
export class IdentityAccessService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
seedEmployeeAccess(orgId: string, employeeId: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
values (?, ?, ?, ?, ?, null, 1, ?, ?)
|
||||
on conflict(org_id, source, external_subject) do nothing
|
||||
`).run(createId("eid"), orgId, employeeId, IDENTITY_SOURCE.BOOTSTRAP, employeeId, now, now);
|
||||
for (const roleKey of [ROLE_KEY.ORG_ADMIN, ROLE_KEY.APPROVAL_DECIDER, ROLE_KEY.HIRING_MANAGER]) {
|
||||
this.insertRoleGrant({
|
||||
orgId,
|
||||
principalType: "employee",
|
||||
principalId: employeeId,
|
||||
roleKey,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
grantedByEmployeeId: null,
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createAgentServiceIdentity(orgId: string, agentId: string, name: string, now: string): void {
|
||||
const serviceIdentityId = createId("sid");
|
||||
this.db.prepare(`
|
||||
insert into service_identities(id, org_id, agent_id, name, scope_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, agent_id) do nothing
|
||||
`).run(serviceIdentityId, orgId, agentId, `${name.trim()} service identity`, JSON.stringify({ agentId }), now);
|
||||
const existing = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
|
||||
.get(orgId, agentId) as { id: string } | undefined;
|
||||
if (!existing) throw new Error("Agent service identity was not created.");
|
||||
this.insertRoleGrant({
|
||||
orgId,
|
||||
principalType: "service_identity",
|
||||
principalId: existing.id,
|
||||
roleKey: ROLE_KEY.SCOPED_AGENT,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
grantedByEmployeeId: null,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Employee was not found.");
|
||||
if (!IDENTITY_SOURCES.has(input.source)) throw new Error("Identity source is invalid.");
|
||||
const externalSubject = input.externalSubject.trim();
|
||||
if (!externalSubject) throw new Error("External subject is required.");
|
||||
const email = input.email?.trim() || null;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
values (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
on conflict(org_id, source, external_subject) do update set
|
||||
employee_id = excluded.employee_id,
|
||||
email = excluded.email,
|
||||
active = 1,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`).run(createId("eid"), input.orgId, input.employeeId, input.source, externalSubject, email, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "employee_identity",
|
||||
entityId: input.employeeId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "synced",
|
||||
payload: { source: input.source, externalSubject, email },
|
||||
});
|
||||
}
|
||||
|
||||
grantRole(input: GrantRoleInput): void {
|
||||
this.assertEmployee(input.orgId, input.grantedByEmployeeId, "Granting employee was not found.");
|
||||
this.assertEmployeeRole(input.orgId, input.grantedByEmployeeId, ROLE_KEY.ORG_ADMIN, "role_grant", input.scopeType, input.scopeId);
|
||||
if (!ROLE_KEYS.has(input.roleKey)) throw new Error("Role key is invalid.");
|
||||
if (!SCOPE_TYPES.has(input.scopeType)) throw new Error("Role scope type is invalid.");
|
||||
this.assertPrincipal(input.orgId, input.principalType, input.principalId);
|
||||
const now = new Date().toISOString();
|
||||
const changes = this.insertRoleGrant({ ...input, now });
|
||||
if (changes > 0) {
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "role_grant",
|
||||
entityId: input.principalId,
|
||||
actorType: "employee",
|
||||
actorId: input.grantedByEmployeeId,
|
||||
action: "granted",
|
||||
payload: { principalType: input.principalType, roleKey: input.roleKey, scopeType: input.scopeType, scopeId: input.scopeId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assertEmployeeRole(
|
||||
orgId: string,
|
||||
employeeId: string,
|
||||
roleKey: RoleKey,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
employeeMissingMessage = "Employee was not found.",
|
||||
): void {
|
||||
this.assertEmployee(orgId, employeeId, employeeMissingMessage);
|
||||
const allowed = this.hasRole(orgId, "employee", employeeId, roleKey) || this.hasRole(orgId, "employee", employeeId, ROLE_KEY.ORG_ADMIN);
|
||||
this.recordDecision(orgId, "employee", employeeId, action, resourceType, resourceId, allowed, allowed ? roleKey : "missing_role");
|
||||
if (!allowed) throw new Error(`Employee requires ${roleKey} role.`);
|
||||
}
|
||||
|
||||
private hasRole(orgId: string, principalType: PrincipalType, principalId: string, roleKey: RoleKey): boolean {
|
||||
const row = this.db.prepare(`
|
||||
select id from role_grants
|
||||
where org_id = ? and principal_type = ? and principal_id = ? and role_key = ?
|
||||
limit 1
|
||||
`).get(orgId, principalType, principalId, roleKey);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
private insertRoleGrant(input: {
|
||||
orgId: string; principalType: PrincipalType; principalId: string; roleKey: RoleKey;
|
||||
scopeType: string; scopeId: string; grantedByEmployeeId: string | null; now: string;
|
||||
}): number {
|
||||
const result = this.db.prepare(`
|
||||
insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, principal_type, principal_id, role_key, scope_type, scope_id) do nothing
|
||||
`).run(createId("rgr"), input.orgId, input.principalType, input.principalId, input.roleKey, input.scopeType, input.scopeId, input.grantedByEmployeeId, input.now);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
private assertPrincipal(orgId: string, principalType: PrincipalType, principalId: string): void {
|
||||
if (principalType === "employee") this.assertEmployee(orgId, principalId, "Employee was not found.");
|
||||
else if (principalType === "agent") this.assertAgent(orgId, principalId);
|
||||
else if (principalType === "service_identity") this.assertServiceIdentity(orgId, principalId);
|
||||
else throw new Error("Principal type is invalid.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!row) throw new Error(message);
|
||||
}
|
||||
|
||||
private assertAgent(orgId: string, agentId: string): void {
|
||||
const row = this.db.prepare("select id from agents where id = ? and org_id = ?").get(agentId, orgId);
|
||||
if (!row) throw new Error("Agent was not found.");
|
||||
}
|
||||
|
||||
private assertServiceIdentity(orgId: string, serviceIdentityId: string): void {
|
||||
const row = this.db.prepare("select id from service_identities where id = ? and org_id = ?").get(serviceIdentityId, orgId);
|
||||
if (!row) throw new Error("Service identity was not found.");
|
||||
}
|
||||
|
||||
private recordDecision(
|
||||
orgId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
allowed: boolean,
|
||||
reason: string,
|
||||
): void {
|
||||
this.db.prepare(`
|
||||
insert into access_decisions(id, org_id, principal_type, principal_id, action, resource_type, resource_id, decision, reason, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("acd"), orgId, principalType, principalId, action, resourceType, resourceId, allowed ? ACCESS_DECISION.ALLOW : ACCESS_DECISION.DENY, reason, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class KnowledgeService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateKnowledgeEntryInput): string {
|
||||
const id = createId("kne");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into knowledge_entries(id, org_id, topic, category, content, importance, source_type, source_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.topic.trim(), input.category, input.content, input.importance, input.sourceType, input.sourceId, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { topic: input.topic.trim(), category: input.category },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
delete(input: DeleteKnowledgeEntryInput): void {
|
||||
const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Knowledge entry was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateMeetingInput, DeleteMeetingInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class MeetingService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateMeetingInput): string {
|
||||
const id = createId("mtg");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into meetings(id, org_id, title, meeting_type, status, scheduled_at, duration_minutes, agenda, notes, decisions, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.meetingType, "scheduled", input.scheduledAt, input.durationMinutes, input.agenda.trim(), "", "", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "meeting",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { title: input.title.trim(), meetingType: input.meetingType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
delete(input: DeleteMeetingInput): void {
|
||||
const result = this.db.prepare("delete from meetings where id = ? and org_id = ?").run(input.meetingId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Meeting was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
const REPLAYABLE_ENTITY_TYPES = new Set([
|
||||
"agent",
|
||||
"approval",
|
||||
"browser_action",
|
||||
"chat_message",
|
||||
"chat_space",
|
||||
"file",
|
||||
"scheduled_post",
|
||||
"social_account",
|
||||
"task",
|
||||
"workspace",
|
||||
]);
|
||||
|
||||
export interface CutoverDeltaEvent {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecordCutoverEventInput {
|
||||
orgId: string;
|
||||
migrationReportId: string;
|
||||
rollbackSnapshotId: string | null;
|
||||
eventType: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class MigrationCutoverJournalService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
recordEvent(input: RecordCutoverEventInput): string {
|
||||
const id = createId("mce");
|
||||
this.db.prepare(`
|
||||
insert into migration_cutover_events(
|
||||
id, org_id, migration_report_id, rollback_snapshot_id, event_type,
|
||||
actor_type, actor_id, payload_json, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.migrationReportId,
|
||||
input.rollbackSnapshotId,
|
||||
input.eventType,
|
||||
input.actorType,
|
||||
input.actorId,
|
||||
JSON.stringify(input.payload),
|
||||
input.createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
queueReconciliation(input: {
|
||||
orgId: string;
|
||||
migrationReportId: string;
|
||||
rollbackSnapshotId: string;
|
||||
events: CutoverDeltaEvent[];
|
||||
createdAt?: string;
|
||||
}): number {
|
||||
let queued = 0;
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
const statement = this.db.prepare(`
|
||||
insert or ignore into migration_reconciliation_items(
|
||||
id, org_id, migration_report_id, rollback_snapshot_id, source_event_id,
|
||||
target_type, target_id, reason, state, payload_json, created_at, resolved_at, resolved_by_employee_id
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null)
|
||||
`);
|
||||
for (const event of input.events) {
|
||||
if (!REPLAYABLE_ENTITY_TYPES.has(event.entityType)) continue;
|
||||
const result = statement.run(
|
||||
createId("mri"),
|
||||
input.orgId,
|
||||
input.migrationReportId,
|
||||
input.rollbackSnapshotId,
|
||||
event.id,
|
||||
event.entityType,
|
||||
event.entityId,
|
||||
"manual_delta_replay",
|
||||
"open",
|
||||
JSON.stringify({
|
||||
action: event.action,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
payload: event.payload,
|
||||
createdAt: event.createdAt,
|
||||
}),
|
||||
createdAt,
|
||||
);
|
||||
queued += Number(result.changes);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||
import type { CreateMigrationReportInput } from "../../shared/commands";
|
||||
import { SLO_SIGNAL_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { buildMigrationValidationSummary, type MigrationValidationSummary } from "./migration-validation-summary";
|
||||
import { OperationsService } from "./operations-service";
|
||||
|
||||
const LEGACY_TABLES = [
|
||||
"organizations",
|
||||
"org_departments",
|
||||
"hiring_policies",
|
||||
"employees",
|
||||
"employee_identities",
|
||||
"service_identities",
|
||||
"role_grants",
|
||||
"access_decisions",
|
||||
"agents",
|
||||
"tasks",
|
||||
"workspaces",
|
||||
"goals",
|
||||
"projects",
|
||||
"task_runs",
|
||||
"approvals",
|
||||
"approval_routing_rules",
|
||||
"approval_assignments",
|
||||
"feature_flags",
|
||||
"slo_targets",
|
||||
"operational_events",
|
||||
"chaos_drills",
|
||||
"chaos_drill_runs",
|
||||
"sdlc_review_agents",
|
||||
"sdlc_review_runs",
|
||||
"eval_suites",
|
||||
"eval_cases",
|
||||
"eval_runs",
|
||||
"pr_bot_gates",
|
||||
"chat_spaces",
|
||||
"chat_messages",
|
||||
"chat_threads",
|
||||
"chat_memberships",
|
||||
"chat_attachments",
|
||||
"chat_mentions",
|
||||
"chat_slash_invocations",
|
||||
"board_interventions",
|
||||
"chat_presence",
|
||||
"chat_read_state",
|
||||
"providers",
|
||||
"provider_requests",
|
||||
"provider_usage_windows",
|
||||
"provider_rate_limit_observations",
|
||||
"provider_auth_profiles",
|
||||
"provider_auth_sessions",
|
||||
"credentials",
|
||||
"skill_definitions",
|
||||
"skill_revisions",
|
||||
"skill_artifacts",
|
||||
"skill_tests",
|
||||
"skill_dependencies",
|
||||
"skill_bindings",
|
||||
"skill_runtime_snapshots",
|
||||
"skill_catalog_tags",
|
||||
"file_objects",
|
||||
"file_access_grants",
|
||||
"file_handoff_records",
|
||||
"social_accounts",
|
||||
"social_account_grants",
|
||||
"browser_session_containers",
|
||||
"browser_action_policies",
|
||||
"social_approval_routes",
|
||||
"browser_actions",
|
||||
"social_audit_records",
|
||||
"scheduled_posts",
|
||||
"policy_rules",
|
||||
"compliance_exports",
|
||||
"legal_holds",
|
||||
"retention_freezes",
|
||||
"search_documents",
|
||||
"audit_replay_runs",
|
||||
"rollback_snapshots",
|
||||
"migration_cutover_events",
|
||||
"migration_reconciliation_items",
|
||||
"audit_events",
|
||||
];
|
||||
|
||||
interface MigrationFailure {
|
||||
table: string;
|
||||
reason: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface RelationCheck {
|
||||
table: string;
|
||||
column: string;
|
||||
referencedTable: string;
|
||||
referencedColumn: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface MigrationReportBody {
|
||||
sourceFingerprint: string;
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
missingTables: string[];
|
||||
failures: MigrationFailure[];
|
||||
validationSummary: MigrationValidationSummary;
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: string;
|
||||
capturedAt: string;
|
||||
restoreStrategy: string;
|
||||
sourceFingerprint: string;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "org_departments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "org_departments", column: "lead_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_lead_agent_id" },
|
||||
{ table: "hiring_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employees", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "service_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "service_identities", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
|
||||
{ table: "role_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "role_grants", column: "granted_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_grantor_employee_id" },
|
||||
{ table: "access_decisions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "agents", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "agents", column: "model_provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_model_provider_id" },
|
||||
{ table: "agents", column: "manager_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_manager_agent_id" },
|
||||
{ table: "agents", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" },
|
||||
{ table: "tasks", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "tasks", column: "assignee_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_assignee_agent_id" },
|
||||
{ table: "tasks", column: "project_id", referencedTable: "projects", referencedColumn: "id", reason: "dangling_project_id" },
|
||||
{ table: "tasks", column: "goal_id", referencedTable: "goals", referencedColumn: "id", reason: "dangling_goal_id" },
|
||||
{ table: "tasks", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" },
|
||||
{ table: "workspaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "workspaces", column: "project_id", referencedTable: "projects", referencedColumn: "id", reason: "dangling_project_id" },
|
||||
{ table: "goals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "goals", column: "owner_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_owner_agent_id" },
|
||||
{ table: "projects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "projects", column: "goal_id", referencedTable: "goals", referencedColumn: "id", reason: "dangling_goal_id" },
|
||||
{ table: "projects", column: "lead_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_lead_agent_id" },
|
||||
{ table: "task_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "task_runs", column: "task_id", referencedTable: "tasks", referencedColumn: "id", reason: "dangling_task_id" },
|
||||
{ table: "task_runs", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
|
||||
{ table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "task_runs", column: "provider_request_id", referencedTable: "provider_requests", referencedColumn: "id", reason: "dangling_provider_request_id" },
|
||||
{ table: "task_runs", column: "service_identity_id", referencedTable: "service_identities", referencedColumn: "id", reason: "dangling_service_identity_id" },
|
||||
{ table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" },
|
||||
{ table: "approval_routing_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approval_assignments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approval_assignments", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "approval_assignments", column: "routing_rule_id", referencedTable: "approval_routing_rules", referencedColumn: "id", reason: "dangling_routing_rule_id" },
|
||||
{ table: "feature_flags", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "slo_targets", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "operational_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chaos_drills", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chaos_drill_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chaos_drill_runs", column: "drill_id", referencedTable: "chaos_drills", referencedColumn: "id", reason: "dangling_drill_id" },
|
||||
{ table: "sdlc_review_agents", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "sdlc_review_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "sdlc_review_runs", column: "review_agent_id", referencedTable: "sdlc_review_agents", referencedColumn: "id", reason: "dangling_review_agent_id" },
|
||||
{ table: "eval_suites", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "eval_cases", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "eval_cases", column: "suite_id", referencedTable: "eval_suites", referencedColumn: "id", reason: "dangling_suite_id" },
|
||||
{ table: "eval_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "eval_runs", column: "suite_id", referencedTable: "eval_suites", referencedColumn: "id", reason: "dangling_suite_id" },
|
||||
{ table: "pr_bot_gates", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_spaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_messages", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_messages", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "chat_messages", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||
{ table: "chat_threads", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_threads", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "chat_threads", column: "root_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_root_message_id" },
|
||||
{ table: "chat_memberships", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "chat_attachments", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "chat_attachments", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||
{ table: "chat_mentions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "chat_slash_invocations", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "board_interventions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "board_interventions", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||
{ table: "board_interventions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "board_interventions", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "chat_read_state", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_read_state", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "chat_read_state", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "chat_read_state", column: "last_read_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_last_read_message_id" },
|
||||
{ table: "providers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ 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" },
|
||||
{ table: "provider_rate_limit_observations", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_auth_profiles", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_auth_profiles", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_auth_profiles", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" },
|
||||
{ table: "provider_auth_sessions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_auth_sessions", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_auth_sessions", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_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" },
|
||||
{ table: "skill_artifacts", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" },
|
||||
{ table: "skill_tests", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" },
|
||||
{ table: "skill_dependencies", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" },
|
||||
{ table: "skill_dependencies", column: "depends_on_revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_depends_on_revision_id" },
|
||||
{ table: "skill_bindings", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "skill_bindings", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
|
||||
{ table: "skill_bindings", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" },
|
||||
{ table: "skill_bindings", column: "pinned_revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" },
|
||||
{ table: "skill_runtime_snapshots", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
|
||||
{ table: "skill_runtime_snapshots", column: "run_id", referencedTable: "task_runs", referencedColumn: "id", reason: "dangling_run_id" },
|
||||
{ table: "skill_catalog_tags", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" },
|
||||
{ table: "file_objects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "file_objects", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
{ table: "file_access_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "file_access_grants", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||
{ table: "file_access_grants", column: "source_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_source_message_id" },
|
||||
{ table: "file_access_grants", column: "source_thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_source_thread_id" },
|
||||
{ table: "file_handoff_records", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "file_handoff_records", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "file_handoff_records", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||
{ table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" },
|
||||
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_account_grants", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_account_grants", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "browser_session_containers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_session_containers", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "browser_action_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_action_policies", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_approval_routes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_approval_routes", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_approval_routes", column: "route_to_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_route_to_id" },
|
||||
{ table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
|
||||
{ table: "social_audit_records", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "social_audit_records", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "social_audit_records", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
|
||||
{ table: "scheduled_posts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "scheduled_posts", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
|
||||
{ table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "scheduled_posts", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "policy_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "requested_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_requester_employee_id" },
|
||||
{ table: "legal_holds", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "audit_replay_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "audit_replay_runs", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||
{ table: "audit_replay_runs", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "rollback_snapshots", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "rollback_snapshots", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "rollback_snapshots", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "migration_cutover_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "migration_cutover_events", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "migration_cutover_events", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
|
||||
{ table: "migration_reconciliation_items", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "migration_reconciliation_items", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "migration_reconciliation_items", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
|
||||
{ table: "migration_reconciliation_items", column: "resolved_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_resolver_employee_id" },
|
||||
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
];
|
||||
|
||||
export class MigrationService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly operations: OperationsService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.operations = new OperationsService(db);
|
||||
}
|
||||
|
||||
createReport(input: CreateMigrationReportInput): string {
|
||||
this.assertOrg(input.orgId);
|
||||
const normalizedSourcePath = normalizeSourcePath(input.sourceDatabasePath);
|
||||
if (!existsSync(normalizedSourcePath)) throw new Error("Source database was not found.");
|
||||
const sourceDatabasePath = realpathSync.native(normalizedSourcePath);
|
||||
const source = new DatabaseSync(sourceDatabasePath, { readOnly: true });
|
||||
try {
|
||||
const availableTables = new Set(
|
||||
source.prepare("select name from sqlite_master where type = 'table'")
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name),
|
||||
);
|
||||
const tableCounts = Object.fromEntries(
|
||||
LEGACY_TABLES
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${quoteIdentifier(table)}`))]),
|
||||
);
|
||||
const tableChecksums = Object.fromEntries(
|
||||
LEGACY_TABLES
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, tableChecksum(source, table)]),
|
||||
);
|
||||
const idMaps = Object.fromEntries(
|
||||
LEGACY_TABLES
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, tableIds(source, table)]),
|
||||
);
|
||||
const idMappedTables = LEGACY_TABLES.filter((table) => availableTables.has(table) && hasColumn(source, table, "id"));
|
||||
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
|
||||
const failures = [
|
||||
...missingTables.map((table) => ({ table, reason: "missing_table", detail: "Required table is absent." })),
|
||||
...validateRelations(source, availableTables),
|
||||
];
|
||||
const validationSummary = buildMigrationValidationSummary({
|
||||
requiredTables: LEGACY_TABLES,
|
||||
tableCounts,
|
||||
tableChecksums,
|
||||
idMaps,
|
||||
idMappedTables,
|
||||
failures,
|
||||
});
|
||||
const sourceFingerprint = fingerprintSource({ tableCounts, tableChecksums, idMaps, failures });
|
||||
const existingReportId = this.findExistingReport(input.orgId, sourceDatabasePath, sourceFingerprint);
|
||||
if (existingReportId) return existingReportId;
|
||||
const reportId = createId("mig");
|
||||
const now = new Date().toISOString();
|
||||
const reportBody: MigrationReportBody = {
|
||||
sourceFingerprint,
|
||||
tableCounts,
|
||||
tableChecksums,
|
||||
idMaps,
|
||||
missingTables,
|
||||
failures,
|
||||
validationSummary,
|
||||
rollbackPack: {
|
||||
sourceDatabasePath,
|
||||
capturedAt: now,
|
||||
restoreStrategy: "legacy_snapshot_restore",
|
||||
sourceFingerprint,
|
||||
tableChecksums,
|
||||
idMaps,
|
||||
},
|
||||
};
|
||||
this.db.prepare(`
|
||||
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(reportId, input.orgId, sourceDatabasePath, JSON.stringify(reportBody), now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "migration_report",
|
||||
entityId: reportId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { sourceRef: sourceDatabasePath, tableCount: Object.keys(tableCounts).length, sourceFingerprint },
|
||||
});
|
||||
this.operations.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: SLO_SIGNAL_KEY.MIGRATION_VALIDATION_SUCCESS,
|
||||
source: "migration_validation",
|
||||
success: validationSummary.passed,
|
||||
durationMs: null,
|
||||
detail: { reportId, sourceFingerprint, validationSummary },
|
||||
});
|
||||
return reportId;
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
private assertOrg(orgId: string): void {
|
||||
const org = this.db.prepare("select id from organizations where id = ?").get(orgId);
|
||||
if (!org) throw new Error("Organization was not found.");
|
||||
}
|
||||
|
||||
private findExistingReport(orgId: string, sourceRef: string, sourceFingerprint: string): string | null {
|
||||
const rows = this.db.prepare(`
|
||||
select id, report_json from migration_reports
|
||||
where org_id = ? and source_ref = ?
|
||||
order by created_at desc
|
||||
`).all(orgId, sourceRef) as Array<{ id: string; report_json: string }>;
|
||||
for (const row of rows) {
|
||||
const parsed = parseExistingReport(row.report_json);
|
||||
if (parsed.sourceFingerprint === sourceFingerprint) return row.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(sourceDatabasePath: string): string {
|
||||
const trimmed = sourceDatabasePath.trim();
|
||||
if (!trimmed) throw new Error("Source database path is required.");
|
||||
return resolve(trimmed);
|
||||
}
|
||||
|
||||
function countRows(statement: StatementSync): number {
|
||||
return (statement.get() as { count: number }).count;
|
||||
}
|
||||
|
||||
function tableChecksum(source: DatabaseSync, table: string): string {
|
||||
const hash = createHash("sha256");
|
||||
const rows = source.prepare(`select * from ${quoteIdentifier(table)} order by ${orderingColumn(source, table)}`).all();
|
||||
for (const row of rows) hash.update(JSON.stringify(row));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function tableIds(source: DatabaseSync, table: string): string[] {
|
||||
if (!hasColumn(source, table, "id")) return [];
|
||||
return source.prepare(`select id from ${quoteIdentifier(table)} order by id`).all().map((row) => String((row as { id: unknown }).id));
|
||||
}
|
||||
|
||||
function validateRelations(source: DatabaseSync, availableTables: Set<string>): MigrationFailure[] {
|
||||
const failures: MigrationFailure[] = [];
|
||||
for (const check of RELATION_CHECKS) {
|
||||
if (!availableTables.has(check.table) || !availableTables.has(check.referencedTable)) continue;
|
||||
if (!hasColumn(source, check.table, check.column)) {
|
||||
failures.push({ table: check.table, reason: "missing_column", detail: check.column });
|
||||
continue;
|
||||
}
|
||||
if (!hasColumn(source, check.referencedTable, check.referencedColumn)) {
|
||||
failures.push({ table: check.referencedTable, reason: "missing_column", detail: check.referencedColumn });
|
||||
continue;
|
||||
}
|
||||
failures.push(...danglingReferences(source, check));
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function danglingReferences(source: DatabaseSync, check: RelationCheck): MigrationFailure[] {
|
||||
const table = quoteIdentifier(check.table);
|
||||
const referencedTable = quoteIdentifier(check.referencedTable);
|
||||
const column = quoteIdentifier(check.column);
|
||||
const referencedColumn = quoteIdentifier(check.referencedColumn);
|
||||
const recordKey = recordKeyExpression(source, check.table);
|
||||
const rows = source.prepare(`
|
||||
select ${recordKey} as record_id, child.${column} as missing_ref
|
||||
from ${table} child
|
||||
left join ${referencedTable} parent on child.${column} = parent.${referencedColumn}
|
||||
where child.${column} is not null and parent.${referencedColumn} is null
|
||||
order by record_id
|
||||
`).all() as Array<{ record_id: unknown; missing_ref: unknown }>;
|
||||
return rows.map((row) => ({
|
||||
table: check.table,
|
||||
reason: check.reason,
|
||||
detail: `${String(row.record_id)} -> ${String(row.missing_ref)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function hasColumn(source: DatabaseSync, table: string, columnName: string): boolean {
|
||||
const columns = source.prepare(`pragma table_info(${quoteIdentifier(table)})`).all() as Array<{ name: string }>;
|
||||
return columns.some((column) => column.name === columnName);
|
||||
}
|
||||
|
||||
function orderingColumn(source: DatabaseSync, table: string): string {
|
||||
if (hasColumn(source, table, "id")) return quoteIdentifier("id");
|
||||
return "rowid";
|
||||
}
|
||||
|
||||
function recordKeyExpression(source: DatabaseSync, table: string): string {
|
||||
if (hasColumn(source, table, "id")) return `child.${quoteIdentifier("id")}`;
|
||||
return "child.rowid";
|
||||
}
|
||||
|
||||
function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "rollbackPack" | "validationSummary">): string {
|
||||
return createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
||||
}
|
||||
|
||||
function parseExistingReport(reportJson: string): { sourceFingerprint?: string } {
|
||||
const parsed = JSON.parse(reportJson) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("Existing migration report is invalid.");
|
||||
const fingerprint = parsed.sourceFingerprint;
|
||||
if (fingerprint !== undefined && typeof fingerprint !== "string") throw new Error("Existing migration report is invalid.");
|
||||
return { sourceFingerprint: fingerprint };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface MigrationFailureView {
|
||||
table: string;
|
||||
reason: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MigrationValidationSummary {
|
||||
requiredTableCount: number;
|
||||
presentTableCount: number;
|
||||
missingTableCount: number;
|
||||
totalSourceRows: number;
|
||||
rowsWithIntegrityFailures: number;
|
||||
rowCoverageBasisPoints: number;
|
||||
checksumCoverageBasisPoints: number;
|
||||
idMapCoverageBasisPoints: number;
|
||||
relationFailureCount: number;
|
||||
passed: boolean;
|
||||
thresholds: {
|
||||
rowCoverageBasisPoints: number;
|
||||
checksumCoverageBasisPoints: number;
|
||||
idMapCoverageBasisPoints: number;
|
||||
};
|
||||
}
|
||||
|
||||
const BASIS_POINTS = 10000;
|
||||
const ROW_COVERAGE_TARGET = 9900;
|
||||
|
||||
export function buildMigrationValidationSummary(input: {
|
||||
requiredTables: string[];
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
idMappedTables: string[];
|
||||
failures: MigrationFailureView[];
|
||||
}): MigrationValidationSummary {
|
||||
const presentTableCount = Object.keys(input.tableCounts).length;
|
||||
const missingTableCount = input.requiredTables.length - presentTableCount;
|
||||
const totalSourceRows = sum(Object.values(input.tableCounts));
|
||||
const rowsWithIntegrityFailures = countFailedRows(input.failures);
|
||||
const rowCoverageBasisPoints = ratioBasisPoints(Math.max(0, totalSourceRows - rowsWithIntegrityFailures), totalSourceRows);
|
||||
const checksumCoverageBasisPoints = ratioBasisPoints(Object.keys(input.tableChecksums).length, presentTableCount);
|
||||
const idMapSourceRows = sum(input.idMappedTables.map((table) => input.tableCounts[table] ?? 0));
|
||||
const idMappedRows = sum(input.idMappedTables.map((table) => input.idMaps[table]?.length ?? 0));
|
||||
const idMapCoverageBasisPoints = ratioBasisPoints(idMappedRows, idMapSourceRows);
|
||||
const relationFailureCount = input.failures.filter((failure) => failure.reason !== "missing_table").length;
|
||||
const passed = missingTableCount === 0
|
||||
&& rowCoverageBasisPoints >= ROW_COVERAGE_TARGET
|
||||
&& checksumCoverageBasisPoints === BASIS_POINTS
|
||||
&& idMapCoverageBasisPoints === BASIS_POINTS
|
||||
&& relationFailureCount === 0;
|
||||
return {
|
||||
requiredTableCount: input.requiredTables.length,
|
||||
presentTableCount,
|
||||
missingTableCount,
|
||||
totalSourceRows,
|
||||
rowsWithIntegrityFailures,
|
||||
rowCoverageBasisPoints,
|
||||
checksumCoverageBasisPoints,
|
||||
idMapCoverageBasisPoints,
|
||||
relationFailureCount,
|
||||
passed,
|
||||
thresholds: {
|
||||
rowCoverageBasisPoints: ROW_COVERAGE_TARGET,
|
||||
checksumCoverageBasisPoints: BASIS_POINTS,
|
||||
idMapCoverageBasisPoints: BASIS_POINTS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countFailedRows(failures: MigrationFailureView[]): number {
|
||||
const keys = new Set<string>();
|
||||
for (const failure of failures) {
|
||||
if (failure.reason === "missing_table" || failure.reason === "missing_column") continue;
|
||||
keys.add(`${failure.table}:${failure.detail.split(" -> ")[0]}`);
|
||||
}
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
function ratioBasisPoints(numerator: number, denominator: number): number {
|
||||
if (denominator === 0) return BASIS_POINTS;
|
||||
return Math.floor((numerator / denominator) * BASIS_POINTS);
|
||||
}
|
||||
|
||||
function sum(values: number[]): number {
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Anthropic Claude Pro/Max — authorization-code + PKCE flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. Uses a
|
||||
* loopback callback server on the registered redirect URI and a JSON token
|
||||
* exchange.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
return `${AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, state: string, verifier: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Token response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
|
||||
}
|
||||
|
||||
/** Refresh an expired Claude Pro/Max access token. */
|
||||
export async function refreshClaudePro(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Claude token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Claude token refresh response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
|
||||
}
|
||||
|
||||
export const anthropicClaudeFlow: OAuthFlow = {
|
||||
providerKey: "claude-pro",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
|
||||
try {
|
||||
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Claude sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state !== verifier) throw new Error("OAuth state mismatch.");
|
||||
return await exchangeCode(result.code, result.state, verifier);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* GitHub Copilot OAuth — device-code flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. The device
|
||||
* flow obtains a GitHub access token, then exchanges it for a Copilot token.
|
||||
*/
|
||||
import { listProviderModels } from "../../../shared/provider-models";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
// Decoded from the published GitHub Copilot client id used by the official CLI.
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
export const COPILOT_HEADERS = {
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
} as const;
|
||||
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
|
||||
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
|
||||
|
||||
interface CopilotUrls {
|
||||
deviceCodeUrl: string;
|
||||
accessTokenUrl: string;
|
||||
copilotTokenUrl: string;
|
||||
}
|
||||
|
||||
function normalizeDomain(input: string | null | undefined): string | null {
|
||||
const trimmed = (input ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getUrls(domain: string): CopilotUrls {
|
||||
return {
|
||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
||||
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${response.status} ${response.statusText}: ${text}`);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DeviceCode {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
async function startDeviceFlow(domain: string): Promise<DeviceCode> {
|
||||
const data = await fetchJson(getUrls(domain).deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": COPILOT_HEADERS["User-Agent"],
|
||||
},
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user" }),
|
||||
});
|
||||
const deviceCode = data.device_code;
|
||||
const userCode = data.user_code;
|
||||
const verificationUri = data.verification_uri;
|
||||
const interval = data.interval;
|
||||
const expiresIn = data.expires_in;
|
||||
if (
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
typeof interval !== "number" ||
|
||||
typeof expiresIn !== "number"
|
||||
) {
|
||||
throw new Error("Invalid device code response from GitHub.");
|
||||
}
|
||||
return { device_code: deviceCode, user_code: userCode, verification_uri: verificationUri, interval, expires_in: expiresIn };
|
||||
}
|
||||
|
||||
export type PollDecision =
|
||||
| { kind: "token"; accessToken: string }
|
||||
| { kind: "pending" }
|
||||
| { kind: "slow_down"; intervalSeconds: number | null }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
/** Pure interpreter of a device-token poll response (unit-tested). */
|
||||
export function interpretPollResponse(raw: Record<string, unknown>): PollDecision {
|
||||
if (typeof raw.access_token === "string") return { kind: "token", accessToken: raw.access_token };
|
||||
if (typeof raw.error === "string") {
|
||||
if (raw.error === "authorization_pending") return { kind: "pending" };
|
||||
if (raw.error === "slow_down") {
|
||||
return { kind: "slow_down", intervalSeconds: typeof raw.interval === "number" ? raw.interval : null };
|
||||
}
|
||||
const description = typeof raw.error_description === "string" ? `: ${raw.error_description}` : "";
|
||||
return { kind: "error", message: `Device flow failed: ${raw.error}${description}` };
|
||||
}
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Login cancelled"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForGitHubAccessToken(domain: string, device: DeviceCode, signal?: AbortSignal): Promise<string> {
|
||||
const { accessTokenUrl } = getUrls(domain);
|
||||
const deadline = Date.now() + device.expires_in * 1000;
|
||||
let intervalMs = Math.max(1000, Math.floor(device.interval * 1000));
|
||||
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const remainingMs = deadline - Date.now();
|
||||
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
|
||||
await abortableSleep(waitMs, signal);
|
||||
const raw = await fetchJson(accessTokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": COPILOT_HEADERS["User-Agent"],
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
const decision = interpretPollResponse(raw);
|
||||
if (decision.kind === "token") return decision.accessToken;
|
||||
if (decision.kind === "error") throw new Error(decision.message);
|
||||
if (decision.kind === "slow_down") {
|
||||
intervalMs = decision.intervalSeconds && decision.intervalSeconds > 0 ? decision.intervalSeconds * 1000 : Math.max(1000, intervalMs + 5000);
|
||||
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
|
||||
}
|
||||
}
|
||||
throw new Error("Device flow timed out. Please try signing in again.");
|
||||
}
|
||||
|
||||
export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null): Promise<OAuthCredentials> {
|
||||
const domain = enterpriseDomain || "github.com";
|
||||
const raw = await fetchJson(getUrls(domain).copilotTokenUrl, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${githubToken}`, ...COPILOT_HEADERS },
|
||||
});
|
||||
const token = raw.token;
|
||||
const expiresAt = raw.expires_at;
|
||||
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
||||
throw new Error("Invalid Copilot token response.");
|
||||
}
|
||||
return {
|
||||
access: token,
|
||||
refresh: githubToken,
|
||||
expires: expiresAt * 1000 - 5 * 60 * 1000,
|
||||
enterpriseUrl: enterpriseDomain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string {
|
||||
const match = token.match(/proxy-ep=([^;]+)/);
|
||||
if (match) return `https://${match[1].replace(/^proxy\./, "api.")}`;
|
||||
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
|
||||
return "https://api.individual.githubcopilot.com";
|
||||
}
|
||||
|
||||
/** Best-effort model policy enablement, matching the official client behaviour. */
|
||||
async function enableCopilotModels(token: string, enterpriseDomain?: string | null): Promise<void> {
|
||||
const baseUrl = copilotBaseUrl(token, enterpriseDomain);
|
||||
await Promise.all(
|
||||
listProviderModels("github-copilot").map(async (model) => {
|
||||
try {
|
||||
await fetch(`${baseUrl}/models/${model.id}/policy`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...COPILOT_HEADERS,
|
||||
"openai-intent": "chat-policy",
|
||||
"x-interaction-type": "chat-policy",
|
||||
},
|
||||
body: JSON.stringify({ state: "enabled" }),
|
||||
});
|
||||
} catch {
|
||||
// Per-model enablement is advisory; the model may already be enabled.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const githubCopilotFlow: OAuthFlow = {
|
||||
providerKey: "github-copilot",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const enterpriseDomain = normalizeDomain(callbacks.enterpriseDomain);
|
||||
const domain = enterpriseDomain ?? "github.com";
|
||||
const device = await startDeviceFlow(domain);
|
||||
callbacks.onAuth({
|
||||
url: device.verification_uri,
|
||||
instructions: `Enter code ${device.user_code} at ${device.verification_uri}`,
|
||||
});
|
||||
const githubToken = await pollForGitHubAccessToken(domain, device, callbacks.signal);
|
||||
const credentials = await refreshGitHubCopilotToken(githubToken, enterpriseDomain ?? undefined);
|
||||
callbacks.onProgress?.("Enabling Copilot models");
|
||||
await enableCopilotModels(credentials.access, enterpriseDomain ?? undefined);
|
||||
return credentials;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Google Antigravity (Gemini 3 / Claude / GPT-OSS via Google Cloud) —
|
||||
* authorization-code + PKCE flow with project discovery.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const CLIENT_SECRET_ENV = "AGENTCOMPANY_GOOGLE_ANTIGRAVITY_CLIENT_SECRET";
|
||||
const CALLBACK_PORT = 51121;
|
||||
const CALLBACK_PATH = "/oauth-callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
|
||||
|
||||
export function resolveAntigravityClientSecret(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const secret = env[CLIENT_SECRET_ENV]?.trim();
|
||||
if (!secret) throw new Error(`${CLIENT_SECRET_ENV} is required for Google Antigravity OAuth.`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: resolveAntigravityClientSecret(),
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Token response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
|
||||
}
|
||||
|
||||
/** Extract a Code Assist project id from a loadCodeAssist response (unit-tested). */
|
||||
export function readProjectId(payload: Record<string, unknown>): string | null {
|
||||
const project = payload.cloudaicompanionProject;
|
||||
if (typeof project === "string" && project) return project;
|
||||
if (project && typeof project === "object" && "id" in project) {
|
||||
const id = (project as { id?: unknown }).id;
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
};
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const projectId = readProjectId((await response.json()) as Record<string, unknown>);
|
||||
if (projectId) return projectId;
|
||||
}
|
||||
} catch {
|
||||
// Try the next endpoint.
|
||||
}
|
||||
}
|
||||
throw new Error("Antigravity project discovery failed.");
|
||||
}
|
||||
|
||||
/** Refresh an expired Antigravity access token, retaining the project id. */
|
||||
export async function refreshAntigravity(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: resolveAntigravityClientSecret(),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Antigravity token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Antigravity token refresh response was missing required fields.");
|
||||
}
|
||||
const refresh = typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : refreshToken;
|
||||
return { access: json.access_token, refresh, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000, projectId };
|
||||
}
|
||||
|
||||
export const antigravityFlow: OAuthFlow = {
|
||||
providerKey: "google-antigravity",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
|
||||
try {
|
||||
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Google sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state !== verifier) throw new Error("OAuth state mismatch.");
|
||||
const tokens = await exchangeCode(result.code, verifier);
|
||||
callbacks.onProgress?.("Resolving project");
|
||||
const projectId = await discoverProject(tokens.access);
|
||||
return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createServer } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page";
|
||||
|
||||
const HTML_HEADERS = { "Content-Type": "text/html; charset=utf-8" } as const;
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
code: string;
|
||||
state: string | null;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackServer {
|
||||
/** Resolves when the provider redirects with a code; rejects on provider
|
||||
* error or cancellation. */
|
||||
waitForCode(): Promise<OAuthCallbackResult>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a loopback HTTP server that captures the OAuth authorization-code
|
||||
* redirect. The provider's OAuth client is registered with the exact loopback
|
||||
* redirect URI, so the port and path must match the flow's REDIRECT_URI.
|
||||
*/
|
||||
export function startOAuthCallbackServer(options: {
|
||||
port: number;
|
||||
path: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCallbackServer> {
|
||||
const { port, path, signal } = options;
|
||||
return new Promise<OAuthCallbackServer>((resolve, reject) => {
|
||||
let settle: ((value: OAuthCallbackResult) => void) | null = null;
|
||||
let failWait: ((error: Error) => void) | null = null;
|
||||
const codePromise = new Promise<OAuthCallbackResult>((resolveCode, rejectCode) => {
|
||||
settle = resolveCode;
|
||||
failWait = rejectCode;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", `http://127.0.0.1:${port}`);
|
||||
if (url.pathname !== path) {
|
||||
res.writeHead(404, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
res.writeHead(400, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Sign-in did not complete.", error));
|
||||
failWait?.(new Error(`Sign-in failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
res.writeHead(400, HTML_HEADERS);
|
||||
res.end(oauthErrorHtml("Missing authorization code."));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, HTML_HEADERS);
|
||||
res.end(oauthSuccessHtml("Sign-in complete. You can close this window and return to AgentCompany."));
|
||||
settle?.({ code, state: url.searchParams.get("state") });
|
||||
});
|
||||
|
||||
const onAbort = () => failWait?.(new Error("Login cancelled"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
server.on("error", (err) => reject(err));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
resolve({
|
||||
waitForCode: () => codePromise,
|
||||
close: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { githubCopilotFlow } from "./github-copilot-flow";
|
||||
import { openaiCodexFlow } from "./openai-codex-flow";
|
||||
import { antigravityFlow } from "./google-antigravity-flow";
|
||||
import { anthropicClaudeFlow } from "./anthropic-claude-flow";
|
||||
import type { OAuthFlow } from "./oauth-types";
|
||||
|
||||
/** Provider keys (from the catalog) mapped to their native OAuth login flow. */
|
||||
const OAUTH_FLOWS: Record<string, OAuthFlow> = {
|
||||
"github-copilot": githubCopilotFlow,
|
||||
"gpt-account": openaiCodexFlow,
|
||||
"google-antigravity": antigravityFlow,
|
||||
"claude-pro": anthropicClaudeFlow,
|
||||
};
|
||||
|
||||
export function hasOAuthFlow(providerKey: string): boolean {
|
||||
return providerKey in OAUTH_FLOWS;
|
||||
}
|
||||
|
||||
export function getOAuthFlow(providerKey: string): OAuthFlow {
|
||||
const flow = OAUTH_FLOWS[providerKey];
|
||||
if (!flow) throw new Error(`${providerKey} does not support interactive sign-in.`);
|
||||
return flow;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Minimal HTML shown in the user's browser after the OAuth redirect. */
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderPage(heading: string, message: string, details?: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(heading)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; background: #09090b; color: #fafafa; text-align: center;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { width: 100%; max-width: 520px; }
|
||||
h1 { margin: 0 0 10px; font-size: 26px; font-weight: 650; }
|
||||
p { margin: 0; line-height: 1.7; color: #a1a1aa; font-size: 15px; }
|
||||
.details { margin-top: 14px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; color: #a1a1aa; word-break: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(heading)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${details ? `<div class="details">${escapeHtml(details)}</div>` : ""}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function oauthSuccessHtml(message: string): string {
|
||||
return renderPage("Authentication successful", message);
|
||||
}
|
||||
|
||||
export function oauthErrorHtml(message: string, details?: string): string {
|
||||
return renderPage("Authentication failed", message, details);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared types for the native provider OAuth login flows.
|
||||
*
|
||||
* These flows obtain real provider credentials through the providers' official
|
||||
* device-code / authorization-code (PKCE) processes. The resulting credentials
|
||||
* are stored as JSON via the existing secret vault so a token can be extracted
|
||||
* for requests and a refresh token retained for future renewal.
|
||||
*/
|
||||
|
||||
export interface OAuthCredentials {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthLoginCallbacks {
|
||||
/** Called once the authorization URL is ready. `instructions` carries the
|
||||
* device user-code (device flow) or a short note (authorization-code flow). */
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** GitHub Enterprise domain for Copilot; null/empty means github.com. */
|
||||
enterpriseDomain?: string | null;
|
||||
}
|
||||
|
||||
export interface OAuthFlow {
|
||||
providerKey: string;
|
||||
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OpenAI Codex (ChatGPT Plus/Pro subscription) — authorization-code + PKCE flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. Uses a
|
||||
* loopback callback server on the registered redirect URI.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", "agentcompany");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the ChatGPT account id embedded in the access token (unit-tested). */
|
||||
export function extractAccountId(accessToken: string): string | null {
|
||||
const claims = decodeJwtClaims(accessToken);
|
||||
const auth = claims?.[JWT_CLAIM_PATH];
|
||||
if (auth && typeof auth === "object" && "chatgpt_account_id" in auth) {
|
||||
const accountId = (auth as { chatgpt_account_id?: unknown }).chatgpt_account_id;
|
||||
if (typeof accountId === "string" && accountId.length > 0) return accountId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Token response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 };
|
||||
}
|
||||
|
||||
/** Refresh an expired Codex access token using the long-lived refresh token. */
|
||||
export async function refreshCodex(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Codex token refresh response was missing required fields.");
|
||||
}
|
||||
const accountId = extractAccountId(json.access_token);
|
||||
if (!accountId) throw new Error("Could not read the ChatGPT account id from the refreshed token.");
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000, accountId };
|
||||
}
|
||||
|
||||
export const openaiCodexFlow: OAuthFlow = {
|
||||
providerKey: "gpt-account",
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = generatePkce().verifier;
|
||||
const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal });
|
||||
try {
|
||||
callbacks.onAuth({ url: buildAuthorizeUrl(challenge, state), instructions: "Complete the ChatGPT sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state !== state) throw new Error("OAuth state mismatch.");
|
||||
const tokens = await exchangeCode(result.code, verifier);
|
||||
const accountId = extractAccountId(tokens.access);
|
||||
if (!accountId) throw new Error("Could not read the ChatGPT account id from the token.");
|
||||
return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export interface PkcePair {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
/** Generate a PKCE verifier and its S256 challenge (RFC 7636). */
|
||||
export function generatePkce(): PkcePair {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
BootstrapOnboardingInput,
|
||||
BootstrapOnboardingSummary,
|
||||
SaveProviderCredentialInput,
|
||||
} from "../../shared/commands";
|
||||
import { PROVIDER_AUTH_TYPE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { goalKickoffDescription, goalKickoffTitle } from "./goal-kickoff";
|
||||
import { OrganizationService } from "./organization-service";
|
||||
import { ChatService } from "./chat-service";
|
||||
|
||||
interface OnboardingServiceDeps {
|
||||
db: DatabaseSync;
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): void;
|
||||
}
|
||||
|
||||
export class OnboardingService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly organization: OrganizationService;
|
||||
private readonly chat: ChatService;
|
||||
|
||||
constructor(private readonly deps: OnboardingServiceDeps) {
|
||||
this.audit = new AuditService(deps.db);
|
||||
this.organization = new OrganizationService(deps.db);
|
||||
this.chat = new ChatService(deps.db);
|
||||
}
|
||||
|
||||
bootstrap(input: BootstrapOnboardingInput): BootstrapOnboardingSummary {
|
||||
const provider = this.resolveProvider(input.providerKey);
|
||||
const orgId = this.organization.createOrganization({
|
||||
organizationName: input.companyName,
|
||||
description: input.companyDescription,
|
||||
mission: input.companyDescription,
|
||||
goal: input.goalTitle,
|
||||
employeeName: input.employeeName,
|
||||
executionMode: input.executionMode,
|
||||
autoApproveHires: input.autoApproveHires,
|
||||
reviewDeliverables: input.reviewDeliverables,
|
||||
});
|
||||
const employeeId = this.requirePrimaryEmployee(orgId);
|
||||
const providerId = this.requireProvider(orgId, provider.key);
|
||||
this.deps.saveProviderCredential({
|
||||
orgId,
|
||||
providerId,
|
||||
secret: input.providerSecret,
|
||||
model: input.providerModel,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const workspaceId = this.createWorkspace(orgId, input.workspacePath.trim(), now);
|
||||
const ceoAgentId = this.organization.createAgent({
|
||||
orgId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
department: "Executive",
|
||||
modelProviderId: providerId,
|
||||
modelId: input.providerModel.trim(),
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
roleLevel: "c_suite",
|
||||
modelStrategy: "explicit",
|
||||
workspaceId,
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: 120,
|
||||
});
|
||||
const goalId = this.createGoal(orgId, ceoAgentId, input, now);
|
||||
const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now);
|
||||
this.createProjectChatSpace(orgId, projectName(input));
|
||||
if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now);
|
||||
const taskId = this.organization.createTask({
|
||||
orgId,
|
||||
title: goalKickoffTitle(input.goalTitle),
|
||||
description: goalKickoffDescription(input.companyName, input.goalTitle, input.goalDescription),
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: ceoAgentId,
|
||||
risk: RISK_LEVEL.MEDIUM,
|
||||
projectId,
|
||||
goalId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
requiresFilesystem: workspaceId !== null,
|
||||
});
|
||||
const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now);
|
||||
|
||||
this.seedComposioSettings(orgId, now);
|
||||
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "organization",
|
||||
entityId: orgId,
|
||||
actorType: "employee",
|
||||
actorId: employeeId,
|
||||
action: "bootstrapped",
|
||||
payload: { providerKey: provider.key, goalId, projectId, taskId, runId },
|
||||
});
|
||||
|
||||
return { orgId, employeeId, ceoAgentId, workspaceId, goalId, projectId, taskId, runId, providerId };
|
||||
}
|
||||
|
||||
private resolveProvider(providerKey: string) {
|
||||
const provider = PROVIDER_CATALOG.find((entry) => entry.key === providerKey);
|
||||
if (!provider) throw new Error(`Provider ${providerKey} is unavailable.`);
|
||||
if (provider.authType === PROVIDER_AUTH_TYPE.CLOUD && !provider.customEndpoint) {
|
||||
throw new Error("Cloud providers need extended configuration — connect them from Settings after the company is created.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private requirePrimaryEmployee(orgId: string): string {
|
||||
const row = this.deps.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1")
|
||||
.get(orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Primary employee was not created.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private requireProvider(orgId: string, providerKey: string): string {
|
||||
const row = this.deps.db.prepare("select id from providers where org_id = ? and provider_key = ?")
|
||||
.get(orgId, providerKey) as { id: string } | undefined;
|
||||
if (!row) throw new Error(`Provider ${providerKey} was not created.`);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private createWorkspace(orgId: string, workspacePath: string, now: string): string | null {
|
||||
if (!workspacePath) return null;
|
||||
const workspaceId = createId("wsp");
|
||||
this.deps.db.prepare(`
|
||||
insert into workspaces(
|
||||
id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(workspaceId, orgId, "Default Workspace", workspacePath, "", "", null, 1, now, now);
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
private createGoal(orgId: string, ceoAgentId: string, input: BootstrapOnboardingInput, now: string): string {
|
||||
const goalTitle = input.goalTitle.trim();
|
||||
if (!goalTitle) throw new Error("Goal title is required.");
|
||||
const goalId = createId("gol");
|
||||
this.deps.db.prepare(`
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(goalId, orgId, goalTitle, input.goalDescription.trim(), "active", ceoAgentId, now, now);
|
||||
return goalId;
|
||||
}
|
||||
|
||||
private createProject(
|
||||
orgId: string,
|
||||
goalId: string,
|
||||
ceoAgentId: string,
|
||||
input: BootstrapOnboardingInput,
|
||||
now: string,
|
||||
): string {
|
||||
const projectId = createId("prj");
|
||||
this.deps.db.prepare(`
|
||||
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
projectId,
|
||||
orgId,
|
||||
goalId,
|
||||
projectName(input),
|
||||
input.companyDescription.trim(),
|
||||
ceoAgentId,
|
||||
"in_progress",
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
private createProjectChatSpace(orgId: string, name: string): string {
|
||||
return this.chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: projectSpaceName(name),
|
||||
topic: name,
|
||||
visibility: "workspace",
|
||||
});
|
||||
}
|
||||
|
||||
private bindWorkspaceToProject(workspaceId: string, projectId: string, now: string): void {
|
||||
this.deps.db.prepare("update workspaces set project_id = ?, updated_at = ? where id = ?")
|
||||
.run(projectId, now, workspaceId);
|
||||
}
|
||||
|
||||
private createTaskRun(orgId: string, taskId: string, ceoAgentId: string, providerId: string, workspaceId: string | null, now: string): string {
|
||||
const runId = createId("run");
|
||||
const executionContextId = createId("ctx");
|
||||
const serviceIdentityId = this.requireServiceIdentity(orgId, ceoAgentId);
|
||||
const workspaceMountJson = JSON.stringify(workspaceId ? [{
|
||||
workspaceId,
|
||||
mode: "read_write",
|
||||
}] : []);
|
||||
const secretScopeJson = JSON.stringify({
|
||||
serviceIdentityId,
|
||||
providerIds: [providerId],
|
||||
scope: "bootstrap",
|
||||
credentialAccess: "provider_runtime_only",
|
||||
});
|
||||
this.deps.db.prepare(`
|
||||
insert into task_runs(
|
||||
id, org_id, task_id, agent_id, provider_id, provider_request_id,
|
||||
execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json,
|
||||
trigger, state, created_at, started_at, completed_at, error_message
|
||||
)
|
||||
values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
runId,
|
||||
orgId,
|
||||
taskId,
|
||||
ceoAgentId,
|
||||
providerId,
|
||||
executionContextId,
|
||||
serviceIdentityId,
|
||||
workspaceMountJson,
|
||||
secretScopeJson,
|
||||
"bootstrap",
|
||||
"queued",
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
return runId;
|
||||
}
|
||||
|
||||
private requireServiceIdentity(orgId: string, agentId: string): string {
|
||||
const row = this.deps.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
|
||||
.get(orgId, agentId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Agent service identity was not found.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private seedComposioSettings(orgId: string, now: string): void {
|
||||
const existing = this.deps.db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = 'api_key'",
|
||||
).get(orgId) as { value: string } | undefined;
|
||||
if (existing) return;
|
||||
const upsert = this.deps.db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`);
|
||||
upsert.run(orgId, "api_key", COMPOSIO_API_KEY, now);
|
||||
upsert.run(orgId, "toolkits", JSON.stringify(DEFAULT_COMPOSIO_TOOLKITS), now);
|
||||
upsert.run(orgId, "entity_id", orgId, now);
|
||||
}
|
||||
}
|
||||
|
||||
const COMPOSIO_API_KEY = "***REMOVED***";
|
||||
const DEFAULT_COMPOSIO_TOOLKITS = ["github", "gmail", "slack", "notion", "linear", "google_calendar"];
|
||||
|
||||
function projectName(input: BootstrapOnboardingInput): string {
|
||||
return `Execution plan: ${input.goalTitle.trim()}`;
|
||||
}
|
||||
|
||||
function projectSpaceName(name: string): string {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
||||
return slug || "project";
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunChaosDrillInput, SetFeatureFlagInput } from "../../shared/commands";
|
||||
import {
|
||||
CHAOS_DRILL_SCENARIO,
|
||||
CHAOS_DRILL_STATE,
|
||||
FEATURE_FLAG_KEY,
|
||||
SLO_SIGNAL_KEY,
|
||||
type ChaosDrillScenario,
|
||||
type FeatureFlagKey,
|
||||
type SloSignalKey,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const FEATURE_FLAGS = [
|
||||
{ key: FEATURE_FLAG_KEY.PROVIDER_BACKGROUND_EXECUTION, label: "Provider background execution", enabled: true },
|
||||
{ key: FEATURE_FLAG_KEY.SOCIAL_PUBLISH_APPROVAL, label: "Social publish approval", enabled: true },
|
||||
{ key: FEATURE_FLAG_KEY.BOARD_INTERVENTION, label: "Board intervention", enabled: true },
|
||||
{ key: FEATURE_FLAG_KEY.CHAOS_DRILLS, label: "Chaos drills", enabled: true },
|
||||
] as const;
|
||||
|
||||
const SLO_TARGETS = [
|
||||
{ signalKey: SLO_SIGNAL_KEY.PROVIDER_REQUEST_SUCCESS, label: "Provider requests", targetBasisPoints: 9950, windowMinutes: 43200 },
|
||||
{ signalKey: SLO_SIGNAL_KEY.APPROVAL_ROUTE_COVERAGE, label: "Approval routing", targetBasisPoints: 10000, windowMinutes: 43200 },
|
||||
{ signalKey: SLO_SIGNAL_KEY.BROWSER_SESSION_BOUNDARY, label: "Browser session boundary", targetBasisPoints: 10000, windowMinutes: 43200 },
|
||||
{ signalKey: SLO_SIGNAL_KEY.SKILL_RESOLVE_SUCCESS, label: "Skill resolve", targetBasisPoints: 9950, windowMinutes: 43200 },
|
||||
{ signalKey: SLO_SIGNAL_KEY.MIGRATION_VALIDATION_SUCCESS, label: "Migration validation", targetBasisPoints: 10000, windowMinutes: 43200 },
|
||||
] as const;
|
||||
|
||||
const CHAOS_DRILLS = [
|
||||
{ key: "provider-outage-policy", label: "Provider outage policy", scenarioKey: CHAOS_DRILL_SCENARIO.PROVIDER_OUTAGE_POLICY_GATE },
|
||||
{ key: "approval-backlog-routing", label: "Approval backlog routing", scenarioKey: CHAOS_DRILL_SCENARIO.APPROVAL_BACKLOG_GATE },
|
||||
{ key: "browser-session-boundary", label: "Browser session boundary", scenarioKey: CHAOS_DRILL_SCENARIO.BROWSER_SESSION_BOUNDARY },
|
||||
{ key: "migration-rollback-pack", label: "Migration rollback pack", scenarioKey: CHAOS_DRILL_SCENARIO.MIGRATION_ROLLBACK_PACK },
|
||||
] as const;
|
||||
|
||||
interface DrillOutcome {
|
||||
success: boolean;
|
||||
checks: Record<string, number | boolean | string>;
|
||||
}
|
||||
|
||||
interface ChaosDrillRow {
|
||||
id: string;
|
||||
scenario_key: ChaosDrillScenario;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export class OperationsService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
seedOrganizationOperations(orgId: string, now: string): void {
|
||||
for (const flag of FEATURE_FLAGS) {
|
||||
this.db.prepare(`
|
||||
insert into feature_flags(id, org_id, key, label, enabled, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, key) do nothing
|
||||
`).run(createId("ffg"), orgId, flag.key, flag.label, flag.enabled ? 1 : 0, now, now);
|
||||
}
|
||||
for (const target of SLO_TARGETS) {
|
||||
this.db.prepare(`
|
||||
insert into slo_targets(id, org_id, signal_key, label, target_basis_points, window_minutes, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, signal_key) do nothing
|
||||
`).run(createId("slo"), orgId, target.signalKey, target.label, target.targetBasisPoints, target.windowMinutes, now, now);
|
||||
}
|
||||
for (const drill of CHAOS_DRILLS) {
|
||||
this.db.prepare(`
|
||||
insert into chaos_drills(id, org_id, key, label, scenario_key, enabled, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
on conflict(org_id, key) do nothing
|
||||
`).run(createId("cdr"), orgId, drill.key, drill.label, drill.scenarioKey, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
setFeatureFlag(input: SetFeatureFlagInput): void {
|
||||
const flag = this.db.prepare("select key from feature_flags where id = ? and org_id = ?")
|
||||
.get(input.flagId, input.orgId) as { key: FeatureFlagKey } | undefined;
|
||||
if (!flag) throw new Error("Feature flag was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare("update feature_flags set enabled = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(input.enabled ? 1 : 0, now, input.flagId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Feature flag was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "feature_flag",
|
||||
entityId: input.flagId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "updated",
|
||||
payload: { key: flag.key, enabled: input.enabled },
|
||||
});
|
||||
}
|
||||
|
||||
runChaosDrill(input: RunChaosDrillInput): string {
|
||||
if (!this.isFeatureEnabled(input.orgId, FEATURE_FLAG_KEY.CHAOS_DRILLS)) throw new Error("Chaos drills are disabled.");
|
||||
const drill = this.db.prepare("select id, scenario_key, enabled from chaos_drills where id = ? and org_id = ?")
|
||||
.get(input.drillId, input.orgId) as ChaosDrillRow | undefined;
|
||||
if (!drill) throw new Error("Chaos drill was not found.");
|
||||
if (drill.enabled !== 1) throw new Error("Chaos drill is disabled.");
|
||||
const startedAt = new Date().toISOString();
|
||||
const outcome = this.evaluateScenario(input.orgId, drill.scenario_key);
|
||||
const runId = createId("cdrun");
|
||||
const completedAt = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chaos_drill_runs(id, org_id, drill_id, state, result_json, started_at, completed_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
runId,
|
||||
input.orgId,
|
||||
input.drillId,
|
||||
outcome.success ? CHAOS_DRILL_STATE.PASSED : CHAOS_DRILL_STATE.FAILED,
|
||||
JSON.stringify(outcome.checks),
|
||||
startedAt,
|
||||
completedAt,
|
||||
);
|
||||
this.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: this.signalForScenario(drill.scenario_key),
|
||||
source: "chaos_drill",
|
||||
success: outcome.success,
|
||||
durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
|
||||
detail: { drillId: input.drillId, scenarioKey: drill.scenario_key },
|
||||
});
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chaos_drill",
|
||||
entityId: input.drillId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "run",
|
||||
payload: { runId, state: outcome.success ? CHAOS_DRILL_STATE.PASSED : CHAOS_DRILL_STATE.FAILED },
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
|
||||
isFeatureEnabled(orgId: string, key: FeatureFlagKey): boolean {
|
||||
const row = this.db.prepare("select enabled from feature_flags where org_id = ? and key = ?")
|
||||
.get(orgId, key) as { enabled: number } | undefined;
|
||||
if (!row) throw new Error("Feature flag is required.");
|
||||
return row.enabled === 1;
|
||||
}
|
||||
|
||||
recordSignal(input: {
|
||||
orgId: string;
|
||||
signalKey: SloSignalKey;
|
||||
source: string;
|
||||
success: boolean;
|
||||
durationMs: number | null;
|
||||
detail: unknown;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert into operational_events(id, org_id, signal_key, source, success, duration_ms, detail_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("ope"),
|
||||
input.orgId,
|
||||
input.signalKey,
|
||||
input.source,
|
||||
input.success ? 1 : 0,
|
||||
input.durationMs,
|
||||
JSON.stringify(input.detail),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
private evaluateScenario(orgId: string, scenario: ChaosDrillScenario): DrillOutcome {
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.PROVIDER_OUTAGE_POLICY_GATE) return this.providerOutagePolicyGate(orgId);
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.APPROVAL_BACKLOG_GATE) return this.approvalBacklogGate(orgId);
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.BROWSER_SESSION_BOUNDARY) return this.browserSessionBoundary(orgId);
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.MIGRATION_ROLLBACK_PACK) return this.migrationRollbackPack(orgId);
|
||||
throw new Error("Chaos drill scenario is invalid.");
|
||||
}
|
||||
|
||||
private providerOutagePolicyGate(orgId: string): DrillOutcome {
|
||||
const total = count(this.db, "select count(*) as count from providers where org_id = ?", orgId);
|
||||
const policyConflicts = count(this.db, `
|
||||
select count(*) as count
|
||||
from providers
|
||||
where org_id = ? and data_policy = 'zero_data_retention' and background_enabled = 1
|
||||
`, orgId);
|
||||
const invalidLimits = count(this.db, "select count(*) as count from providers where org_id = ? and monthly_request_limit < 1", orgId);
|
||||
return {
|
||||
success: total > 0 && policyConflicts === 0 && invalidLimits === 0,
|
||||
checks: { providers: total, policyConflicts, invalidLimits },
|
||||
};
|
||||
}
|
||||
|
||||
private approvalBacklogGate(orgId: string): DrillOutcome {
|
||||
const routes = count(this.db, "select count(*) as count from approval_routing_rules where org_id = ?", orgId);
|
||||
const unassigned = count(this.db, `
|
||||
select count(*) as count
|
||||
from approvals
|
||||
where org_id = ? and id not in (
|
||||
select approval_id from approval_assignments where org_id = ?
|
||||
)
|
||||
`, orgId, orgId);
|
||||
return { success: routes > 0 && unassigned === 0, checks: { routes, unassignedApprovals: unassigned } };
|
||||
}
|
||||
|
||||
private browserSessionBoundary(orgId: string): DrillOutcome {
|
||||
const accounts = count(this.db, "select count(*) as count from social_accounts where org_id = ?", orgId);
|
||||
const grants = count(this.db, "select count(*) as count from social_account_grants where org_id = ?", orgId);
|
||||
const actions = count(this.db, "select count(*) as count from browser_actions where org_id = ?", orgId);
|
||||
return { success: grants >= accounts, checks: { accounts, grants, actions } };
|
||||
}
|
||||
|
||||
private migrationRollbackPack(orgId: string): DrillOutcome {
|
||||
const reports = this.db.prepare("select report_json from migration_reports where org_id = ?").all(orgId) as Array<{ report_json: string }>;
|
||||
const complete = reports.filter((report) => hasRollbackPack(report.report_json)).length;
|
||||
return { success: reports.length === complete, checks: { reports: reports.length, rollbackPacks: complete } };
|
||||
}
|
||||
|
||||
private signalForScenario(scenario: ChaosDrillScenario): SloSignalKey {
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.PROVIDER_OUTAGE_POLICY_GATE) return SLO_SIGNAL_KEY.PROVIDER_REQUEST_SUCCESS;
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.APPROVAL_BACKLOG_GATE) return SLO_SIGNAL_KEY.APPROVAL_ROUTE_COVERAGE;
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.BROWSER_SESSION_BOUNDARY) return SLO_SIGNAL_KEY.BROWSER_SESSION_BOUNDARY;
|
||||
if (scenario === CHAOS_DRILL_SCENARIO.MIGRATION_ROLLBACK_PACK) return SLO_SIGNAL_KEY.MIGRATION_VALIDATION_SUCCESS;
|
||||
throw new Error("Chaos drill scenario is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function count(db: DatabaseSync, sql: string, ...params: string[]): number {
|
||||
return (db.prepare(sql).get(...params) as { count: number }).count;
|
||||
}
|
||||
|
||||
function hasRollbackPack(reportJson: string): boolean {
|
||||
const parsed = JSON.parse(reportJson) as { rollbackPack?: unknown };
|
||||
return typeof parsed.rollbackPack === "object" && parsed.rollbackPack !== null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateAgentInput } from "../../shared/commands";
|
||||
import { RISK_LEVEL, ROUTE_TO_TYPE, SLO_SIGNAL_KEY, type RiskLevel } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
|
||||
export const ORGANIZATION_DEPARTMENTS = ["Executive", "Engineering", "Product", "Design", "Marketing", "Operations", "Support"] as const;
|
||||
const RISK_RANK: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
|
||||
|
||||
export class OrgGovernanceService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly operations: OperationsService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.operations = new OperationsService(db);
|
||||
}
|
||||
|
||||
seedOrganizationGovernance(orgId: string, boardEmployeeId: string, now: string): void {
|
||||
for (const department of ORGANIZATION_DEPARTMENTS) this.seedDepartmentPolicy(orgId, department, now);
|
||||
this.db.prepare(`
|
||||
insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, kind, minimum_risk) do nothing
|
||||
`).run(createId("arr"), orgId, "*", RISK_LEVEL.LOW, ROUTE_TO_TYPE.EMPLOYEE, boardEmployeeId, now);
|
||||
}
|
||||
|
||||
/** Resolves the canonical department name (case-insensitive), or throws naming the valid choices. */
|
||||
requireCanonicalDepartment(orgId: string, department: string): string {
|
||||
const requested = department.trim();
|
||||
if (!requested) throw new Error("Department is required.");
|
||||
const row = this.db.prepare(`
|
||||
select department_name from hiring_policies
|
||||
where org_id = ? and lower(department_name) = lower(?)
|
||||
`).get(orgId, requested) as { department_name: string } | undefined;
|
||||
if (!row) {
|
||||
const known = this.db.prepare("select department_name from hiring_policies where org_id = ? order by department_name asc")
|
||||
.all(orgId) as Array<{ department_name: string }>;
|
||||
throw new Error(`Unknown department "${requested}". Departments: ${known.map((entry) => entry.department_name).join(", ")}.`);
|
||||
}
|
||||
return row.department_name;
|
||||
}
|
||||
|
||||
enforceHiringPolicy(input: CreateAgentInput): HiringPolicyDecision {
|
||||
const departmentName = this.requireCanonicalDepartment(input.orgId, input.department);
|
||||
const policy = this.db.prepare(`
|
||||
select max_headcount, monthly_budget_limit_cents, require_approval_above_cents
|
||||
from hiring_policies
|
||||
where org_id = ? and department_name = ?
|
||||
`).get(input.orgId, departmentName) as HiringPolicyRow | undefined;
|
||||
if (!policy) throw new Error("Hiring policy is required for department.");
|
||||
const usage = this.db.prepare(`
|
||||
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
||||
from agents
|
||||
where org_id = ? and department = ? and lifecycle = ?
|
||||
`).get(input.orgId, departmentName, "active") as { headcount: number; budget_cents: number };
|
||||
if (usage.headcount >= policy.max_headcount) throw new Error("Department headcount limit has been reached.");
|
||||
if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) {
|
||||
throw new Error("Department monthly budget limit has been reached.");
|
||||
}
|
||||
return { reviewRequired: input.budgetCents > policy.require_approval_above_cents, departmentName };
|
||||
}
|
||||
|
||||
routeApproval(input: { orgId: string; approvalId: string; kind: string; risk: RiskLevel }): void {
|
||||
const rule = this.resolveRoutingRule(input.orgId, input.kind, input.risk);
|
||||
this.assertRouteTarget(input.orgId, rule.route_to_type, rule.route_to_id);
|
||||
this.db.prepare(`
|
||||
insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(approval_id, assignee_type, assignee_id) do nothing
|
||||
`).run(createId("aas"), input.orgId, input.approvalId, rule.route_to_type, rule.route_to_id, rule.id, new Date().toISOString());
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: input.approvalId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "routed",
|
||||
payload: { routingRuleId: rule.id, routeToType: rule.route_to_type, routeToId: rule.route_to_id },
|
||||
});
|
||||
this.operations.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: SLO_SIGNAL_KEY.APPROVAL_ROUTE_COVERAGE,
|
||||
source: "approval_routing",
|
||||
success: true,
|
||||
durationMs: null,
|
||||
detail: { approvalId: input.approvalId, routingRuleId: rule.id },
|
||||
});
|
||||
}
|
||||
|
||||
private seedDepartmentPolicy(orgId: string, departmentName: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at)
|
||||
values (?, ?, ?, null, 12, 2500000, ?, ?)
|
||||
on conflict(org_id, name) do nothing
|
||||
`).run(createId("dep"), orgId, departmentName, now, now);
|
||||
this.db.prepare(`
|
||||
insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at)
|
||||
values (?, ?, ?, 12, 2500000, 500000, ?, ?)
|
||||
on conflict(org_id, department_name) do nothing
|
||||
`).run(createId("hpl"), orgId, departmentName, now, now);
|
||||
}
|
||||
|
||||
private resolveRoutingRule(orgId: string, kind: string, risk: RiskLevel): ApprovalRoutingRuleRow {
|
||||
const rows = this.db.prepare(`
|
||||
select id, kind, minimum_risk, route_to_type, route_to_id
|
||||
from approval_routing_rules
|
||||
where org_id = ? and (kind = ? or kind = ?)
|
||||
`).all(orgId, kind, "*") as unknown as ApprovalRoutingRuleRow[];
|
||||
const matching = rows
|
||||
.filter((row) => riskApplies(row.minimum_risk, risk))
|
||||
.sort((a, b) => ruleRank(kind, b) - ruleRank(kind, a));
|
||||
const selected = matching[0];
|
||||
if (!selected) throw new Error("Approval routing rule is required.");
|
||||
return selected;
|
||||
}
|
||||
|
||||
private assertRouteTarget(orgId: string, routeToType: string, routeToId: string): void {
|
||||
if (routeToType === ROUTE_TO_TYPE.BOARD) {
|
||||
if (routeToId !== orgId) throw new Error("Board approval route is invalid.");
|
||||
return;
|
||||
}
|
||||
if (routeToType === ROUTE_TO_TYPE.EMPLOYEE) {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(routeToId, orgId);
|
||||
if (!employee) throw new Error("Approval route employee was not found.");
|
||||
return;
|
||||
}
|
||||
if (routeToType === ROUTE_TO_TYPE.AGENT) {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(routeToId, orgId);
|
||||
if (!agent) throw new Error("Approval route agent was not found.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Approval route target type is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
interface HiringPolicyRow {
|
||||
max_headcount: number;
|
||||
monthly_budget_limit_cents: number;
|
||||
require_approval_above_cents: number;
|
||||
}
|
||||
|
||||
interface HiringPolicyDecision {
|
||||
reviewRequired: boolean;
|
||||
/** Canonical department name from the policy table (the input may differ in case). */
|
||||
departmentName: string;
|
||||
}
|
||||
|
||||
interface ApprovalRoutingRuleRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
minimum_risk: string;
|
||||
route_to_type: string;
|
||||
route_to_id: string;
|
||||
}
|
||||
|
||||
function riskApplies(minimumRisk: string, risk: RiskLevel): boolean {
|
||||
const required = RISK_RANK[minimumRisk as RiskLevel];
|
||||
if (required === undefined) throw new Error("Approval routing risk is invalid.");
|
||||
return RISK_RANK[risk] >= required;
|
||||
}
|
||||
|
||||
function ruleRank(kind: string, rule: ApprovalRoutingRuleRow): number {
|
||||
const exactKind = rule.kind === kind ? 100 : 0;
|
||||
return exactKind + RISK_RANK[rule.minimum_risk as RiskLevel];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateOrganizationInput } from "../../shared/commands";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { PolicyService } from "./policy-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { SdlcService } from "./sdlc-service";
|
||||
import { assertUsableMentionName } from "../../shared/mention-handles";
|
||||
|
||||
/**
|
||||
* Seeds a brand-new organization in one transaction's worth of writes: the org
|
||||
* row and its Board Chair employee, the identity / governance / operations /
|
||||
* SDLC / policy defaults, the starter chat channels, the disabled provider
|
||||
* catalog, and the active-org pointer. Returns the new organization id.
|
||||
*/
|
||||
export function seedOrganization(db: DatabaseSync, input: CreateOrganizationInput): string {
|
||||
const audit = new AuditService(db);
|
||||
const policy = new PolicyService(db);
|
||||
const identityAccess = new IdentityAccessService(db);
|
||||
const governance = new OrgGovernanceService(db);
|
||||
const operations = new OperationsService(db);
|
||||
const sdlc = new SdlcService(db);
|
||||
|
||||
assertUsableMentionName(input.employeeName);
|
||||
const orgId = createId("org");
|
||||
const employeeId = createId("emp");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.prepare(`
|
||||
insert into organizations(
|
||||
id, name, mission, goal, description, execution_mode, auto_approve_hires, review_deliverables, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
orgId,
|
||||
input.organizationName.trim(),
|
||||
input.mission.trim(),
|
||||
input.goal.trim(),
|
||||
input.description?.trim() ?? "",
|
||||
input.executionMode ?? "ai_ceo",
|
||||
input.autoApproveHires === false ? 0 : 1,
|
||||
input.reviewDeliverables === true ? 1 : 0,
|
||||
now,
|
||||
);
|
||||
db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||
identityAccess.seedEmployeeAccess(orgId, employeeId, now);
|
||||
governance.seedOrganizationGovernance(orgId, employeeId, now);
|
||||
operations.seedOrganizationOperations(orgId, now);
|
||||
sdlc.seedOrganizationSdlc(orgId, now);
|
||||
|
||||
for (const space of [
|
||||
["company", "general", "Company coordination", "workspace"],
|
||||
["department", "product", "Product decisions", "workspace"],
|
||||
["department", "engineering", "Engineering delivery", "workspace"],
|
||||
["company", "board", "Board decisions", "restricted"],
|
||||
]) {
|
||||
const spaceId = createId("spc");
|
||||
db.prepare(`
|
||||
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(spaceId, orgId, space[0], space[1], space[2], space[3], now);
|
||||
db.prepare(`
|
||||
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(spaceId, "employee", employeeId, space[1] === "board" ? "approver" : "member", "unmuted");
|
||||
}
|
||||
|
||||
for (const provider of PROVIDER_CATALOG) {
|
||||
db.prepare(`
|
||||
insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("prv"),
|
||||
orgId,
|
||||
provider.key,
|
||||
provider.label,
|
||||
provider.authType,
|
||||
provider.defaultModel,
|
||||
null,
|
||||
0,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
policy.seedOrganizationPolicies(orgId, now);
|
||||
db.prepare(`
|
||||
insert into app_state(id, active_org_id, updated_at)
|
||||
values (1, ?, ?)
|
||||
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
|
||||
`).run(orgId, now);
|
||||
audit.record({
|
||||
orgId,
|
||||
entityType: "organization",
|
||||
entityId: orgId,
|
||||
actorType: "employee",
|
||||
actorId: employeeId,
|
||||
action: "created",
|
||||
payload: { name: input.organizationName.trim() },
|
||||
});
|
||||
return orgId;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateTaskInput } from "../../shared/commands";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
|
||||
import { PolicyService } from "./policy-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
import { assertUsableMentionName } from "../../shared/mention-handles";
|
||||
import { seedOrganization } from "./organization-seed";
|
||||
|
||||
export class OrganizationService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly policy: PolicyService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
private readonly governance: OrgGovernanceService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.policy = new PolicyService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
this.governance = new OrgGovernanceService(db);
|
||||
}
|
||||
|
||||
createOrganization(input: CreateOrganizationInput): string {
|
||||
return seedOrganization(this.db, input);
|
||||
}
|
||||
|
||||
setActiveOrganization(orgId: string): void {
|
||||
const row = this.db.prepare("select id from organizations where id = ?").get(orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into app_state(id, active_org_id, updated_at)
|
||||
values (1, ?, ?)
|
||||
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
|
||||
`).run(orgId, now);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "app_state",
|
||||
entityId: "active_org",
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "switched",
|
||||
payload: { orgId },
|
||||
});
|
||||
}
|
||||
|
||||
deleteOrganization(orgId: string): void {
|
||||
const row = this.db.prepare("select id from organizations where id = ?")
|
||||
.get(orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
this.db.prepare("delete from organizations where id = ?").run(orgId);
|
||||
const nextActive = this.db.prepare("select id from organizations order by created_at asc limit 1")
|
||||
.get() as { id: string } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into app_state(id, active_org_id, updated_at)
|
||||
values (1, ?, ?)
|
||||
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
|
||||
`).run(nextActive?.id ?? null, now);
|
||||
}
|
||||
|
||||
createAgent(input: CreateAgentInput): string {
|
||||
assertUsableMentionName(input.name);
|
||||
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
||||
const id = createId("agt");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into agents(
|
||||
id, org_id, name, role, department, model_provider_id, model_id,
|
||||
budget_cents, manager_agent_id, title, role_level, model_strategy,
|
||||
workspace_id, heartbeat_enabled, heartbeat_interval_sec, lifecycle, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.name.trim(),
|
||||
input.role.trim(),
|
||||
hiringPolicy.departmentName,
|
||||
input.modelProviderId,
|
||||
input.modelId,
|
||||
input.budgetCents,
|
||||
input.managerAgentId,
|
||||
input.title?.trim() || input.role.trim(),
|
||||
input.roleLevel ?? "staff",
|
||||
input.modelStrategy ?? "pooled",
|
||||
input.workspaceId ?? null,
|
||||
input.heartbeatEnabled === true ? 1 : 0,
|
||||
input.heartbeatIntervalSec ?? 0,
|
||||
"active",
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "agent",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { role: input.role, department: hiringPolicy.departmentName },
|
||||
});
|
||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
||||
if (hiringPolicy.reviewRequired) {
|
||||
const approvalId = createId("app");
|
||||
const autoApprove = this.autonomy(input.orgId).autoApproveHires;
|
||||
const decision = this.approvalDecision(input.orgId, autoApprove, now);
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type,
|
||||
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
approvalId,
|
||||
input.orgId,
|
||||
"agent_hire",
|
||||
`Review hire: ${input.name.trim()}`,
|
||||
`Department: ${hiringPolicy.departmentName}\nRole: ${input.role.trim()}\nBudget: ${input.budgetCents}`,
|
||||
RISK_LEVEL.MEDIUM,
|
||||
"system",
|
||||
null,
|
||||
"agent",
|
||||
id,
|
||||
decision.state,
|
||||
decision.decidedBy,
|
||||
now,
|
||||
decision.decidedAt,
|
||||
);
|
||||
this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove);
|
||||
}
|
||||
this.kickoffUntouchedGoals(input.orgId);
|
||||
return id;
|
||||
}
|
||||
|
||||
updateAgent(input: UpdateAgentInput): void {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?")
|
||||
.get(input.agentId, input.orgId) as { id: string } | undefined;
|
||||
if (!agent) throw new Error("Agent was not found.");
|
||||
const role = input.role.trim();
|
||||
if (!role) throw new Error("Agent role is required.");
|
||||
if (input.modelProviderId) {
|
||||
const provider = this.db.prepare("select id from providers where id = ? and org_id = ?")
|
||||
.get(input.modelProviderId, input.orgId) as { id: string } | undefined;
|
||||
if (!provider) throw new Error("Agent provider was not found.");
|
||||
}
|
||||
const department = this.governance.requireCanonicalDepartment(input.orgId, input.department);
|
||||
const budgetCents = Math.max(0, Math.round(input.budgetCents));
|
||||
const result = this.db.prepare(`
|
||||
update agents
|
||||
set role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, input.agentId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Agent update failed.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "updated",
|
||||
payload: { role, roleLevel: input.roleLevel.trim() || "staff" },
|
||||
});
|
||||
}
|
||||
|
||||
createTask(input: CreateTaskInput): string {
|
||||
const id = createId("tsk");
|
||||
const now = new Date().toISOString();
|
||||
this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent");
|
||||
this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
|
||||
this.verifyOptionalOrgEntity("workspaces", input.workspaceId, input.orgId, "Workspace");
|
||||
this.verifyProject(input.projectId, input.goalId, input.orgId);
|
||||
|
||||
this.db.prepare(`
|
||||
insert into tasks(
|
||||
id, org_id, title, description, status, assignee_agent_id, risk,
|
||||
project_id, goal_id, workspace_id, priority, requires_filesystem, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.title.trim(),
|
||||
input.description.trim(),
|
||||
input.status,
|
||||
input.assigneeAgentId,
|
||||
input.risk,
|
||||
input.projectId,
|
||||
input.goalId,
|
||||
input.workspaceId,
|
||||
input.priority,
|
||||
input.requiresFilesystem ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
||||
const approvalRequired = policy.approvalRequired || input.requiresFilesystem;
|
||||
if (approvalRequired) {
|
||||
const approvalId = createId("app");
|
||||
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
|
||||
const autoApprove = this.autonomy(input.orgId).executionMode === "ai_ceo";
|
||||
const decision = this.approvalDecision(input.orgId, autoApprove, now);
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type,
|
||||
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
approvalId,
|
||||
input.orgId,
|
||||
approvalKind,
|
||||
input.title.trim(),
|
||||
input.description.trim(),
|
||||
RISK_LEVEL.HIGH,
|
||||
"system",
|
||||
null,
|
||||
"task",
|
||||
id,
|
||||
decision.state,
|
||||
decision.decidedBy,
|
||||
now,
|
||||
decision.decidedAt,
|
||||
);
|
||||
this.settleApproval(input.orgId, approvalId, approvalKind, RISK_LEVEL.HIGH, autoApprove);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { status: TASK_STATUS.BACKLOG, policyRuleId: policy.matchedRuleId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the org's autonomy settings. In `ai_ceo` mode the CEO runs without
|
||||
* the operator approving each task; `autoApproveHires` clears hire reviews.
|
||||
*/
|
||||
private autonomy(orgId: string): { executionMode: string; autoApproveHires: boolean } {
|
||||
const row = this.db.prepare("select execution_mode, auto_approve_hires from organizations where id = ?")
|
||||
.get(orgId) as { execution_mode: string; auto_approve_hires: number } | undefined;
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
return { executionMode: row.execution_mode, autoApproveHires: row.auto_approve_hires === 1 };
|
||||
}
|
||||
|
||||
/** The decision recorded on an approval: pending, or auto-approved by the board chair. */
|
||||
private approvalDecision(orgId: string, autoApprove: boolean, now: string): { state: ApprovalState; decidedBy: string | null; decidedAt: string | null } {
|
||||
if (!autoApprove) return { state: APPROVAL_STATE.PENDING, decidedBy: null, decidedAt: null };
|
||||
return { state: APPROVAL_STATE.APPROVED, decidedBy: this.boardChairEmployeeId(orgId), decidedAt: now };
|
||||
}
|
||||
|
||||
/** Routes a pending approval to its decider, or records the auto-approval in the audit trail. */
|
||||
private settleApproval(orgId: string, approvalId: string, kind: string, risk: typeof RISK_LEVEL[keyof typeof RISK_LEVEL], autoApproved: boolean): void {
|
||||
if (!autoApproved) {
|
||||
this.governance.routeApproval({ orgId, approvalId, kind, risk });
|
||||
return;
|
||||
}
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "approved",
|
||||
payload: { kind, auto: true },
|
||||
});
|
||||
}
|
||||
|
||||
private boardChairEmployeeId(orgId: string): string {
|
||||
const row = this.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1")
|
||||
.get(orgId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Organization has no employee to record the approval decision.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private verifyOptionalOrgEntity(table: "agents" | "goals" | "workspaces", id: string | null, orgId: string, label: string): void {
|
||||
if (!id) return;
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId);
|
||||
if (!row) throw new Error(`${label} was not found.`);
|
||||
}
|
||||
|
||||
private verifyProject(projectId: string | null, goalId: string | null, orgId: string): void {
|
||||
if (!projectId) return;
|
||||
const project = this.db.prepare("select goal_id from projects where id = ? and org_id = ?")
|
||||
.get(projectId, orgId) as { goal_id: string | null } | undefined;
|
||||
if (!project) throw new Error("Project was not found.");
|
||||
if (goalId && project.goal_id !== goalId) throw new Error("Project is linked to a different goal.");
|
||||
}
|
||||
|
||||
private addAgentToWorkspaceSpaces(orgId: string, agentId: string): void {
|
||||
const spaces = this.db.prepare("select id from chat_spaces where org_id = ? and visibility = ? and space_type <> ?")
|
||||
.all(orgId, "workspace", "direct") as Array<{ id: string }>;
|
||||
for (const space of spaces) {
|
||||
this.db.prepare(`
|
||||
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(space_id, principal_type, principal_id) do nothing
|
||||
`).run(space.id, "agent", agentId, "member", "unmuted");
|
||||
}
|
||||
}
|
||||
|
||||
createGoal(input: CreateGoalInput): string {
|
||||
const id = createId("gol");
|
||||
const now = new Date().toISOString();
|
||||
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
|
||||
this.db.prepare(`
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { title: input.title.trim() },
|
||||
});
|
||||
const plan = resolveGoalKickoff(this.db, {
|
||||
orgId: input.orgId,
|
||||
goalTitle: input.title,
|
||||
goalDescription: input.description,
|
||||
ownerAgentId: input.ownerAgentId,
|
||||
});
|
||||
if (plan) this.createKickoffTask(input.orgId, id, plan);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes a goal's kickoff plan as a first-class task: it passes the
|
||||
* same policy/approval gates as any other task and wakes the assignee.
|
||||
*/
|
||||
private createKickoffTask(orgId: string, goalId: string, plan: GoalKickoffPlan): string {
|
||||
ensureAgentHeartbeat(this.db, orgId, plan.assigneeAgentId);
|
||||
return this.createTask({
|
||||
orgId,
|
||||
title: plan.title,
|
||||
description: plan.description,
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: plan.assigneeAgentId,
|
||||
risk: RISK_LEVEL.MEDIUM,
|
||||
projectId: null,
|
||||
goalId,
|
||||
workspaceId: plan.workspaceId,
|
||||
priority: "high",
|
||||
requiresFilesystem: plan.workspaceId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Goals created before any agent existed (or whose owner was archived)
|
||||
* have no kickoff task yet; the moment an agent joins, each untouched
|
||||
* active goal is operationalized so no goal stays inert.
|
||||
*/
|
||||
private kickoffUntouchedGoals(orgId: string): void {
|
||||
const goals = this.db.prepare(`
|
||||
select id, title, description, owner_agent_id
|
||||
from goals g
|
||||
where g.org_id = ? and g.status = 'active'
|
||||
and not exists (select 1 from tasks t where t.goal_id = g.id)
|
||||
`).all(orgId) as Array<{ id: string; title: string; description: string; owner_agent_id: string | null }>;
|
||||
for (const goal of goals) {
|
||||
const plan = resolveGoalKickoff(this.db, {
|
||||
orgId,
|
||||
goalTitle: goal.title,
|
||||
goalDescription: goal.description,
|
||||
ownerAgentId: goal.owner_agent_id,
|
||||
});
|
||||
if (plan) this.createKickoffTask(orgId, goal.id, plan);
|
||||
}
|
||||
}
|
||||
|
||||
createProject(input: CreateProjectInput): string {
|
||||
const id = createId("prj");
|
||||
const now = new Date().toISOString();
|
||||
if (input.goalId) this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
|
||||
if (input.leadAgentId) this.verifyOptionalOrgEntity("agents", input.leadAgentId, input.orgId, "Lead agent");
|
||||
this.db.prepare(`
|
||||
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.goalId, input.name.trim(), input.description.trim(), input.leadAgentId, "in_progress", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createWorkspace(input: CreateWorkspaceInput): string {
|
||||
const id = createId("wsp");
|
||||
const now = new Date().toISOString();
|
||||
if (input.projectId) this.verifyProject(input.projectId, null, input.orgId);
|
||||
this.db.prepare(`
|
||||
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.name.trim(), input.localPath.trim(), "", "", input.projectId, 0, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "workspace",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
updateTask(input: UpdateTaskInput): void {
|
||||
const now = new Date().toISOString();
|
||||
if (input.assigneeAgentId) this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent");
|
||||
if (input.parentTaskId) {
|
||||
const parent = this.db.prepare("select id from tasks where id = ? and org_id = ?").get(input.parentTaskId, input.orgId);
|
||||
if (!parent) throw new Error("Parent task was not found.");
|
||||
}
|
||||
const result = this.db.prepare(`
|
||||
update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Task was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "task",
|
||||
entityId: input.taskId,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "updated",
|
||||
payload: { title: input.title.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
deleteTask(input: DeleteTaskInput): void {
|
||||
const result = this.db.prepare("delete from tasks where id = ? and org_id = ?").run(input.taskId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Task was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "task",
|
||||
entityId: input.taskId,
|
||||
actorType: "employee",
|
||||
actorId: null,
|
||||
action: "deleted",
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
||||
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(status, new Date().toISOString(), taskId, orgId);
|
||||
if (result.changes === 0) throw new Error("Task was not found.");
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task",
|
||||
entityId: taskId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "status_changed",
|
||||
payload: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RiskLevel } from "../../shared/domain";
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
const RISK_RANK: Record<RiskLevel, number> = {
|
||||
[RISK_LEVEL.LOW]: 0,
|
||||
[RISK_LEVEL.MEDIUM]: 1,
|
||||
[RISK_LEVEL.HIGH]: 2,
|
||||
};
|
||||
|
||||
export interface PolicyDecision {
|
||||
approvalRequired: boolean;
|
||||
matchedRuleId: string;
|
||||
}
|
||||
|
||||
export class PolicyService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
seedOrganizationPolicies(orgId: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("pol"), orgId, "organization", orgId, "task_create", "require_approval", RISK_LEVEL.MEDIUM, now);
|
||||
}
|
||||
|
||||
evaluateTaskCreation(orgId: string, risk: RiskLevel): PolicyDecision {
|
||||
const rule = this.db.prepare(`
|
||||
select id, effect, risk_ceiling
|
||||
from policy_rules
|
||||
where org_id = ? and action = ?
|
||||
order by created_at asc
|
||||
limit 1
|
||||
`).get(orgId, "task_create") as PolicyRuleRow | undefined;
|
||||
if (!rule) throw new Error("Policy rule is required for task creation.");
|
||||
if (rule.effect !== "require_approval") throw new Error(`Unsupported policy effect: ${rule.effect}`);
|
||||
return {
|
||||
approvalRequired: RISK_RANK[risk] > RISK_RANK[rule.risk_ceiling],
|
||||
matchedRuleId: rule.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface PolicyRuleRow {
|
||||
id: string;
|
||||
effect: string;
|
||||
risk_ceiling: RiskLevel;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProviderAuthType } from "../../shared/domain";
|
||||
import type { ProviderModelEntry } from "../../shared/provider-models";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import type { ProviderAuthProfile } from "./provider-auth-profiles";
|
||||
import type { ProviderBackgroundCreateResult, ProviderBackgroundRetrieveResult } from "./provider-background";
|
||||
import type { ProviderAuthorizeHandler, ProviderRefreshHandler, ProviderRevokeHandler } from "./provider-auth-lifecycle";
|
||||
import type { ProviderCancelResult, ProviderStreamResult } from "./provider-streaming";
|
||||
|
||||
export interface ProviderExecutionInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
model: string;
|
||||
prompt: string;
|
||||
instructions: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderExecutionResult {
|
||||
text: string;
|
||||
rawResponseJson: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export type ProviderStreamInput = ProviderExecutionInput;
|
||||
export type ProviderBackgroundCreateInput = ProviderExecutionInput;
|
||||
|
||||
export interface ProviderCancelInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
responseId: string;
|
||||
}
|
||||
|
||||
export interface ProviderBackgroundRetrieveInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
responseId: string;
|
||||
}
|
||||
|
||||
export interface ProviderHealthInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderHealthResult {
|
||||
checkedAt: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderContext {
|
||||
authProfile: ProviderAuthProfile;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitState {
|
||||
requestLimit: number | null;
|
||||
remainingRequests: number | null;
|
||||
resetAt: string | null;
|
||||
retryAfterMs: number | null;
|
||||
rawStateJson: string;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
providerKey: string;
|
||||
authType: ProviderAuthType;
|
||||
authorize: ProviderAuthorizeHandler;
|
||||
refresh: ProviderRefreshHandler;
|
||||
revoke: ProviderRevokeHandler;
|
||||
parseAuthProfile(secret: string): ProviderAuthProfile;
|
||||
listModels(ctx: ProviderContext): Promise<ProviderModelEntry[]>;
|
||||
getRateLimitState(ctx: ProviderContext): Promise<ProviderRateLimitState | null>;
|
||||
checkHealth(input: ProviderHealthInput): Promise<ProviderHealthResult>;
|
||||
streamResponse(input: ProviderStreamInput): Promise<ProviderStreamResult>;
|
||||
cancelResponse(input: ProviderCancelInput): Promise<ProviderCancelResult>;
|
||||
createBackgroundResponse(input: ProviderBackgroundCreateInput): Promise<ProviderBackgroundCreateResult>;
|
||||
retrieveResponse(input: ProviderBackgroundRetrieveInput): Promise<ProviderBackgroundRetrieveResult>;
|
||||
createResponse?: (input: ProviderExecutionInput) => Promise<ProviderExecutionResult>;
|
||||
}
|
||||
|
||||
export type ProviderAdapterRegistry = Record<string, ProviderAdapter>;
|
||||
@@ -0,0 +1,453 @@
|
||||
import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain";
|
||||
import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import {
|
||||
parseAccessToken,
|
||||
parseAccountToken,
|
||||
parseApiKey,
|
||||
parseAnthropicCompatProfile,
|
||||
parseAzureProfile,
|
||||
parseBedrockProfile,
|
||||
parseOpenAiCompatProfile,
|
||||
parseVertexProfile,
|
||||
requireAccessTokenProfile,
|
||||
requireAccountTokenProfile,
|
||||
requireAnthropicCompatProfile,
|
||||
requireApiKeyProfile,
|
||||
requireAzureProfile,
|
||||
requireBedrockProfile,
|
||||
requireOpenAiCompatProfile,
|
||||
requireVertexProfile,
|
||||
type ProviderAuthProfile,
|
||||
} from "./provider-auth-profiles";
|
||||
import { backgroundUnsupported, retrieveUnsupported } from "./provider-background";
|
||||
import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./provider-auth-lifecycle";
|
||||
import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http";
|
||||
import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch";
|
||||
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
|
||||
import {
|
||||
cancelUnsupported,
|
||||
mapAnthropicMessageStream,
|
||||
mapOpenAiChatCompletionStream,
|
||||
mapOpenAiResponseStream,
|
||||
streamUnsupported,
|
||||
} from "./provider-streaming";
|
||||
|
||||
export { ProviderHttpError, providerRateLimitStateFromError } from "./provider-http";
|
||||
export type { ProviderAuthProfile } from "./provider-auth-profiles";
|
||||
export * from "./provider-adapter-contract";
|
||||
|
||||
import type {
|
||||
ProviderAdapter,
|
||||
ProviderAdapterRegistry,
|
||||
ProviderHealthResult,
|
||||
ProviderRateLimitState,
|
||||
} from "./provider-adapter-contract";
|
||||
|
||||
export const providerAdapters: ProviderAdapterRegistry = {
|
||||
openai: {
|
||||
providerKey: "openai",
|
||||
authType: PROVIDER_AUTH_TYPE.API_KEY,
|
||||
authorize: authorizeWithParser(parseApiKey),
|
||||
refresh: refreshUnsupported("OpenAI API key"),
|
||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }),
|
||||
parseAuthProfile: parseApiKey,
|
||||
listModels: (ctx) => fetchOpenAiModels("https://api.openai.com/v1/models", {
|
||||
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, "OpenAI").apiKey}`,
|
||||
}),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"),
|
||||
async streamResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const response = await postEventStream("https://api.openai.com/v1/responses", {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
input: input.prompt,
|
||||
stream: true,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
});
|
||||
return { events: mapOpenAiResponseStream(response.messages), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
async cancelResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const responseId = requireResponseId(input.responseId);
|
||||
const response = await postJson(`https://api.openai.com/v1/responses/${encodeURIComponent(responseId)}/cancel`, {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {});
|
||||
return { responseId, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
async createBackgroundResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const response = await postJson("https://api.openai.com/v1/responses", {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
input: input.prompt,
|
||||
background: true,
|
||||
store: true,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
});
|
||||
const data = response.data;
|
||||
return {
|
||||
responseId: requireStringField(data, "id", "OpenAI response id"),
|
||||
status: requireStringField(data, "status", "OpenAI response status"),
|
||||
rawResponseJson: JSON.stringify(data),
|
||||
rateLimitState: response.rateLimitState,
|
||||
};
|
||||
},
|
||||
async retrieveResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const responseId = requireResponseId(input.responseId);
|
||||
const response = await getJson(`https://api.openai.com/v1/responses/${encodeURIComponent(responseId)}`, {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
});
|
||||
const data = response.data;
|
||||
return {
|
||||
responseId: requireStringField(data, "id", "OpenAI response id"),
|
||||
status: requireStringField(data, "status", "OpenAI response status"),
|
||||
text: optionalText(collectText(data.output_text ?? data.output)),
|
||||
rawResponseJson: JSON.stringify(data),
|
||||
rateLimitState: response.rateLimitState,
|
||||
};
|
||||
},
|
||||
async createResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const response = await postJson("https://api.openai.com/v1/responses", {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
input: input.prompt,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.output_text ?? data.output), "OpenAI");
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
providerKey: "anthropic",
|
||||
authType: PROVIDER_AUTH_TYPE.API_KEY,
|
||||
authorize: authorizeWithParser(parseApiKey),
|
||||
refresh: refreshUnsupported("Anthropic API key"),
|
||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }),
|
||||
parseAuthProfile: parseApiKey,
|
||||
listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", {
|
||||
"x-api-key": requireApiKeyProfile(ctx.authProfile, "Anthropic").apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
}),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
async checkHealth(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||
const response = await getJson("https://api.anthropic.com/v1/models", {
|
||||
"x-api-key": profile.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
return healthy(response.rateLimitState);
|
||||
},
|
||||
async streamResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||
const response = await postEventStream("https://api.anthropic.com/v1/messages", {
|
||||
"x-api-key": profile.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}, {
|
||||
model: input.model,
|
||||
max_tokens: 4096,
|
||||
stream: true,
|
||||
...(input.instructions ? { system: input.instructions } : {}),
|
||||
messages: [{ role: "user", content: input.prompt }],
|
||||
});
|
||||
return { events: mapAnthropicMessageStream(response.messages), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
cancelResponse: cancelUnsupported("Anthropic"),
|
||||
createBackgroundResponse: backgroundUnsupported("Anthropic"),
|
||||
retrieveResponse: retrieveUnsupported("Anthropic"),
|
||||
async createResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||
const response = await postJson("https://api.anthropic.com/v1/messages", {
|
||||
"x-api-key": profile.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}, {
|
||||
model: input.model,
|
||||
max_tokens: 4096,
|
||||
...(input.instructions ? { system: input.instructions } : {}),
|
||||
messages: [{ role: "user", content: input.prompt }],
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.content), "Anthropic");
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
},
|
||||
xai: openAiCompatibleAdapter({
|
||||
providerKey: "xai",
|
||||
label: "xAI",
|
||||
endpoint: "https://api.x.ai/v1/chat/completions",
|
||||
healthUrl: "https://api.x.ai/v1/models",
|
||||
}),
|
||||
groq: openAiCompatibleAdapter({
|
||||
providerKey: "groq",
|
||||
label: "Groq",
|
||||
endpoint: "https://api.groq.com/openai/v1/chat/completions",
|
||||
healthUrl: "https://api.groq.com/openai/v1/models",
|
||||
}),
|
||||
mistral: openAiCompatibleAdapter({
|
||||
providerKey: "mistral",
|
||||
label: "Mistral",
|
||||
endpoint: "https://api.mistral.ai/v1/chat/completions",
|
||||
healthUrl: "https://api.mistral.ai/v1/models",
|
||||
}),
|
||||
cerebras: openAiCompatibleAdapter({
|
||||
providerKey: "cerebras",
|
||||
label: "Cerebras",
|
||||
endpoint: "https://api.cerebras.ai/v1/chat/completions",
|
||||
healthUrl: "https://api.cerebras.ai/v1/models",
|
||||
}),
|
||||
openrouter: openAiCompatibleAdapter({
|
||||
providerKey: "openrouter",
|
||||
label: "OpenRouter",
|
||||
endpoint: "https://openrouter.ai/api/v1/chat/completions",
|
||||
healthUrl: "https://openrouter.ai/api/v1/models",
|
||||
}),
|
||||
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
"google-antigravity": browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
"gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION),
|
||||
"claude-pro": {
|
||||
...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", {
|
||||
authorization: `Bearer ${requireAccessTokenProfile(ctx.authProfile).accessToken}`,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
"anthropic-beta": CLAUDE_PRO_BETA,
|
||||
}),
|
||||
},
|
||||
"azure-openai": {
|
||||
providerKey: "azure-openai",
|
||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||
authorize: authorizeWithParser(parseAzureProfile),
|
||||
refresh: refreshUnsupported("Azure OpenAI cloud credential"),
|
||||
revoke: revokeLocalProfile((profile) => { requireAzureProfile(profile); }),
|
||||
parseAuthProfile: parseAzureProfile,
|
||||
listModels: () => listStaticModels("azure-openai"),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported("Azure OpenAI"),
|
||||
cancelResponse: cancelUnsupported("Azure OpenAI"),
|
||||
createBackgroundResponse: backgroundUnsupported("Azure OpenAI"),
|
||||
retrieveResponse: retrieveUnsupported("Azure OpenAI"),
|
||||
async checkHealth(input) {
|
||||
const profile = requireAzureProfile(input.authProfile);
|
||||
const response = await getJson(`${profile.baseUrl}/openai/deployments?api-version=2024-10-21`, { "api-key": profile.apiKey });
|
||||
return healthy(response.rateLimitState);
|
||||
},
|
||||
},
|
||||
"aws-bedrock": {
|
||||
providerKey: "aws-bedrock",
|
||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||
authorize: authorizeWithParser(parseBedrockProfile),
|
||||
refresh: refreshUnsupported("AWS Bedrock cloud credential"),
|
||||
revoke: revokeLocalProfile((profile) => { requireBedrockProfile(profile); }),
|
||||
parseAuthProfile: parseBedrockProfile,
|
||||
listModels: () => listStaticModels("aws-bedrock"),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported("AWS Bedrock"),
|
||||
cancelResponse: cancelUnsupported("AWS Bedrock"),
|
||||
createBackgroundResponse: backgroundUnsupported("AWS Bedrock"),
|
||||
retrieveResponse: retrieveUnsupported("AWS Bedrock"),
|
||||
checkHealth: async (input) => {
|
||||
requireBedrockProfile(input.authProfile);
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
"vertex-ai": {
|
||||
providerKey: "vertex-ai",
|
||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||
authorize: authorizeWithParser(parseVertexProfile),
|
||||
refresh: refreshUnsupported("Vertex AI cloud credential"),
|
||||
revoke: revokeLocalProfile((profile) => { requireVertexProfile(profile); }),
|
||||
parseAuthProfile: parseVertexProfile,
|
||||
listModels: () => listStaticModels("vertex-ai"),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported("Vertex AI"),
|
||||
cancelResponse: cancelUnsupported("Vertex AI"),
|
||||
createBackgroundResponse: backgroundUnsupported("Vertex AI"),
|
||||
retrieveResponse: retrieveUnsupported("Vertex AI"),
|
||||
checkHealth: async (input) => {
|
||||
requireVertexProfile(input.authProfile);
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
"custom-openai": {
|
||||
providerKey: "custom-openai",
|
||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||
authorize: authorizeWithParser(parseOpenAiCompatProfile),
|
||||
refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"),
|
||||
revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }),
|
||||
parseAuthProfile: parseOpenAiCompatProfile,
|
||||
listModels: () => listStaticModels("custom-openai"),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported("Custom OpenAI-compatible endpoint"),
|
||||
cancelResponse: cancelUnsupported("Custom OpenAI-compatible endpoint"),
|
||||
createBackgroundResponse: backgroundUnsupported("Custom OpenAI-compatible endpoint"),
|
||||
retrieveResponse: retrieveUnsupported("Custom OpenAI-compatible endpoint"),
|
||||
checkHealth: async (input) => {
|
||||
requireOpenAiCompatProfile(input.authProfile);
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
"custom-anthropic": {
|
||||
providerKey: "custom-anthropic",
|
||||
authType: PROVIDER_AUTH_TYPE.CLOUD,
|
||||
authorize: authorizeWithParser(parseAnthropicCompatProfile),
|
||||
refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"),
|
||||
revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }),
|
||||
parseAuthProfile: parseAnthropicCompatProfile,
|
||||
listModels: () => listStaticModels("custom-anthropic"),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported("Custom Anthropic-compatible endpoint"),
|
||||
cancelResponse: cancelUnsupported("Custom Anthropic-compatible endpoint"),
|
||||
createBackgroundResponse: backgroundUnsupported("Custom Anthropic-compatible endpoint"),
|
||||
retrieveResponse: retrieveUnsupported("Custom Anthropic-compatible endpoint"),
|
||||
checkHealth: async (input) => {
|
||||
requireAnthropicCompatProfile(input.authProfile);
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function collectText(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.flatMap((item) => collectText(item));
|
||||
if (!isRecord(value)) return [];
|
||||
const direct = ["text", "output_text", "content"].flatMap((key) => collectText(value[key]));
|
||||
return direct.length > 0 ? direct : Object.values(value).flatMap((item) => collectText(item));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireText(parts: string[], providerKey: string): string {
|
||||
const text = parts.map((part) => part.trim()).filter(Boolean).join("\n");
|
||||
if (!text) throw new Error(`${providerKey} returned no text content.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalText(parts: string[]): string | null {
|
||||
const text = parts.map((part) => part.trim()).filter(Boolean).join("\n");
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function requireStringField(record: JsonRecord, key: string, label: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function openAiCompatibleAdapter({
|
||||
providerKey,
|
||||
label,
|
||||
endpoint,
|
||||
healthUrl,
|
||||
}: {
|
||||
providerKey: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
healthUrl: string;
|
||||
}): ProviderAdapter {
|
||||
return {
|
||||
providerKey,
|
||||
authType: PROVIDER_AUTH_TYPE.API_KEY,
|
||||
authorize: authorizeWithParser(parseApiKey),
|
||||
refresh: refreshUnsupported(`${label} API key`),
|
||||
revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }),
|
||||
parseAuthProfile: parseApiKey,
|
||||
listModels: (ctx) => fetchOpenAiModels(healthUrl, {
|
||||
authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, label).apiKey}`,
|
||||
}),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl),
|
||||
async streamResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, label);
|
||||
const messages = responseMessages(input.instructions, input.prompt);
|
||||
const response = await postEventStream(endpoint, {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
messages,
|
||||
stream: true,
|
||||
});
|
||||
return { events: mapOpenAiChatCompletionStream(response.messages), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
cancelResponse: cancelUnsupported(label),
|
||||
createBackgroundResponse: backgroundUnsupported(label),
|
||||
retrieveResponse: retrieveUnsupported(label),
|
||||
async createResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, label);
|
||||
const messages = responseMessages(input.instructions, input.prompt);
|
||||
const response = await postJson(endpoint, {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
messages,
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.choices), label);
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function responseMessages(instructions: string | null, prompt: string): Array<{ role: "system" | "user"; content: string }> {
|
||||
return [
|
||||
...(instructions ? [{ role: "system" as const, content: instructions }] : []),
|
||||
{ role: "user", content: prompt },
|
||||
];
|
||||
}
|
||||
|
||||
async function checkOpenAiHealth(authProfile: ProviderAuthProfile, healthUrl: string): Promise<ProviderHealthResult> {
|
||||
const profile = requireApiKeyProfile(authProfile, "OpenAI-compatible provider");
|
||||
const response = await getJson(healthUrl, { authorization: `Bearer ${profile.apiKey}` });
|
||||
return healthy(response.rateLimitState);
|
||||
}
|
||||
|
||||
function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter {
|
||||
const parseAuthProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? parseAccountToken : parseAccessToken;
|
||||
const assertProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? requireAccountTokenProfile : requireAccessTokenProfile;
|
||||
return {
|
||||
providerKey,
|
||||
authType,
|
||||
authorize: authorizeWithParser(parseAuthProfile),
|
||||
refresh: refreshUnsupported(`${providerKey} token`),
|
||||
revoke: revokeLocalProfile((profile) => { assertProfile(profile); }),
|
||||
parseAuthProfile,
|
||||
listModels: () => listStaticModels(providerKey),
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
streamResponse: streamUnsupported(providerKey),
|
||||
cancelResponse: cancelUnsupported(providerKey),
|
||||
createBackgroundResponse: backgroundUnsupported(providerKey),
|
||||
retrieveResponse: retrieveUnsupported(providerKey),
|
||||
checkHealth: async (input) => {
|
||||
if (authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) requireAccountTokenProfile(input.authProfile);
|
||||
else requireAccessTokenProfile(input.authProfile);
|
||||
return healthy(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireResponseId(value: string): string {
|
||||
const responseId = value.trim();
|
||||
if (!responseId) throw new Error("Provider response id is required.");
|
||||
return responseId;
|
||||
}
|
||||
|
||||
function healthy(rateLimitState: ProviderObservedRateLimitState | null): ProviderHealthResult {
|
||||
return { checkedAt: new Date().toISOString(), rateLimitState };
|
||||
}
|
||||
|
||||
function listStaticModels(providerKey: string): Promise<ProviderModelEntry[]> {
|
||||
return Promise.resolve(listProviderModels(providerKey));
|
||||
}
|
||||
|
||||
async function providerRateLimitUnknown(): Promise<ProviderRateLimitState | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ProviderAuthProfile } from "./provider-adapters";
|
||||
|
||||
export interface ProviderAuthorizeInput {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface ProviderAuthorizeResult {
|
||||
authProfile: ProviderAuthProfile;
|
||||
}
|
||||
|
||||
export interface ProviderRefreshInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
}
|
||||
|
||||
export interface ProviderRefreshResult {
|
||||
authProfile: ProviderAuthProfile;
|
||||
rotatedSecret: string | null;
|
||||
refreshedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderRevokeInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
}
|
||||
|
||||
export type ProviderAuthorizeHandler = (input: ProviderAuthorizeInput) => Promise<ProviderAuthorizeResult>;
|
||||
export type ProviderRefreshHandler = (input: ProviderRefreshInput) => Promise<ProviderRefreshResult>;
|
||||
export type ProviderRevokeHandler = (input: ProviderRevokeInput) => Promise<void>;
|
||||
|
||||
export function authorizeWithParser(parseAuthProfile: (secret: string) => ProviderAuthProfile): ProviderAuthorizeHandler {
|
||||
return async (input) => ({ authProfile: parseAuthProfile(input.secret) });
|
||||
}
|
||||
|
||||
export function refreshUnsupported(label: string): ProviderRefreshHandler {
|
||||
return async () => {
|
||||
throw new Error(`${label} does not support token refresh.`);
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeLocalProfile(assertProfile: (profile: ProviderAuthProfile) => void): ProviderRevokeHandler {
|
||||
return async (input) => {
|
||||
assertProfile(input.authProfile);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import { isRecord } from "./provider-http";
|
||||
|
||||
export type ProviderAuthProfile =
|
||||
| { type: "api_key"; apiKey: string }
|
||||
| { type: "access_token"; accessToken: string }
|
||||
| { type: "account_token"; accessToken: string }
|
||||
| { type: "azure_openai"; baseUrl: string; deployment: string; apiKey: string }
|
||||
| { type: "aws_bedrock"; region: string; accessKeyId: string; secretAccessKey: string; sessionToken: string | null }
|
||||
| { type: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string }
|
||||
| { type: "openai_compat"; baseUrl: string; apiKey: string }
|
||||
| { type: "anthropic_compat"; baseUrl: string; apiKey: string };
|
||||
|
||||
export function parseApiKey(secret: string): ProviderAuthProfile {
|
||||
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
||||
}
|
||||
|
||||
export function parseAccessToken(secret: string): ProviderAuthProfile {
|
||||
return { type: "access_token", accessToken: oauthAccessToken(secret, "Access token") };
|
||||
}
|
||||
|
||||
export function parseAccountToken(secret: string): ProviderAuthProfile {
|
||||
return { type: "account_token", accessToken: oauthAccessToken(secret, "Account token") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts either a raw token string or the JSON credential object produced by
|
||||
* the native OAuth login flow, returning the usable access token.
|
||||
*/
|
||||
function oauthAccessToken(secret: string, label: string): string {
|
||||
const value = nonEmptySecret(secret, label);
|
||||
if (!value.startsWith("{")) return value;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error(`${label} credential must be a token or JSON.`);
|
||||
}
|
||||
if (isRecord(parsed) && typeof parsed.access === "string" && parsed.access.trim()) {
|
||||
return parsed.access.trim();
|
||||
}
|
||||
throw new Error(`${label} credential is missing an access token.`);
|
||||
}
|
||||
|
||||
export function parseAzureProfile(secret: string): ProviderAuthProfile {
|
||||
const parsed = parseCloudSecret(secret);
|
||||
const baseUrl = requiredString(parsed.baseUrl, "Azure base URL").replace(/\/+$/, "");
|
||||
const deployment = requiredString(parsed.deployment, "Azure deployment");
|
||||
return { type: "azure_openai", baseUrl: normalizedHttpsUrl(baseUrl, "Azure base URL"), deployment, apiKey: requiredString(parsed.secret, "Azure API key") };
|
||||
}
|
||||
|
||||
export function parseBedrockProfile(secret: string): ProviderAuthProfile {
|
||||
const parsed = parseCloudSecret(secret);
|
||||
const credential = parseCredentialJson(requiredString(parsed.secret, "AWS credential"));
|
||||
return {
|
||||
type: "aws_bedrock",
|
||||
region: requiredString(parsed.region, "AWS region"),
|
||||
accessKeyId: requiredString(credential.accessKeyId, "AWS access key id"),
|
||||
secretAccessKey: requiredString(credential.secretAccessKey, "AWS secret access key"),
|
||||
sessionToken: typeof credential.sessionToken === "string" && credential.sessionToken.trim() ? credential.sessionToken.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVertexProfile(secret: string): ProviderAuthProfile {
|
||||
const parsed = parseCloudSecret(secret);
|
||||
const serviceAccountJson = requiredString(parsed.secret, "Vertex service account JSON");
|
||||
parseCredentialJson(serviceAccountJson);
|
||||
return {
|
||||
type: "vertex_ai",
|
||||
projectId: requiredString(parsed.projectId, "Vertex project id"),
|
||||
region: requiredString(parsed.region, "Vertex region"),
|
||||
serviceAccountJson,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOpenAiCompatProfile(secret: string): ProviderAuthProfile {
|
||||
const parsed = parseCloudSecret(secret);
|
||||
return { type: "openai_compat", baseUrl: normalizedHttpsUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") };
|
||||
}
|
||||
|
||||
export function parseAnthropicCompatProfile(secret: string): ProviderAuthProfile {
|
||||
const parsed = parseCloudSecret(secret);
|
||||
return { type: "anthropic_compat", baseUrl: normalizedHttpsUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") };
|
||||
}
|
||||
|
||||
export function requireOpenAiCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "openai_compat" }> {
|
||||
if (profile.type !== "openai_compat") throw new Error("OpenAI-compatible profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireAnthropicCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "anthropic_compat" }> {
|
||||
if (profile.type !== "anthropic_compat") throw new Error("Anthropic-compatible profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireApiKeyProfile(profile: ProviderAuthProfile, label: string): Extract<ProviderAuthProfile, { type: "api_key" }> {
|
||||
if (profile.type !== "api_key") throw new Error(`${label} API key profile is required.`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "access_token" }> {
|
||||
if (profile.type !== "access_token") throw new Error("Access token profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "account_token" }> {
|
||||
if (profile.type !== "account_token") throw new Error("Account token profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireAzureProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "azure_openai" }> {
|
||||
if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireBedrockProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "aws_bedrock" }> {
|
||||
if (profile.type !== "aws_bedrock") throw new Error("AWS Bedrock profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireVertexProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "vertex_ai" }> {
|
||||
if (profile.type !== "vertex_ai") throw new Error("Vertex AI profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
function parseCloudSecret(secret: string): JsonRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(secret) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("Cloud credential profile is invalid.");
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error("Cloud credential profile must be JSON.");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCredentialJson(value: string): JsonRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("Credential JSON is invalid.");
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error("Credential must be JSON.");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function nonEmptySecret(secret: string, label: string): string {
|
||||
const value = secret.trim();
|
||||
if (!value) throw new Error(`${label} is required.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizedHttpsUrl(value: string, label: string): string {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`);
|
||||
if (url.username || url.password) throw new Error(`${label} must not include credentials.`);
|
||||
if (url.search || url.hash) throw new Error(`${label} must not include query strings or fragments.`);
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateProviderResponseInput } from "../../shared/commands";
|
||||
import {
|
||||
FEATURE_FLAG_KEY,
|
||||
PROVIDER_AUTH_TYPE,
|
||||
PROVIDER_DATA_POLICY,
|
||||
PROVIDER_EXECUTION_MODE,
|
||||
PROVIDER_REQUEST_STATE,
|
||||
type ProviderAuthType,
|
||||
type ProviderExecutionMode,
|
||||
} from "../../shared/domain";
|
||||
import { currentProviderUsageWindow, type ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { SecretVault } from "./secret-vault";
|
||||
import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import { ProviderRequestStore, type ProviderBackgroundRequestRow } from "./provider-request-store";
|
||||
import { ProviderResponseObserver } from "./provider-response-observer";
|
||||
import { hashPrompt, readProviderFailureMessage, requireBackgroundText } from "./provider-response-utils";
|
||||
|
||||
const AUTH_STATE_CONFIGURED = "configured";
|
||||
const HEALTH_STATE_HEALTHY = "healthy";
|
||||
const ACTIVE_BACKGROUND_STATUSES = new Set(["queued", "in_progress"]);
|
||||
const FAILED_BACKGROUND_STATUSES = new Set(["failed", "incomplete"]);
|
||||
const CANCELLED_BACKGROUND_STATUSES = new Set(["cancelled"]);
|
||||
|
||||
type BackgroundCreateInput = CreateProviderResponseInput & {
|
||||
onRequestCreated?: (requestId: string) => void;
|
||||
};
|
||||
|
||||
export class ProviderBackgroundService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly authBroker: AuthBrokerService;
|
||||
private readonly operations: OperationsService;
|
||||
private readonly rateLimitRegistry: ProviderRateLimitRegistry;
|
||||
private readonly requestStore: ProviderRequestStore;
|
||||
private readonly responseObserver: ProviderResponseObserver;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
||||
vault: ProviderSecretVault = new SecretVault(),
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
this.authBroker = new AuthBrokerService(db, vault);
|
||||
this.operations = new OperationsService(db);
|
||||
this.rateLimitRegistry = new ProviderRateLimitRegistry(db);
|
||||
this.requestStore = new ProviderRequestStore(db);
|
||||
this.responseObserver = new ProviderResponseObserver(db, this.audit, this.operations, this.requestStore, this.rateLimitRegistry);
|
||||
}
|
||||
|
||||
async create(input: BackgroundCreateInput): Promise<string> {
|
||||
const execution = this.prepareExecution(input);
|
||||
const requestId = this.requestStore.create({
|
||||
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,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
input.onRequestCreated?.(requestId);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
||||
const result = await execution.adapter.createBackgroundResponse({
|
||||
authProfile,
|
||||
model: execution.provider.model,
|
||||
prompt: execution.prompt,
|
||||
instructions: execution.instructions,
|
||||
});
|
||||
this.recordObservedRateLimit(input.orgId, execution.provider, execution.provider.model, result.rateLimitState);
|
||||
this.requestStore.markBackgroundAccepted({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
externalResponseId: result.responseId,
|
||||
});
|
||||
this.recordProviderHealth(execution.provider.id, input.orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider_request",
|
||||
entityId: requestId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "background_response_started",
|
||||
payload: { providerKey: execution.provider.provider_key, model: execution.provider.model, status: result.status },
|
||||
});
|
||||
return requestId;
|
||||
} catch (error) {
|
||||
this.responseObserver.recordFailure({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
requestId,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
||||
startedAt,
|
||||
inputCharCount: execution.prompt.length,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async poll(input: { orgId: string; requestId: string }): Promise<void> {
|
||||
await this.pollRow(this.requestStore.loadBackgroundRequest(input.orgId, input.requestId));
|
||||
}
|
||||
|
||||
async recoverBackgroundResponses(orgId?: string): Promise<number> {
|
||||
const requests = this.requestStore.listRecoverableBackgroundRequests(orgId);
|
||||
let recovered = 0;
|
||||
for (const request of requests) {
|
||||
await this.pollRow(request);
|
||||
recovered += 1;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private async pollRow(request: ProviderBackgroundRequestRow): Promise<void> {
|
||||
let provider: ProviderRow | ProviderBackgroundRequestProvider = {
|
||||
id: request.provider_id,
|
||||
provider_key: request.provider_key,
|
||||
model: request.model,
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const providerRow = this.loadConfiguredProvider(request.org_id, request.provider_id);
|
||||
provider = providerRow;
|
||||
const adapter = this.requireAdapter(providerRow.provider_key, providerRow.auth_type);
|
||||
const credential = this.loadCredential(request.org_id, providerRow);
|
||||
const result = await adapter.retrieveResponse({
|
||||
authProfile: adapter.parseAuthProfile(credential.secret),
|
||||
responseId: request.external_response_id,
|
||||
});
|
||||
this.recordObservedRateLimit(request.org_id, providerRow, providerRow.model, result.rateLimitState);
|
||||
if (ACTIVE_BACKGROUND_STATUSES.has(result.status)) {
|
||||
this.requestStore.markBackgroundProgress({
|
||||
orgId: request.org_id,
|
||||
requestId: request.id,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
externalResponseId: result.responseId,
|
||||
state: result.status === "in_progress" ? PROVIDER_REQUEST_STATE.STREAMING : PROVIDER_REQUEST_STATE.PENDING,
|
||||
});
|
||||
this.recordProviderHealth(provider.id, request.org_id, HEALTH_STATE_HEALTHY, null);
|
||||
return;
|
||||
}
|
||||
if (result.status === "completed") {
|
||||
const responseText = requireBackgroundText(result.text, provider.provider_key);
|
||||
this.requestStore.markSucceeded({
|
||||
orgId: request.org_id,
|
||||
requestId: request.id,
|
||||
responseText,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
outputCharCount: responseText.length,
|
||||
externalResponseId: result.responseId,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
this.responseObserver.recordSuccess({
|
||||
orgId: request.org_id,
|
||||
provider,
|
||||
requestId: request.id,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
||||
startedAt,
|
||||
action: "background_response_completed",
|
||||
});
|
||||
this.requestStore.recordUsage(request.org_id, provider.id, request.input_char_count, responseText.length);
|
||||
return;
|
||||
}
|
||||
if (CANCELLED_BACKGROUND_STATUSES.has(result.status)) {
|
||||
this.requestStore.markCancelled({
|
||||
orgId: request.org_id,
|
||||
requestId: request.id,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
externalResponseId: result.responseId,
|
||||
cancelledAt: new Date().toISOString(),
|
||||
});
|
||||
this.recordProviderHealth(provider.id, request.org_id, HEALTH_STATE_HEALTHY, null);
|
||||
return;
|
||||
}
|
||||
if (FAILED_BACKGROUND_STATUSES.has(result.status)) {
|
||||
throw new ProviderBackgroundStatusError(readProviderFailureMessage(result.rawResponseJson, result.status), result.rawResponseJson);
|
||||
}
|
||||
throw new Error(`Provider background response returned unsupported status ${result.status}.`);
|
||||
} catch (error) {
|
||||
this.responseObserver.recordFailure({
|
||||
orgId: request.org_id,
|
||||
provider,
|
||||
requestId: request.id,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND,
|
||||
startedAt,
|
||||
inputCharCount: request.input_char_count,
|
||||
error,
|
||||
rawResponseJson: error instanceof ProviderBackgroundStatusError ? error.rawResponseJson : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private prepareExecution(input: CreateProviderResponseInput): PreparedBackgroundExecution {
|
||||
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);
|
||||
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 },
|
||||
adapter: this.requireAdapter(provider.provider_key, provider.auth_type),
|
||||
credential: this.loadCredential(input.orgId, provider),
|
||||
};
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
if (!this.operations.isFeatureEnabled(orgId, FEATURE_FLAG_KEY.PROVIDER_BACKGROUND_EXECUTION)) {
|
||||
throw new Error("Provider background execution is disabled by feature flag.");
|
||||
}
|
||||
if (provider.background_enabled !== 1) throw new Error("Background execution is disabled for this provider policy.");
|
||||
const window = currentProviderUsageWindow();
|
||||
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 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,
|
||||
data_policy, background_enabled, monthly_request_limit, workspace_scope
|
||||
from providers
|
||||
where id = ? and org_id = ?
|
||||
`).get(providerId, orgId) as ProviderRow | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required.");
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return provider;
|
||||
}
|
||||
|
||||
private loadCredential(orgId: string, provider: ProviderRow): { secret: string } {
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return {
|
||||
secret: this.authBroker.loadProviderSecret({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
credentialId: provider.credential_id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private requireAdapter(providerKey: string, authType: string): ProviderAdapterRegistry[string] {
|
||||
const adapter = this.adapters[providerKey];
|
||||
if (!adapter) throw new Error(`Provider adapter is unavailable for ${providerKey}.`);
|
||||
if (adapter.authType !== authType) throw new Error(`Provider adapter auth type mismatch for ${providerKey}.`);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
private recordProviderHealth(providerId: string, orgId: string, healthState: string, error: string | null): void {
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set health_state = ?, last_error = ?, last_checked_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(healthState, error, new Date().toISOString(), providerId, orgId);
|
||||
}
|
||||
|
||||
private recordObservedRateLimit(
|
||||
orgId: string,
|
||||
provider: Pick<ProviderRow, "id" | "provider_key">,
|
||||
model: string | null,
|
||||
state: ProviderObservedRateLimitState | null,
|
||||
): void {
|
||||
if (!state) return;
|
||||
this.rateLimitRegistry.recordObservation({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
model,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedBackgroundExecution {
|
||||
workspaceId: string | null;
|
||||
prompt: string;
|
||||
instructions: string | null;
|
||||
provider: ProviderRow & { model: string };
|
||||
adapter: ProviderAdapterRegistry[string];
|
||||
credential: { secret: string };
|
||||
}
|
||||
|
||||
interface ProviderRow {
|
||||
id: string;
|
||||
provider_key: string;
|
||||
auth_type: ProviderAuthType;
|
||||
model: string | null;
|
||||
credential_id: string | null;
|
||||
enabled: number;
|
||||
auth_state: string;
|
||||
data_policy: string;
|
||||
background_enabled: number;
|
||||
monthly_request_limit: number;
|
||||
workspace_scope: string;
|
||||
}
|
||||
|
||||
interface ProviderBackgroundRequestProvider {
|
||||
id: string;
|
||||
provider_key: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
class ProviderBackgroundStatusError extends Error {
|
||||
constructor(message: string, readonly rawResponseJson: string) {
|
||||
super(message);
|
||||
this.name = "ProviderBackgroundStatusError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
|
||||
export interface ProviderBackgroundCreateResult {
|
||||
responseId: string;
|
||||
status: string;
|
||||
rawResponseJson: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderBackgroundRetrieveResult {
|
||||
responseId: string;
|
||||
status: string;
|
||||
text: string | null;
|
||||
rawResponseJson: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export function backgroundUnsupported(providerLabel: string): () => Promise<ProviderBackgroundCreateResult> {
|
||||
return async () => {
|
||||
throw new Error(`${providerLabel} background execution is unavailable.`);
|
||||
};
|
||||
}
|
||||
|
||||
export function retrieveUnsupported(providerLabel: string): () => Promise<ProviderBackgroundRetrieveResult> {
|
||||
return async () => {
|
||||
throw new Error(`${providerLabel} response retrieval is unavailable.`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { parseProviderRateLimitHeaders } from "./provider-rate-limit-registry";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface ProviderJsonResponse {
|
||||
data: JsonRecord;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderEventStreamMessage {
|
||||
eventName: string | null;
|
||||
data: JsonRecord;
|
||||
rawData: string;
|
||||
}
|
||||
|
||||
export interface ProviderEventStreamResponse {
|
||||
messages: AsyncIterable<ProviderEventStreamMessage>;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
const HEADERS_TIMEOUT_MS = 60_000;
|
||||
const BODY_TIMEOUT_MS = 300_000;
|
||||
const STREAM_IDLE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export interface ProviderRequestTimeouts {
|
||||
headersTimeoutMs?: number;
|
||||
bodyTimeoutMs?: number;
|
||||
idleTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class ProviderHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly rateLimitState: ProviderObservedRateLimitState | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderHttpError";
|
||||
}
|
||||
}
|
||||
|
||||
export function providerRateLimitStateFromError(error: unknown): ProviderObservedRateLimitState | null {
|
||||
return error instanceof ProviderHttpError ? error.rateLimitState : null;
|
||||
}
|
||||
|
||||
export async function postJson(
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
body: JsonRecord,
|
||||
timeouts: ProviderRequestTimeouts = {},
|
||||
): Promise<ProviderJsonResponse> {
|
||||
return requestJson(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}, timeouts);
|
||||
}
|
||||
|
||||
export async function getJson(endpoint: string, headers: Record<string, string>): Promise<ProviderJsonResponse> {
|
||||
return requestJson(endpoint, { method: "GET", headers });
|
||||
}
|
||||
|
||||
export async function postEventStream(
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
body: JsonRecord,
|
||||
timeouts: ProviderRequestTimeouts = {},
|
||||
): Promise<ProviderEventStreamResponse> {
|
||||
const controller = new AbortController();
|
||||
const response = await withDeadline(controller, timeouts.headersTimeoutMs ?? HEADERS_TIMEOUT_MS, () => fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}));
|
||||
const rateLimitState = parseProviderRateLimitHeaders(response.headers);
|
||||
if (!response.ok) {
|
||||
const raw = await withDeadline(controller, timeouts.bodyTimeoutMs ?? BODY_TIMEOUT_MS, () => response.text());
|
||||
const data = parseProviderJson(raw, false);
|
||||
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||
throw new ProviderHttpError(message, rateLimitState);
|
||||
}
|
||||
if (!response.body) throw new ProviderHttpError("Provider returned an empty event stream.", rateLimitState);
|
||||
return { messages: readServerSentEvents(response.body, timeouts.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS), rateLimitState };
|
||||
}
|
||||
|
||||
async function requestJson(endpoint: string, init: RequestInit, timeouts: ProviderRequestTimeouts = {}): Promise<ProviderJsonResponse> {
|
||||
const controller = new AbortController();
|
||||
const response = await withDeadline(controller, timeouts.headersTimeoutMs ?? HEADERS_TIMEOUT_MS, () => fetch(endpoint, { ...init, signal: controller.signal }));
|
||||
const rateLimitState = parseProviderRateLimitHeaders(response.headers);
|
||||
const raw = await withDeadline(controller, timeouts.bodyTimeoutMs ?? BODY_TIMEOUT_MS, () => response.text());
|
||||
const data = parseProviderJson(raw, response.ok);
|
||||
if (!response.ok) {
|
||||
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||
throw new ProviderHttpError(message, rateLimitState);
|
||||
}
|
||||
return { data, rateLimitState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one phase of a provider request under a hard deadline. The abort
|
||||
* controller covers the WHOLE request — a server that returns headers and
|
||||
* then stalls the body must not hang the caller, so the body read gets its
|
||||
* own (longer) deadline after the headers arrive.
|
||||
*/
|
||||
async function withDeadline<T>(controller: AbortController, timeoutMs: number, operation: () => Promise<T>): Promise<T> {
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new ProviderHttpError("Provider request timed out.", null);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* readServerSentEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
|
||||
): AsyncIterable<ProviderEventStreamMessage> {
|
||||
const decoder = new TextDecoder();
|
||||
const state: EventStreamParseState = { eventName: null, dataLines: [] };
|
||||
let lineBuffer = "";
|
||||
const reader = body.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await readWithIdleDeadline(reader, idleTimeoutMs);
|
||||
if (done) break;
|
||||
lineBuffer += decoder.decode(value, { stream: true });
|
||||
const lines = lineBuffer.split("\n");
|
||||
lineBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const message = parseServerSentEventLine(state, line);
|
||||
if (message) yield message;
|
||||
}
|
||||
}
|
||||
lineBuffer += decoder.decode();
|
||||
if (lineBuffer.length > 0) {
|
||||
const message = parseServerSentEventLine(state, lineBuffer);
|
||||
if (message) yield message;
|
||||
}
|
||||
const message = flushServerSentEvent(state);
|
||||
if (message) yield message;
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** A healthy event stream emits chunks continuously; a read that produces nothing within the idle window means the connection is dead. */
|
||||
async function readWithIdleDeadline(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
idleTimeoutMs: number,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const idle = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new ProviderHttpError("Provider stream timed out.", null)), idleTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([reader.read(), idle]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerSentEventLine(state: EventStreamParseState, rawLine: string): ProviderEventStreamMessage | null {
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line === "") return flushServerSentEvent(state);
|
||||
if (line.startsWith(":")) return null;
|
||||
const separatorIndex = line.indexOf(":");
|
||||
const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
|
||||
const rawValue = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") state.eventName = value;
|
||||
if (field === "data") state.dataLines.push(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function flushServerSentEvent(state: EventStreamParseState): ProviderEventStreamMessage | null {
|
||||
if (state.dataLines.length === 0) {
|
||||
state.eventName = null;
|
||||
return null;
|
||||
}
|
||||
const rawData = state.dataLines.join("\n");
|
||||
const eventName = state.eventName;
|
||||
state.eventName = null;
|
||||
state.dataLines = [];
|
||||
if (rawData === "[DONE]") return null;
|
||||
return { eventName, data: parseProviderJson(rawData, true), rawData };
|
||||
}
|
||||
|
||||
function parseProviderJson(raw: string, requireJson: boolean): JsonRecord {
|
||||
if (!raw.trim()) return {};
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
if (!requireJson) return {};
|
||||
throw error;
|
||||
}
|
||||
if (!isRecord(value)) throw new Error("Provider returned invalid JSON.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function readProviderError(data: JsonRecord): string | null {
|
||||
const error = data.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (isRecord(error) && typeof error.message === "string") return error.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
interface EventStreamParseState {
|
||||
eventName: string | null;
|
||||
dataLines: string[];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProviderAuthType } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import type { AuthBrokerService } from "./auth-broker-service";
|
||||
import type { ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
|
||||
export const AUTH_STATE_CONFIGURED = "configured";
|
||||
export const AUTH_STATE_REVOKED = "revoked";
|
||||
export const HEALTH_STATE_HEALTHY = "healthy";
|
||||
export const HEALTH_STATE_FAILED = "failed";
|
||||
export const HEALTH_STATE_UNCHECKED = "unchecked";
|
||||
export const WORKSPACE_SCOPES = new Set(["organization", "workspace"]);
|
||||
|
||||
export interface ProviderRow {
|
||||
id: string;
|
||||
provider_key: string;
|
||||
auth_type: ProviderAuthType;
|
||||
model: string | null;
|
||||
credential_id: string | null;
|
||||
enabled: number;
|
||||
auth_state: string;
|
||||
data_policy: string;
|
||||
background_enabled: number;
|
||||
monthly_request_limit: number;
|
||||
workspace_scope: string;
|
||||
}
|
||||
|
||||
export function loadConfiguredProvider(db: DatabaseSync, orgId: string, providerId: string): ProviderRow {
|
||||
const provider = db.prepare(`
|
||||
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;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required.");
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function loadCredential(authBroker: AuthBrokerService, orgId: string, provider: ProviderRow): { secret: string } {
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return {
|
||||
secret: authBroker.loadProviderSecret({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
credentialId: provider.credential_id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAdapter(
|
||||
adapters: ProviderAdapterRegistry,
|
||||
providerKey: string,
|
||||
authType: string,
|
||||
): ProviderAdapterRegistry[string] {
|
||||
const adapter = adapters[providerKey];
|
||||
if (!adapter) throw new Error(`Provider adapter is unavailable for ${providerKey}.`);
|
||||
if (adapter.authType !== authType) throw new Error(`Provider adapter auth type mismatch for ${providerKey}.`);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function requireCatalogEntry(providerKey: string): (typeof PROVIDER_CATALOG)[number] {
|
||||
const entry = PROVIDER_CATALOG.find((item) => item.key === providerKey);
|
||||
if (!entry) throw new Error(`Provider catalog entry missing for ${providerKey}.`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function recordProviderHealth(
|
||||
db: DatabaseSync,
|
||||
providerId: string,
|
||||
orgId: string,
|
||||
healthState: string,
|
||||
error: string | null,
|
||||
): void {
|
||||
db.prepare(`
|
||||
update providers
|
||||
set health_state = ?, last_error = ?, last_checked_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(healthState, error, new Date().toISOString(), providerId, orgId);
|
||||
}
|
||||
|
||||
export function recordObservedRateLimit(
|
||||
rateLimitRegistry: ProviderRateLimitRegistry,
|
||||
orgId: string,
|
||||
provider: Pick<ProviderRow, "id" | "provider_key">,
|
||||
model: string | null,
|
||||
state: ProviderObservedRateLimitState | null,
|
||||
): void {
|
||||
if (!state) return;
|
||||
rateLimitRegistry.recordObservation({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
model,
|
||||
state,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ProviderModelEntry } from "../../shared/provider-models";
|
||||
import { getJson, type JsonRecord } from "./provider-http";
|
||||
|
||||
/**
|
||||
* Live model-list fetching. Each provider that exposes a standard models
|
||||
* endpoint (OpenAI `/v1/models`, Anthropic `/v1/models`) returns its current
|
||||
* catalog; we map it to {id, name} with no curation and no fabricated entries.
|
||||
* An empty or malformed response throws — the caller surfaces the real error
|
||||
* rather than falling back to a stale static list.
|
||||
*/
|
||||
|
||||
export function mapOpenAiModels(data: unknown): ProviderModelEntry[] {
|
||||
return collectModels(data, (item, id) => ({ id, name: id }));
|
||||
}
|
||||
|
||||
export function mapAnthropicModels(data: unknown): ProviderModelEntry[] {
|
||||
return collectModels(data, (item, id) => ({
|
||||
id,
|
||||
name: typeof item.display_name === "string" && item.display_name.trim() ? item.display_name.trim() : id,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchOpenAiModels(modelsUrl: string, headers: Record<string, string>): Promise<ProviderModelEntry[]> {
|
||||
const response = await getJson(modelsUrl, headers);
|
||||
return mapOpenAiModels(response.data);
|
||||
}
|
||||
|
||||
export async function fetchAnthropicModels(modelsUrl: string, headers: Record<string, string>): Promise<ProviderModelEntry[]> {
|
||||
const response = await getJson(modelsUrl, headers);
|
||||
return mapAnthropicModels(response.data);
|
||||
}
|
||||
|
||||
function collectModels(data: unknown, toEntry: (item: JsonRecord, id: string) => ProviderModelEntry): ProviderModelEntry[] {
|
||||
const list = isRecord(data) && Array.isArray(data.data) ? data.data : null;
|
||||
if (!list) throw new Error("Provider model list response was not in the expected shape.");
|
||||
const entries: ProviderModelEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of list) {
|
||||
if (!isRecord(item)) continue;
|
||||
const id = typeof item.id === "string" ? item.id.trim() : "";
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
entries.push(toEntry(item, id));
|
||||
}
|
||||
if (entries.length === 0) throw new Error("Provider returned no models.");
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getOAuthFlow } from "./oauth/oauth-flows";
|
||||
|
||||
export interface ProviderOAuthHooks {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a provider's native OAuth login and returns the obtained credentials
|
||||
* as a JSON string, ready to be stored through the existing secret vault. The
|
||||
* service is decoupled from Electron: opening the browser and emitting UI
|
||||
* events are supplied by the caller via {@link ProviderOAuthHooks}.
|
||||
*/
|
||||
export class ProviderOAuthService {
|
||||
private readonly controllers = new Map<string, AbortController>();
|
||||
|
||||
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
|
||||
const flow = getOAuthFlow(input.providerKey);
|
||||
if (this.controllers.has(input.providerKey)) {
|
||||
throw new Error("A sign-in for this provider is already in progress.");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(input.providerKey, controller);
|
||||
try {
|
||||
const credentials = await flow.login({
|
||||
onAuth: hooks.onAuth,
|
||||
onProgress: hooks.onProgress,
|
||||
signal: controller.signal,
|
||||
enterpriseDomain: input.enterpriseDomain ?? null,
|
||||
});
|
||||
return JSON.stringify(credentials);
|
||||
} finally {
|
||||
this.controllers.delete(input.providerKey);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(providerKey: string): void {
|
||||
this.controllers.get(providerKey)?.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
const REQUEST_LIMIT_HEADERS = [
|
||||
"x-ratelimit-limit-requests",
|
||||
"x-ratelimit-limit",
|
||||
"anthropic-ratelimit-requests-limit",
|
||||
"ratelimit-limit",
|
||||
] as const;
|
||||
|
||||
const REMAINING_REQUEST_HEADERS = [
|
||||
"x-ratelimit-remaining-requests",
|
||||
"x-ratelimit-remaining",
|
||||
"anthropic-ratelimit-requests-remaining",
|
||||
"ratelimit-remaining",
|
||||
] as const;
|
||||
|
||||
const RESET_HEADERS = [
|
||||
"x-ratelimit-reset-requests",
|
||||
"x-ratelimit-reset",
|
||||
"anthropic-ratelimit-requests-reset",
|
||||
"ratelimit-reset",
|
||||
] as const;
|
||||
|
||||
const RETRY_AFTER_HEADERS = ["retry-after"] as const;
|
||||
const OBSERVED_HEADERS = [
|
||||
...REQUEST_LIMIT_HEADERS,
|
||||
...REMAINING_REQUEST_HEADERS,
|
||||
...RESET_HEADERS,
|
||||
...RETRY_AFTER_HEADERS,
|
||||
] as const;
|
||||
|
||||
export interface RecordProviderRateLimitObservationInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
model: string | null;
|
||||
state: ProviderObservedRateLimitState;
|
||||
}
|
||||
|
||||
export class ProviderRateLimitRegistry {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
recordObservation(input: RecordProviderRateLimitObservationInput): void {
|
||||
this.db.prepare(`
|
||||
insert into provider_rate_limit_observations(
|
||||
id, org_id, provider_id, provider_key, model, request_limit,
|
||||
remaining_requests, reset_at, retry_after_ms, raw_state_json, observed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("prlo"),
|
||||
input.orgId,
|
||||
input.providerId,
|
||||
input.providerKey,
|
||||
input.model,
|
||||
input.state.requestLimit,
|
||||
input.state.remainingRequests,
|
||||
input.state.resetAt,
|
||||
input.state.retryAfterMs,
|
||||
input.state.rawStateJson,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProviderRateLimitHeaders(headers: Headers, now = new Date()): ProviderObservedRateLimitState | null {
|
||||
const rawHeaders = collectObservedHeaders(headers);
|
||||
const requestLimit = readIntegerHeader(headers, REQUEST_LIMIT_HEADERS);
|
||||
const remainingRequests = readIntegerHeader(headers, REMAINING_REQUEST_HEADERS);
|
||||
const resetAt = readResetAt(headers, RESET_HEADERS, now);
|
||||
const retryAfterMs = readRetryAfterMs(headers, now);
|
||||
if (
|
||||
requestLimit === null
|
||||
&& remainingRequests === null
|
||||
&& resetAt === null
|
||||
&& retryAfterMs === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestLimit,
|
||||
remainingRequests,
|
||||
resetAt,
|
||||
retryAfterMs,
|
||||
rawStateJson: JSON.stringify(rawHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
function collectObservedHeaders(headers: Headers): Record<string, string> {
|
||||
const rawHeaders: Record<string, string> = {};
|
||||
for (const key of OBSERVED_HEADERS) {
|
||||
const value = headers.get(key);
|
||||
if (value?.trim()) rawHeaders[key] = value.trim();
|
||||
}
|
||||
return rawHeaders;
|
||||
}
|
||||
|
||||
function readIntegerHeader(headers: Headers, keys: readonly string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = parseInteger(headers.get(key));
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readResetAt(headers: Headers, keys: readonly string[], now: Date): string | null {
|
||||
for (const key of keys) {
|
||||
const value = parseResetValue(headers.get(key), now);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRetryAfterMs(headers: Headers, now: Date): number | null {
|
||||
for (const key of RETRY_AFTER_HEADERS) {
|
||||
const value = parseRetryAfter(headers.get(key), now);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseInteger(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
if (!/^\d+$/.test(normalized)) return null;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isSafeInteger(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResetValue(value: string | null, now: Date): string | null {
|
||||
if (!value?.trim()) return null;
|
||||
const normalized = value.trim();
|
||||
const durationMs = parseDurationMs(normalized);
|
||||
if (durationMs !== null) return new Date(now.getTime() + durationMs).toISOString();
|
||||
const numeric = Number(normalized);
|
||||
if (Number.isFinite(numeric) && numeric >= 0) {
|
||||
if (numeric > 1_000_000_000_000) return new Date(numeric).toISOString();
|
||||
if (numeric > 1_000_000_000) return new Date(numeric * 1000).toISOString();
|
||||
return new Date(now.getTime() + numeric * 1000).toISOString();
|
||||
}
|
||||
const dateMs = Date.parse(normalized);
|
||||
if (!Number.isNaN(dateMs)) return new Date(dateMs).toISOString();
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null, now: Date): number | null {
|
||||
if (!value?.trim()) return null;
|
||||
const normalized = value.trim();
|
||||
const seconds = Number(normalized);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
|
||||
const dateMs = Date.parse(normalized);
|
||||
if (Number.isNaN(dateMs)) return null;
|
||||
return Math.max(0, dateMs - now.getTime());
|
||||
}
|
||||
|
||||
function parseDurationMs(value: string): number | null {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
|
||||
if (!match) return null;
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount)) return null;
|
||||
const unit = match[2];
|
||||
if (unit === "ms") return Math.round(amount);
|
||||
if (unit === "s") return Math.round(amount * 1000);
|
||||
if (unit === "m") return Math.round(amount * 60_000);
|
||||
return Math.round(amount * 3_600_000);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
||||
|
||||
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;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderRequestProgressInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
responseText: string;
|
||||
rawResponseJson: string;
|
||||
outputCharCount: number;
|
||||
streamEventCount: number;
|
||||
externalResponseId: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderBackgroundAcceptedInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
rawResponseJson: string;
|
||||
externalResponseId: string;
|
||||
}
|
||||
|
||||
export interface ProviderBackgroundProgressInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
rawResponseJson: string;
|
||||
externalResponseId: string;
|
||||
state: typeof PROVIDER_REQUEST_STATE.PENDING | typeof PROVIDER_REQUEST_STATE.STREAMING;
|
||||
}
|
||||
|
||||
export interface ProviderRequestResultInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
responseText: string;
|
||||
rawResponseJson: string;
|
||||
outputCharCount: number;
|
||||
externalResponseId: string | null;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderRequestFailureInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
errorMessage: string;
|
||||
completedAt: string;
|
||||
rawResponseJson?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderRequestCancellationInput {
|
||||
orgId: string;
|
||||
requestId: string;
|
||||
rawResponseJson: string;
|
||||
externalResponseId: string;
|
||||
cancelledAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderCancellationRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
provider_id: string;
|
||||
provider_key: string;
|
||||
model: string;
|
||||
state: string;
|
||||
external_response_id: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderBackgroundRequestRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
provider_id: string;
|
||||
provider_key: string;
|
||||
model: string;
|
||||
state: string;
|
||||
external_response_id: string;
|
||||
input_char_count: number;
|
||||
execution_mode: ProviderExecutionMode;
|
||||
}
|
||||
|
||||
export class ProviderRequestStore {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
create(input: CreateProviderRequestInput): string {
|
||||
const requestId = createId("preq");
|
||||
this.db.prepare(`
|
||||
insert into provider_requests(
|
||||
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)
|
||||
`).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,
|
||||
policyDecision(input.provider, input.workspaceId),
|
||||
input.createdAt,
|
||||
);
|
||||
return requestId;
|
||||
}
|
||||
|
||||
markStreaming(input: ProviderRequestProgressInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, response_text = ?, raw_response_json = ?, output_char_count = ?,
|
||||
stream_event_count = ?, external_response_id = coalesce(?, external_response_id)
|
||||
where id = ? and org_id = ?
|
||||
`).run(
|
||||
PROVIDER_REQUEST_STATE.STREAMING,
|
||||
input.responseText,
|
||||
input.rawResponseJson,
|
||||
input.outputCharCount,
|
||||
input.streamEventCount,
|
||||
input.externalResponseId,
|
||||
input.requestId,
|
||||
input.orgId,
|
||||
);
|
||||
}
|
||||
|
||||
markBackgroundAccepted(input: ProviderBackgroundAcceptedInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, raw_response_json = ?, external_response_id = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(
|
||||
PROVIDER_REQUEST_STATE.PENDING,
|
||||
input.rawResponseJson,
|
||||
input.externalResponseId,
|
||||
input.requestId,
|
||||
input.orgId,
|
||||
);
|
||||
}
|
||||
|
||||
markBackgroundProgress(input: ProviderBackgroundProgressInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, raw_response_json = ?, external_response_id = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.state, input.rawResponseJson, input.externalResponseId, input.requestId, input.orgId);
|
||||
}
|
||||
|
||||
markSucceeded(input: ProviderRequestResultInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, response_text = ?, raw_response_json = ?, output_char_count = ?,
|
||||
external_response_id = coalesce(?, external_response_id), completed_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(
|
||||
PROVIDER_REQUEST_STATE.SUCCEEDED,
|
||||
input.responseText,
|
||||
input.rawResponseJson,
|
||||
input.outputCharCount,
|
||||
input.externalResponseId,
|
||||
input.completedAt,
|
||||
input.requestId,
|
||||
input.orgId,
|
||||
);
|
||||
}
|
||||
|
||||
markFailed(input: ProviderRequestFailureInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, error_message = ?, raw_response_json = coalesce(?, raw_response_json), completed_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(PROVIDER_REQUEST_STATE.FAILED, input.errorMessage, input.rawResponseJson ?? null, input.completedAt, input.requestId, input.orgId);
|
||||
}
|
||||
|
||||
markCancelled(input: ProviderRequestCancellationInput): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, raw_response_json = ?, external_response_id = ?, cancelled_at = ?, completed_at = ?, error_message = null
|
||||
where id = ? and org_id = ?
|
||||
`).run(
|
||||
PROVIDER_REQUEST_STATE.CANCELLED,
|
||||
input.rawResponseJson,
|
||||
input.externalResponseId,
|
||||
input.cancelledAt,
|
||||
input.cancelledAt,
|
||||
input.requestId,
|
||||
input.orgId,
|
||||
);
|
||||
}
|
||||
|
||||
loadCancellationRow(orgId: string, requestId: string): ProviderCancellationRow {
|
||||
const request = this.db.prepare(`
|
||||
select id, org_id, provider_id, provider_key, model, state, external_response_id
|
||||
from provider_requests
|
||||
where id = ? and org_id = ?
|
||||
`).get(requestId, orgId) as ProviderCancellationRow | undefined;
|
||||
if (!request) throw new Error("Provider request was not found.");
|
||||
return request;
|
||||
}
|
||||
|
||||
listRecoverableBackgroundRequests(orgId?: string): ProviderBackgroundRequestRow[] {
|
||||
const sql = `
|
||||
select id, org_id, provider_id, provider_key, model, state, external_response_id, input_char_count, execution_mode
|
||||
from provider_requests
|
||||
where execution_mode = ?
|
||||
and state in (?, ?)
|
||||
and external_response_id is not null
|
||||
${orgId ? "and org_id = ?" : ""}
|
||||
order by created_at asc
|
||||
`;
|
||||
return orgId
|
||||
? this.db.prepare(sql).all(PROVIDER_EXECUTION_MODE.BACKGROUND, PROVIDER_REQUEST_STATE.PENDING, PROVIDER_REQUEST_STATE.STREAMING, orgId) as unknown as ProviderBackgroundRequestRow[]
|
||||
: this.db.prepare(sql).all(PROVIDER_EXECUTION_MODE.BACKGROUND, PROVIDER_REQUEST_STATE.PENDING, PROVIDER_REQUEST_STATE.STREAMING) as unknown as ProviderBackgroundRequestRow[];
|
||||
}
|
||||
|
||||
loadBackgroundRequest(orgId: string, requestId: string): ProviderBackgroundRequestRow {
|
||||
const request = this.db.prepare(`
|
||||
select id, org_id, provider_id, provider_key, model, state, external_response_id, input_char_count, execution_mode
|
||||
from provider_requests
|
||||
where id = ? and org_id = ? and execution_mode = ? and external_response_id is not null
|
||||
`).get(requestId, orgId, PROVIDER_EXECUTION_MODE.BACKGROUND) as ProviderBackgroundRequestRow | undefined;
|
||||
if (!request) throw new Error("Provider background request was not found.");
|
||||
return request;
|
||||
}
|
||||
|
||||
recordUsage(orgId: string, providerId: string, inputCharCount: number, outputCharCount: number): void {
|
||||
const window = currentProviderUsageWindow();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { SLO_SIGNAL_KEY, type ProviderExecutionMode } from "../../shared/domain";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { providerRateLimitStateFromError } from "./provider-adapters";
|
||||
import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import { ProviderRequestStore } from "./provider-request-store";
|
||||
|
||||
const HEALTH_STATE_HEALTHY = "healthy";
|
||||
const HEALTH_STATE_FAILED = "failed";
|
||||
|
||||
export interface ProviderResponseObserverProvider {
|
||||
id: string;
|
||||
provider_key: string;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderResponseSuccessInput {
|
||||
orgId: string;
|
||||
provider: ProviderResponseObserverProvider;
|
||||
requestId: string;
|
||||
executionMode: ProviderExecutionMode;
|
||||
startedAt: number;
|
||||
action: "response_created" | "response_streamed" | "background_response_completed";
|
||||
}
|
||||
|
||||
export interface ProviderResponseFailureInput {
|
||||
orgId: string;
|
||||
provider: ProviderResponseObserverProvider;
|
||||
requestId: string;
|
||||
executionMode: ProviderExecutionMode;
|
||||
startedAt: number;
|
||||
inputCharCount: number;
|
||||
error: unknown;
|
||||
rawResponseJson?: string | null;
|
||||
}
|
||||
|
||||
export class ProviderResponseObserver {
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly audit: AuditService,
|
||||
private readonly operations: OperationsService,
|
||||
private readonly requestStore: ProviderRequestStore,
|
||||
private readonly rateLimitRegistry: ProviderRateLimitRegistry,
|
||||
) {}
|
||||
|
||||
recordSuccess(input: ProviderResponseSuccessInput): void {
|
||||
this.recordProviderHealth(input.provider.id, input.orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider_request",
|
||||
entityId: input.requestId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: input.action,
|
||||
payload: { providerKey: input.provider.provider_key, model: input.provider.model },
|
||||
});
|
||||
this.operations.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: SLO_SIGNAL_KEY.PROVIDER_REQUEST_SUCCESS,
|
||||
source: "provider_gateway",
|
||||
success: true,
|
||||
durationMs: Date.now() - input.startedAt,
|
||||
detail: { requestId: input.requestId, providerKey: input.provider.provider_key, executionMode: input.executionMode },
|
||||
});
|
||||
}
|
||||
|
||||
recordFailure(input: ProviderResponseFailureInput): void {
|
||||
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||
this.recordObservedRateLimit(input.orgId, input.provider, input.provider.model, providerRateLimitStateFromError(input.error));
|
||||
this.requestStore.markFailed({
|
||||
orgId: input.orgId,
|
||||
requestId: input.requestId,
|
||||
errorMessage: message,
|
||||
completedAt: new Date().toISOString(),
|
||||
rawResponseJson: input.rawResponseJson,
|
||||
});
|
||||
this.requestStore.recordUsage(input.orgId, input.provider.id, input.inputCharCount, 0);
|
||||
this.recordProviderHealth(input.provider.id, input.orgId, HEALTH_STATE_FAILED, message);
|
||||
this.operations.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: SLO_SIGNAL_KEY.PROVIDER_REQUEST_SUCCESS,
|
||||
source: "provider_gateway",
|
||||
success: false,
|
||||
durationMs: Date.now() - input.startedAt,
|
||||
detail: { requestId: input.requestId, providerKey: input.provider.provider_key, executionMode: input.executionMode },
|
||||
});
|
||||
}
|
||||
|
||||
recordObservedRateLimit(
|
||||
orgId: string,
|
||||
provider: ProviderResponseObserverProvider,
|
||||
model: string | null,
|
||||
state: ProviderObservedRateLimitState | null,
|
||||
): void {
|
||||
if (!state) return;
|
||||
this.rateLimitRegistry.recordObservation({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
model,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
private recordProviderHealth(providerId: string, orgId: string, healthState: string, error: string | null): void {
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set health_state = ?, last_error = ?, last_checked_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(healthState, error, new Date().toISOString(), providerId, orgId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CancelProviderResponseInput, CreateProviderResponseInput, StreamProviderResponseInput } from "../../shared/commands";
|
||||
import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain";
|
||||
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
|
||||
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
||||
import type { AuditService } from "./audit-service";
|
||||
import type { AuthBrokerService } from "./auth-broker-service";
|
||||
import type { OperationsService } from "./operations-service";
|
||||
import type { ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import type { ProviderBackgroundService } from "./provider-background-service";
|
||||
import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import type { ProviderRequestStore } from "./provider-request-store";
|
||||
import type { ProviderResponseObserver } from "./provider-response-observer";
|
||||
import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils";
|
||||
import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
|
||||
import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router";
|
||||
import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver";
|
||||
import {
|
||||
HEALTH_STATE_HEALTHY,
|
||||
WORKSPACE_SCOPES,
|
||||
loadConfiguredProvider,
|
||||
loadCredential,
|
||||
recordObservedRateLimit,
|
||||
recordProviderHealth,
|
||||
requireAdapter,
|
||||
type ProviderRow,
|
||||
} from "./provider-internal";
|
||||
|
||||
export type ProviderResponseCreateInput = CreateProviderResponseInput & {
|
||||
onRequestCreated?: (requestId: string) => void;
|
||||
};
|
||||
|
||||
export interface ProviderResponseRunnerDeps {
|
||||
db: DatabaseSync;
|
||||
adapters: ProviderAdapterRegistry;
|
||||
audit: AuditService;
|
||||
authBroker: AuthBrokerService;
|
||||
operations: OperationsService;
|
||||
rateLimitRegistry: ProviderRateLimitRegistry;
|
||||
responseObserver: ProviderResponseObserver;
|
||||
requestStore: ProviderRequestStore;
|
||||
background: ProviderBackgroundService;
|
||||
/** Test-injectable override for the provider's tool-use HTTP turn. */
|
||||
toolTurn?: ToolTurnFn;
|
||||
}
|
||||
|
||||
export class ProviderResponseRunner {
|
||||
constructor(private readonly deps: ProviderResponseRunnerDeps) {}
|
||||
|
||||
async createResponse(input: ProviderResponseCreateInput): Promise<string> {
|
||||
if (input.executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND) return this.deps.background.create(input);
|
||||
const execution = this.prepareExecution(input);
|
||||
if (!execution.adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${execution.provider.provider_key}.`);
|
||||
const requestId = this.createRequestRecord(execution);
|
||||
input.onRequestCreated?.(requestId);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
||||
const result = await execution.adapter.createResponse({
|
||||
authProfile,
|
||||
model: execution.provider.model,
|
||||
prompt: execution.prompt,
|
||||
instructions: execution.instructions,
|
||||
});
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, input.orgId, execution.provider, execution.provider.model, result.rateLimitState);
|
||||
this.deps.requestStore.markSucceeded({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
responseText: result.text,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
outputCharCount: result.text.length,
|
||||
externalResponseId: readProviderResponseId(result.rawResponseJson),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
this.deps.responseObserver.recordSuccess({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
requestId,
|
||||
executionMode: execution.executionMode,
|
||||
startedAt,
|
||||
action: "response_created",
|
||||
});
|
||||
this.deps.requestStore.recordUsage(input.orgId, execution.provider.id, execution.prompt.length, result.text.length);
|
||||
return requestId;
|
||||
} catch (error) {
|
||||
this.deps.responseObserver.recordFailure({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
requestId,
|
||||
executionMode: execution.executionMode,
|
||||
startedAt,
|
||||
inputCharCount: execution.prompt.length,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runToolTurn(input: {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
workspaceId: string | null;
|
||||
instructions: string | null;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
}): Promise<AgentTurnResult & { requestId: string }> {
|
||||
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
|
||||
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
|
||||
this.enforcePolicy(input.orgId, provider, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId);
|
||||
if (!supportsToolTurn(provider.provider_key)) {
|
||||
throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, Google Antigravity, or Claude Pro provider.");
|
||||
}
|
||||
const model = provider.model;
|
||||
if (!model) throw new Error("Provider model is required.");
|
||||
const owner: ProviderRow & { model: string } = { ...provider, model };
|
||||
const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key);
|
||||
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId: input.orgId, provider });
|
||||
const promptChars = JSON.stringify(input.messages).length;
|
||||
const requestId = this.deps.requestStore.create({
|
||||
orgId: input.orgId,
|
||||
provider: owner,
|
||||
model,
|
||||
workspaceId,
|
||||
promptSha256: hashPrompt(JSON.stringify(input.messages)),
|
||||
inputCharCount: promptChars,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools });
|
||||
this.deps.requestStore.markSucceeded({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
responseText: result.text,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
outputCharCount: result.text.length,
|
||||
externalResponseId: readProviderResponseId(result.rawResponseJson),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
this.deps.responseObserver.recordSuccess({
|
||||
orgId: input.orgId,
|
||||
provider: owner,
|
||||
requestId,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
startedAt,
|
||||
action: "response_created",
|
||||
});
|
||||
this.deps.requestStore.recordUsage(input.orgId, provider.id, promptChars, result.text.length);
|
||||
return { ...result, requestId };
|
||||
} catch (error) {
|
||||
this.deps.responseObserver.recordFailure({
|
||||
orgId: input.orgId,
|
||||
provider: owner,
|
||||
requestId,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
startedAt,
|
||||
inputCharCount: promptChars,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async streamResponse(input: StreamProviderResponseInput, onEvent: (event: ProviderStreamRendererEvent) => void): Promise<string> {
|
||||
const execution = this.prepareExecution(input);
|
||||
const requestId = this.createRequestRecord(execution);
|
||||
const startedAt = Date.now();
|
||||
const rawEvents: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
let providerResponseId: string | null = null;
|
||||
try {
|
||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
||||
const result = await execution.adapter.streamResponse({
|
||||
authProfile,
|
||||
model: execution.provider.model,
|
||||
prompt: execution.prompt,
|
||||
instructions: execution.instructions,
|
||||
});
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, input.orgId, execution.provider, execution.provider.model, result.rateLimitState);
|
||||
for await (const event of result.events) {
|
||||
if (event.type === "error") throw new Error(event.textDelta ?? "Provider stream failed.");
|
||||
rawEvents.push(event.rawEventJson);
|
||||
if (event.providerResponseId) providerResponseId = event.providerResponseId;
|
||||
if (event.textDelta) textParts.push(event.textDelta);
|
||||
const responseText = textParts.join("");
|
||||
const streamEventCount = rawEvents.length;
|
||||
this.deps.requestStore.markStreaming({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
responseText,
|
||||
rawResponseJson: JSON.stringify(rawEvents),
|
||||
outputCharCount: responseText.length,
|
||||
streamEventCount,
|
||||
externalResponseId: providerResponseId,
|
||||
});
|
||||
onEvent({
|
||||
requestId,
|
||||
providerId: execution.provider.id,
|
||||
providerKey: execution.provider.provider_key,
|
||||
model: execution.provider.model,
|
||||
streamEventType: event.type,
|
||||
textDelta: event.textDelta,
|
||||
responseText,
|
||||
providerResponseId,
|
||||
rawEventJson: event.rawEventJson,
|
||||
streamEventCount,
|
||||
});
|
||||
}
|
||||
const responseText = requireStreamText(textParts);
|
||||
this.deps.requestStore.markSucceeded({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
responseText,
|
||||
rawResponseJson: JSON.stringify(rawEvents),
|
||||
outputCharCount: responseText.length,
|
||||
externalResponseId: providerResponseId,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
this.deps.responseObserver.recordSuccess({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
requestId,
|
||||
executionMode: execution.executionMode,
|
||||
startedAt,
|
||||
action: "response_streamed",
|
||||
});
|
||||
this.deps.requestStore.recordUsage(input.orgId, execution.provider.id, execution.prompt.length, responseText.length);
|
||||
return requestId;
|
||||
} catch (error) {
|
||||
this.deps.responseObserver.recordFailure({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
requestId,
|
||||
executionMode: execution.executionMode,
|
||||
startedAt,
|
||||
inputCharCount: execution.prompt.length,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelResponse(input: CancelProviderResponseInput): Promise<void> {
|
||||
const request = this.deps.requestStore.loadCancellationRow(input.orgId, input.requestId);
|
||||
if (request.state !== PROVIDER_REQUEST_STATE.PENDING && request.state !== PROVIDER_REQUEST_STATE.STREAMING) {
|
||||
throw new Error(`Provider request cannot be cancelled from state ${request.state}.`);
|
||||
}
|
||||
const providerResponseId = input.providerResponseId?.trim() || request.external_response_id;
|
||||
if (!providerResponseId) throw new Error("Provider response id is required.");
|
||||
const provider = loadConfiguredProvider(this.deps.db, input.orgId, request.provider_id);
|
||||
const adapter = requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type);
|
||||
const credential = loadCredential(this.deps.authBroker, input.orgId, provider);
|
||||
const authProfile = adapter.parseAuthProfile(credential.secret);
|
||||
const result = await adapter.cancelResponse({ authProfile, responseId: providerResponseId });
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, input.orgId, provider, provider.model, result.rateLimitState);
|
||||
this.deps.requestStore.markCancelled({
|
||||
orgId: input.orgId,
|
||||
requestId: input.requestId,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
externalResponseId: providerResponseId,
|
||||
cancelledAt: new Date().toISOString(),
|
||||
});
|
||||
recordProviderHealth(this.deps.db, provider.id, input.orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.deps.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider_request",
|
||||
entityId: input.requestId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "response_cancelled",
|
||||
payload: { providerKey: provider.provider_key, model: provider.model },
|
||||
});
|
||||
}
|
||||
|
||||
private prepareExecution(input: CreateProviderResponseInput | StreamProviderResponseInput): PreparedProviderExecution {
|
||||
const prompt = input.prompt.trim();
|
||||
if (!prompt) throw new Error("Prompt is required.");
|
||||
const provider = loadConfiguredProvider(this.deps.db, 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, 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,
|
||||
provider: { ...provider, model: provider.model },
|
||||
adapter: requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type),
|
||||
credential: loadCredential(this.deps.authBroker, input.orgId, provider),
|
||||
};
|
||||
}
|
||||
|
||||
private createRequestRecord(execution: PreparedProviderExecution): string {
|
||||
return this.deps.requestStore.create({
|
||||
orgId: execution.orgId,
|
||||
provider: execution.provider,
|
||||
model: execution.provider.model,
|
||||
workspaceId: execution.workspaceId,
|
||||
promptSha256: hashPrompt(execution.prompt),
|
||||
inputCharCount: execution.prompt.length,
|
||||
executionMode: execution.executionMode,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && !this.deps.operations.isFeatureEnabled(orgId, FEATURE_FLAG_KEY.PROVIDER_BACKGROUND_EXECUTION)) {
|
||||
throw new Error("Provider background execution is disabled by feature flag.");
|
||||
}
|
||||
if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.background_enabled !== 1) {
|
||||
throw new Error("Background execution is disabled for this provider policy.");
|
||||
}
|
||||
const window = currentProviderUsageWindow();
|
||||
const usage = this.deps.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 normalizeWorkspaceId(orgId: string, workspaceId: string | null): string | null {
|
||||
const normalized = workspaceId?.trim() || null;
|
||||
if (!normalized) return null;
|
||||
const workspace = this.deps.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;
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedProviderExecution {
|
||||
orgId: string;
|
||||
workspaceId: string | null;
|
||||
prompt: string;
|
||||
instructions: string | null;
|
||||
executionMode: ProviderExecutionMode;
|
||||
provider: ProviderRow & { model: string };
|
||||
adapter: ProviderAdapterRegistry[string];
|
||||
credential: { secret: string };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function hashPrompt(prompt: string): string {
|
||||
return createHash("sha256").update(prompt).digest("hex");
|
||||
}
|
||||
|
||||
export function readProviderResponseId(rawResponseJson: string): string | null {
|
||||
const parsed = JSON.parse(rawResponseJson) as unknown;
|
||||
if (!isRecord(parsed)) return null;
|
||||
const id = parsed.id;
|
||||
return typeof id === "string" && id.trim() ? id.trim() : null;
|
||||
}
|
||||
|
||||
export function readProviderFailureMessage(rawResponseJson: string, status: string): string {
|
||||
const parsed = JSON.parse(rawResponseJson) as unknown;
|
||||
if (!isRecord(parsed)) return `Provider background response ended with status ${status}.`;
|
||||
const error = parsed.error;
|
||||
if (typeof error === "string" && error.trim()) return error.trim();
|
||||
if (isRecord(error) && typeof error.message === "string" && error.message.trim()) return error.message.trim();
|
||||
const incomplete = parsed.incomplete_details;
|
||||
if (isRecord(incomplete) && typeof incomplete.reason === "string" && incomplete.reason.trim()) return incomplete.reason.trim();
|
||||
return `Provider background response ended with status ${status}.`;
|
||||
}
|
||||
|
||||
export function requireStreamText(parts: string[]): string {
|
||||
const text = parts.join("");
|
||||
if (!text.trim()) throw new Error("Provider stream returned no text content.");
|
||||
return text;
|
||||
}
|
||||
|
||||
export function requireBackgroundText(text: string | null, providerKey: string): string {
|
||||
if (!text?.trim()) throw new Error(`${providerKey} background response returned no text content.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput } from "../../shared/commands";
|
||||
import type { ProviderModelEntry } from "../../shared/provider-models";
|
||||
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
|
||||
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { SecretVault } from "./secret-vault";
|
||||
import { providerAdapters, providerRateLimitStateFromError, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import { ProviderBackgroundService } from "./provider-background-service";
|
||||
import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import { ProviderResponseObserver } from "./provider-response-observer";
|
||||
import { ProviderRequestStore } from "./provider-request-store";
|
||||
import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./provider-response-runner";
|
||||
import { launchProviderSignInWindow, type LaunchProviderSignInWindowInput } from "./provider-sign-in-window";
|
||||
import type { ToolTurnFn } from "./provider-tool-turn";
|
||||
import {
|
||||
AUTH_STATE_CONFIGURED,
|
||||
AUTH_STATE_REVOKED,
|
||||
HEALTH_STATE_FAILED,
|
||||
HEALTH_STATE_HEALTHY,
|
||||
HEALTH_STATE_UNCHECKED,
|
||||
WORKSPACE_SCOPES,
|
||||
loadConfiguredProvider,
|
||||
loadCredential,
|
||||
recordObservedRateLimit,
|
||||
recordProviderHealth,
|
||||
requireAdapter,
|
||||
requireCatalogEntry,
|
||||
} from "./provider-internal";
|
||||
|
||||
export type { ProviderResponseCreateInput };
|
||||
|
||||
const AUTH_SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
export class ProviderService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly authBroker: AuthBrokerService;
|
||||
private readonly operations: OperationsService;
|
||||
private readonly rateLimitRegistry: ProviderRateLimitRegistry;
|
||||
private readonly responseObserver: ProviderResponseObserver;
|
||||
private readonly requestStore: ProviderRequestStore;
|
||||
private readonly background: ProviderBackgroundService;
|
||||
private readonly runner: ProviderResponseRunner;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
||||
vault: ProviderSecretVault = new SecretVault(),
|
||||
private readonly launchSignIn: (input: LaunchProviderSignInWindowInput) => void = launchProviderSignInWindow,
|
||||
toolTurn?: ToolTurnFn,
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
this.authBroker = new AuthBrokerService(db, vault);
|
||||
this.operations = new OperationsService(db);
|
||||
this.rateLimitRegistry = new ProviderRateLimitRegistry(db);
|
||||
this.requestStore = new ProviderRequestStore(db);
|
||||
this.responseObserver = new ProviderResponseObserver(db, this.audit, this.operations, this.requestStore, this.rateLimitRegistry);
|
||||
this.background = new ProviderBackgroundService(db, adapters, vault);
|
||||
this.runner = new ProviderResponseRunner({
|
||||
db,
|
||||
adapters,
|
||||
audit: this.audit,
|
||||
authBroker: this.authBroker,
|
||||
operations: this.operations,
|
||||
rateLimitRegistry: this.rateLimitRegistry,
|
||||
responseObserver: this.responseObserver,
|
||||
requestStore: this.requestStore,
|
||||
background: this.background,
|
||||
toolTurn,
|
||||
});
|
||||
}
|
||||
|
||||
createResponse(input: ProviderResponseCreateInput): Promise<string> {
|
||||
return this.runner.createResponse(input);
|
||||
}
|
||||
|
||||
streamResponse(input: StreamProviderResponseInput, onEvent: (event: ProviderStreamRendererEvent) => void): Promise<string> {
|
||||
return this.runner.streamResponse(input, onEvent);
|
||||
}
|
||||
|
||||
cancelResponse(input: CancelProviderResponseInput): Promise<void> {
|
||||
return this.runner.cancelResponse(input);
|
||||
}
|
||||
|
||||
runToolTurn(input: Parameters<ProviderResponseRunner["runToolTurn"]>[0]): ReturnType<ProviderResponseRunner["runToolTurn"]> {
|
||||
return this.runner.runToolTurn(input);
|
||||
}
|
||||
|
||||
saveCredential(input: SaveProviderCredentialInput): void {
|
||||
const provider = this.db.prepare("select * from providers where id = ? and org_id = ?")
|
||||
.get(input.providerId, input.orgId) as { provider_key: string; auth_type: ProviderAuthType } | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
const secret = input.secret.trim();
|
||||
if (!secret) throw new Error("Credential is required.");
|
||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||
adapter.parseAuthProfile(secret);
|
||||
const catalogEntry = requireCatalogEntry(provider.provider_key);
|
||||
const source = this.resolveCredentialSource({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
providerKey: provider.provider_key,
|
||||
authType: provider.auth_type,
|
||||
employeeId: input.employeeId ?? null,
|
||||
secret,
|
||||
});
|
||||
const authProfile = this.authBroker.createProviderProfile({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
providerKey: provider.provider_key,
|
||||
authType: provider.auth_type,
|
||||
secret,
|
||||
profileKind: provider.auth_type,
|
||||
sourceType: source.sourceType,
|
||||
sourceRef: source.sourceRef,
|
||||
capabilityTags: catalogEntry.capabilityTags,
|
||||
});
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||
where id = ? and org_id = ?
|
||||
`).run(authProfile.credentialId, input.model, AUTH_STATE_CONFIGURED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
entityId: input.providerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "credential_saved",
|
||||
payload: { providerKey: provider.provider_key, authType: provider.auth_type, authProfileId: authProfile.authProfileId, sourceType: source.sourceType, sourceRef: source.sourceRef },
|
||||
});
|
||||
}
|
||||
|
||||
revokeCredential(input: RevokeProviderCredentialInput): void {
|
||||
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.authBroker.revokeProviderProfile({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
providerKey: provider.provider_key,
|
||||
});
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||
where id = ? and org_id = ?
|
||||
`).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
entityId: input.providerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "credential_revoked",
|
||||
payload: { providerKey: provider.provider_key },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the live model catalog from the provider. Onboarding passes the
|
||||
* just-typed (or just-authorized) credential as `secret`; settings passes a
|
||||
* stored provider reference and the secret is decrypted server-side. Either
|
||||
* way the credential never round-trips through the renderer's persisted state.
|
||||
*/
|
||||
async listModels(input: ListProviderModelsInput): Promise<ProviderModelEntry[]> {
|
||||
if (typeof input.secret === "string") {
|
||||
const entry = requireCatalogEntry(input.providerKey);
|
||||
const adapter = requireAdapter(this.adapters, input.providerKey, entry.authType);
|
||||
const authProfile = adapter.parseAuthProfile(input.secret);
|
||||
return adapter.listModels({ authProfile, model: null });
|
||||
}
|
||||
if (!input.orgId || !input.providerId) throw new Error("A credential is required to list provider models.");
|
||||
const provider = loadConfiguredProvider(this.db, input.orgId, input.providerId);
|
||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||
const credential = loadCredential(this.authBroker, input.orgId, provider);
|
||||
const authProfile = adapter.parseAuthProfile(credential.secret);
|
||||
return adapter.listModels({ authProfile, model: provider.model });
|
||||
}
|
||||
|
||||
async checkHealth(orgId: string, providerId: string): Promise<void> {
|
||||
const provider = loadConfiguredProvider(this.db, orgId, providerId);
|
||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||
const credential = loadCredential(this.authBroker, orgId, provider);
|
||||
try {
|
||||
const profile = adapter.parseAuthProfile(credential.secret);
|
||||
const result = await adapter.checkHealth({ authProfile: profile, model: provider.model });
|
||||
recordObservedRateLimit(this.rateLimitRegistry, orgId, provider, provider.model, result.rateLimitState);
|
||||
recordProviderHealth(this.db, provider.id, orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "provider",
|
||||
entityId: provider.id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "health_checked",
|
||||
payload: { providerKey: provider.provider_key, healthState: HEALTH_STATE_HEALTHY },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
recordObservedRateLimit(this.rateLimitRegistry, orgId, provider, provider.model, providerRateLimitStateFromError(error));
|
||||
recordProviderHealth(this.db, provider.id, orgId, HEALTH_STATE_FAILED, message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
launchSignInWindow(input: LaunchProviderSignInInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId);
|
||||
const provider = this.db.prepare(`
|
||||
select id, provider_key, label, auth_type
|
||||
from providers
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.providerId, input.orgId) as { id: string; provider_key: string; label: string; auth_type: string } | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
const catalogEntry = requireCatalogEntry(provider.provider_key);
|
||||
if (!catalogEntry.signInUrl) throw new Error(`${provider.label} does not support browser sign-in.`);
|
||||
const session = this.createAuthSession({
|
||||
orgId: input.orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
employeeId: input.employeeId,
|
||||
signInUrl: catalogEntry.signInUrl,
|
||||
});
|
||||
this.launchSignIn({ providerId: provider.id, label: provider.label, signInUrl: catalogEntry.signInUrl, partition: session.partition });
|
||||
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
entityId: provider.id,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: "sign_in_window_opened",
|
||||
payload: {
|
||||
providerKey: provider.provider_key,
|
||||
authType: provider.auth_type,
|
||||
authSessionId: session.id,
|
||||
state: session.state,
|
||||
nonce: session.nonce,
|
||||
codeVerifierSha256: session.codeVerifierSha256,
|
||||
redirectUri: session.redirectUri,
|
||||
partition: session.partition,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
|
||||
.get(employeeId, orgId) as { id: string } | undefined;
|
||||
if (!employee) throw new Error("Provider sign-in employee was not found.");
|
||||
}
|
||||
|
||||
private resolveCredentialSource(input: {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
authType: ProviderAuthType;
|
||||
employeeId: string | null;
|
||||
secret: string;
|
||||
}): { sourceType: string; sourceRef: string | null } {
|
||||
if (input.authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && input.authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) {
|
||||
return { sourceType: "manual_secret", sourceRef: null };
|
||||
}
|
||||
// Credentials obtained through the native OAuth login arrive as a JSON
|
||||
// object; they are authenticated by the flow itself and need no paste session.
|
||||
if (input.secret.trim().startsWith("{")) {
|
||||
return { sourceType: "provider_oauth_login", sourceRef: null };
|
||||
}
|
||||
if (!input.employeeId) throw new Error("Provider sign-in employee is required.");
|
||||
const session = this.db.prepare(`
|
||||
select id
|
||||
from provider_auth_sessions
|
||||
where org_id = ?
|
||||
and provider_id = ?
|
||||
and provider_key = ?
|
||||
and employee_id = ?
|
||||
and completed_at is null
|
||||
and expires_at > ?
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`).get(input.orgId, input.providerId, input.providerKey, input.employeeId, new Date().toISOString()) as { id: string } | undefined;
|
||||
if (!session) throw new Error("Provider sign-in session is required.");
|
||||
this.db.prepare("update provider_auth_sessions set completed_at = ? where id = ? and org_id = ?")
|
||||
.run(new Date().toISOString(), session.id, input.orgId);
|
||||
return { sourceType: "provider_sign_in_session", sourceRef: session.id };
|
||||
}
|
||||
|
||||
private createAuthSession(input: {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
employeeId: string;
|
||||
signInUrl: string;
|
||||
}): ProviderAuthSessionDraft {
|
||||
const id = createProviderAuthSessionId();
|
||||
const state = randomToken();
|
||||
const nonce = randomToken();
|
||||
const codeVerifier = randomToken();
|
||||
const codeVerifierSha256 = createHash("sha256").update(codeVerifier).digest("hex");
|
||||
const redirectUri = `agentcompany://provider-auth/${input.providerId}`;
|
||||
const partition = `persist:agentcompany-provider-${input.providerId}`;
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + AUTH_SESSION_TTL_MS);
|
||||
this.db.prepare(`
|
||||
insert into provider_auth_sessions(
|
||||
id, org_id, provider_id, provider_key, employee_id, state, nonce,
|
||||
code_verifier_sha256, redirect_uri, sign_in_url, partition,
|
||||
created_at, expires_at, completed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.providerId,
|
||||
input.providerKey,
|
||||
input.employeeId,
|
||||
state,
|
||||
nonce,
|
||||
codeVerifierSha256,
|
||||
redirectUri,
|
||||
input.signInUrl,
|
||||
partition,
|
||||
createdAt.toISOString(),
|
||||
expiresAt.toISOString(),
|
||||
);
|
||||
return { id, state, nonce, codeVerifierSha256, redirectUri, partition };
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderAuthSessionDraft {
|
||||
id: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifierSha256: string;
|
||||
redirectUri: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
function createProviderAuthSessionId(): string {
|
||||
return `auths_${randomToken()}`;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BrowserWindow, session } from "electron";
|
||||
|
||||
export interface LaunchProviderSignInWindowInput {
|
||||
providerId: string;
|
||||
label: string;
|
||||
signInUrl: string;
|
||||
partition: string;
|
||||
}
|
||||
|
||||
export function launchProviderSignInWindow(input: LaunchProviderSignInWindowInput): void {
|
||||
const providerSession = session.fromPartition(input.partition);
|
||||
providerSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
providerSession.setPermissionCheckHandler(() => false);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 760,
|
||||
title: input.label,
|
||||
webPreferences: {
|
||||
session: providerSession,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
attachNavigationPolicy(win, input.signInUrl);
|
||||
win.loadURL(input.signInUrl);
|
||||
}
|
||||
|
||||
function attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void {
|
||||
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
||||
const allow = (url: string) => {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return host === allowedHost || host.endsWith(`.${allowedHost}`);
|
||||
};
|
||||
const blockOffHost = (event: Electron.Event, url: string) => {
|
||||
if (!allow(url)) event.preventDefault();
|
||||
};
|
||||
win.webContents.on("will-navigate", blockOffHost);
|
||||
win.webContents.on("will-redirect", blockOffHost);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import type { JsonRecord, ProviderEventStreamMessage } from "./provider-http";
|
||||
import { isRecord } from "./provider-http";
|
||||
|
||||
export interface ProviderStreamEvent {
|
||||
type: "message_start" | "text_delta" | "message_stop" | "error";
|
||||
textDelta: string | null;
|
||||
providerResponseId: string | null;
|
||||
rawEventJson: string;
|
||||
}
|
||||
|
||||
export interface ProviderStreamResult {
|
||||
events: AsyncIterable<ProviderStreamEvent>;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderCancelResult {
|
||||
responseId: string;
|
||||
rawResponseJson: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export async function* mapOpenAiResponseStream(messages: AsyncIterable<ProviderEventStreamMessage>): AsyncIterable<ProviderStreamEvent> {
|
||||
let started = false;
|
||||
for await (const message of messages) {
|
||||
const type = eventType(message);
|
||||
if (type === "response.created") {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === "response.output_text.delta") {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
const delta = stringField(message.data, "delta");
|
||||
if (delta) yield streamEvent("text_delta", delta, message);
|
||||
continue;
|
||||
}
|
||||
if (type === "response.completed" || type === "response.incomplete") {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
yield streamEvent("message_stop", null, message);
|
||||
continue;
|
||||
}
|
||||
if (type === "response.failed" || type === "error") {
|
||||
yield streamEvent("error", readErrorText(message.data), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* mapOpenAiChatCompletionStream(messages: AsyncIterable<ProviderEventStreamMessage>): AsyncIterable<ProviderStreamEvent> {
|
||||
let started = false;
|
||||
for await (const message of messages) {
|
||||
const delta = readChatDeltaContent(message.data);
|
||||
if (delta) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
yield streamEvent("text_delta", delta, message);
|
||||
}
|
||||
if (hasChatFinish(message.data)) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
yield streamEvent("message_stop", null, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* mapAnthropicMessageStream(messages: AsyncIterable<ProviderEventStreamMessage>): AsyncIterable<ProviderStreamEvent> {
|
||||
let started = false;
|
||||
for await (const message of messages) {
|
||||
const type = eventType(message);
|
||||
if (type === "message_start") {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
continue;
|
||||
}
|
||||
if (type === "content_block_delta") {
|
||||
const delta = readAnthropicTextDelta(message.data);
|
||||
if (delta) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
yield streamEvent("text_delta", delta, message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === "message_stop") {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
}
|
||||
yield streamEvent("message_stop", null, message);
|
||||
continue;
|
||||
}
|
||||
if (type === "error") yield streamEvent("error", readErrorText(message.data), message);
|
||||
}
|
||||
}
|
||||
|
||||
export function streamUnsupported(providerLabel: string): () => Promise<ProviderStreamResult> {
|
||||
return async () => {
|
||||
throw new Error(`${providerLabel} streaming is unavailable.`);
|
||||
};
|
||||
}
|
||||
|
||||
export function cancelUnsupported(providerLabel: string): () => Promise<ProviderCancelResult> {
|
||||
return async () => {
|
||||
throw new Error(`${providerLabel} response cancellation is unavailable.`);
|
||||
};
|
||||
}
|
||||
|
||||
function streamEvent(type: ProviderStreamEvent["type"], textDelta: string | null, message: ProviderEventStreamMessage): ProviderStreamEvent {
|
||||
return { type, textDelta, providerResponseId: readProviderResponseId(message.data), rawEventJson: message.rawData };
|
||||
}
|
||||
|
||||
function eventType(message: ProviderEventStreamMessage): string | null {
|
||||
const dataType = stringField(message.data, "type");
|
||||
return dataType ?? message.eventName;
|
||||
}
|
||||
|
||||
function readChatDeltaContent(data: JsonRecord): string | null {
|
||||
for (const choice of recordArray(data.choices)) {
|
||||
const delta = recordField(choice, "delta");
|
||||
const content = delta ? stringField(delta, "content") : null;
|
||||
if (content) return content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasChatFinish(data: JsonRecord): boolean {
|
||||
return recordArray(data.choices).some((choice) => typeof choice.finish_reason === "string" && choice.finish_reason.length > 0);
|
||||
}
|
||||
|
||||
function readAnthropicTextDelta(data: JsonRecord): string | null {
|
||||
const delta = recordField(data, "delta");
|
||||
if (!delta) return null;
|
||||
return stringField(delta, "text");
|
||||
}
|
||||
|
||||
function readErrorText(data: JsonRecord): string | null {
|
||||
const error = data.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (isRecord(error) && typeof error.message === "string") return error.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readProviderResponseId(data: JsonRecord): string | null {
|
||||
const direct = stringField(data, "id") ?? stringField(data, "response_id");
|
||||
if (direct) return direct;
|
||||
const response = recordField(data, "response");
|
||||
return response ? stringField(response, "id") : null;
|
||||
}
|
||||
|
||||
function stringField(record: JsonRecord, key: string): string | null {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function recordField(record: JsonRecord, key: string): JsonRecord | null {
|
||||
const value = record[key];
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function recordArray(value: unknown): JsonRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shared contract for a single tool-use ("function calling") turn against any
|
||||
* provider backend. The agent loop maintains a normalized conversation
|
||||
* ({@link AgentTurnMessage}[]) plus the requested {@link ToolDefinition}s; each
|
||||
* provider's turn function (under `provider-tool-turns/`) translates that into
|
||||
* the backend's wire format, calls the live endpoint, and parses the reply plus
|
||||
* any tool calls back into {@link AgentTurnResult}.
|
||||
*/
|
||||
import { isRecord, type JsonRecord } from "./provider-http";
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
/** JSON Schema for the tool's arguments. */
|
||||
parameters: JsonRecord;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: JsonRecord;
|
||||
}
|
||||
|
||||
export type AgentTurnMessage =
|
||||
| { role: "user"; text: string }
|
||||
| { role: "assistant"; text: string; toolCalls: ToolCall[] }
|
||||
| { role: "tool"; results: Array<{ toolCallId: string; toolName: string; output: string }> };
|
||||
|
||||
export interface AgentTurnResult {
|
||||
text: string;
|
||||
toolCalls: ToolCall[];
|
||||
rawResponseJson: string;
|
||||
}
|
||||
|
||||
/** Resolved per-request credential, already refreshed if it was an OAuth token. */
|
||||
export interface TurnCredential {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
accountId?: string;
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string | null;
|
||||
/** Base URL for Azure and custom OpenAI/Anthropic-compatible endpoints. */
|
||||
baseUrl?: string;
|
||||
deployment?: string;
|
||||
/** AWS Bedrock. */
|
||||
region?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
sessionToken?: string | null;
|
||||
/** Vertex AI service-account JSON. */
|
||||
serviceAccountJson?: string;
|
||||
}
|
||||
|
||||
export interface ProviderToolTurnInput {
|
||||
credential: TurnCredential;
|
||||
model: string;
|
||||
instructions: string | null;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
}
|
||||
|
||||
export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise<AgentTurnResult>;
|
||||
|
||||
/** Coerce a tool-call argument payload (string or object) into a JSON object. */
|
||||
export function parseArguments(value: unknown): JsonRecord {
|
||||
if (isRecord(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (isRecord(parsed)) return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Anthropic Messages API encoding/decoding, shared by anthropic + claude-pro. */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import { type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function anthropicToolDefs(tools: ToolDefinition[]): unknown[] {
|
||||
return tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters }));
|
||||
}
|
||||
|
||||
export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] {
|
||||
const out: unknown[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
out.push({ role: "user", content: [{ type: "text", text: message.text }] });
|
||||
} else if (message.role === "assistant") {
|
||||
const content: unknown[] = [];
|
||||
if (message.text) content.push({ type: "text", text: message.text });
|
||||
for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments });
|
||||
out.push({ role: "assistant", content });
|
||||
} else {
|
||||
out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const content = Array.isArray(data.content) ? data.content : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) continue;
|
||||
if (block.type === "text" && typeof block.text === "string") textParts.push(block.text);
|
||||
else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
|
||||
toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} });
|
||||
}
|
||||
}
|
||||
return { text: textParts.join("\n").trim(), toolCalls };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AWS Signature Version 4 signing for Bedrock Runtime requests. Implements the
|
||||
* canonical-request / string-to-sign / signing-key chain with node:crypto so no
|
||||
* AWS SDK is needed.
|
||||
*/
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
export interface AwsSigner {
|
||||
region: string;
|
||||
service: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string | null;
|
||||
}
|
||||
|
||||
function sha256Hex(data: string): string {
|
||||
return createHash("sha256").update(data, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function hmac(key: Buffer | string, data: string): Buffer {
|
||||
return createHmac("sha256", typeof key === "string" ? Buffer.from(key, "utf8") : key).update(data, "utf8").digest();
|
||||
}
|
||||
|
||||
/** RFC 3986 encoding used by SigV4; keeps "/" only when encodeSlash is false. */
|
||||
function awsUriEncode(value: string, encodeSlash: boolean): string {
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (/[A-Za-z0-9\-._~]/.test(ch)) out += ch;
|
||||
else if (ch === "/" && !encodeSlash) out += ch;
|
||||
else out += [...Buffer.from(ch, "utf8")].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function amzTimestamp(): { amzDate: string; dateStamp: string } {
|
||||
const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a request and returns the absolute (encoded) URL plus the headers to
|
||||
* send. `rawPath` is the unencoded absolute path (e.g. /model/<modelId>/converse).
|
||||
*/
|
||||
export function signAwsRequest(input: {
|
||||
method: string;
|
||||
host: string;
|
||||
rawPath: string;
|
||||
body: string;
|
||||
signer: AwsSigner;
|
||||
}): { url: string; headers: Record<string, string> } {
|
||||
const { host, rawPath, body, signer } = input;
|
||||
const { region, service, accessKeyId, secretAccessKey, sessionToken } = signer;
|
||||
const { amzDate, dateStamp } = amzTimestamp();
|
||||
const canonicalUri = awsUriEncode(rawPath, false);
|
||||
const payloadHash = sha256Hex(body);
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
host,
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
"x-amz-date": amzDate,
|
||||
};
|
||||
if (sessionToken) baseHeaders["x-amz-security-token"] = sessionToken;
|
||||
|
||||
const sortedHeaderKeys = Object.keys(baseHeaders).sort();
|
||||
const canonicalHeaders = sortedHeaderKeys.map((key) => `${key}:${baseHeaders[key].trim()}\n`).join("");
|
||||
const signedHeaders = sortedHeaderKeys.join(";");
|
||||
const canonicalRequest = [input.method, canonicalUri, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||
|
||||
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
|
||||
|
||||
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
const kSigning = hmac(kService, "aws4_request");
|
||||
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
||||
|
||||
// `host` is signed but not returned: fetch sets the Host header from the URL.
|
||||
return {
|
||||
url: `https://${host}${canonicalUri}`,
|
||||
headers: {
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
"x-amz-date": amzDate,
|
||||
...(sessionToken ? { "x-amz-security-token": sessionToken } : {}),
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** AWS Bedrock Converse API encoding/decoding (ported from Pi's bedrock handler). */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import type { AgentTurnMessage, ToolCall, ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord {
|
||||
return {
|
||||
tools: tools.map((tool) => ({ toolSpec: { name: tool.name, description: tool.description, inputSchema: { json: tool.parameters } } })),
|
||||
toolChoice: { auto: {} },
|
||||
};
|
||||
}
|
||||
|
||||
export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] {
|
||||
const out: JsonRecord[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
out.push({ role: "user", content: [{ text: message.text }] });
|
||||
} else if (message.role === "assistant") {
|
||||
const content: unknown[] = [];
|
||||
if (message.text) content.push({ text: message.text });
|
||||
for (const call of message.toolCalls) content.push({ toolUse: { toolUseId: call.id, name: call.name, input: call.arguments } });
|
||||
out.push({ role: "assistant", content });
|
||||
} else {
|
||||
out.push({ role: "user", content: message.results.map((result) => ({ toolResult: { toolUseId: result.toolCallId, content: [{ text: result.output }] } })) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const output = isRecord(data.output) ? data.output : null;
|
||||
const message = output && isRecord(output.message) ? output.message : null;
|
||||
const content = message && Array.isArray(message.content) ? message.content : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) continue;
|
||||
if (typeof block.text === "string") textParts.push(block.text);
|
||||
else if (isRecord(block.toolUse)) {
|
||||
const toolUse = block.toolUse;
|
||||
const id = typeof toolUse.toolUseId === "string" ? toolUse.toolUseId : "";
|
||||
const name = typeof toolUse.name === "string" ? toolUse.name : "";
|
||||
if (id && name) toolCalls.push({ id, name, arguments: isRecord(toolUse.input) ? toolUse.input : {} });
|
||||
}
|
||||
}
|
||||
return { text: textParts.join("\n").trim(), toolCalls };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter). */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import { parseArguments, type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function chatToolDefs(tools: ToolDefinition[]): unknown[] {
|
||||
return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }));
|
||||
}
|
||||
|
||||
export function toChatMessages(instructions: string | null, messages: AgentTurnMessage[]): unknown[] {
|
||||
const out: unknown[] = [];
|
||||
if (instructions) out.push({ role: "system", content: instructions });
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
out.push({ role: "user", content: message.text });
|
||||
} else if (message.role === "assistant") {
|
||||
const entry: JsonRecord = { role: "assistant", content: message.text || null };
|
||||
if (message.toolCalls.length > 0) {
|
||||
entry.tool_calls = message.toolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments) } }));
|
||||
}
|
||||
out.push(entry);
|
||||
} else {
|
||||
for (const result of message.results) {
|
||||
out.push({ role: "tool", tool_call_id: result.toolCallId, content: result.output });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const choices = Array.isArray(data.choices) ? data.choices : [];
|
||||
const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null;
|
||||
const message = first && isRecord(first.message) ? first.message : null;
|
||||
if (!message) return { text: "", toolCalls: [] };
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
const rawCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const call of rawCalls) {
|
||||
if (!isRecord(call)) continue;
|
||||
const fn = isRecord(call.function) ? call.function : null;
|
||||
const id = typeof call.id === "string" ? call.id : "";
|
||||
const name = fn && typeof fn.name === "string" ? fn.name : "";
|
||||
if (id && name) toolCalls.push({ id, name, arguments: parseArguments(fn?.arguments) });
|
||||
}
|
||||
return { text: text.trim(), toolCalls };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Resolves the per-request credential for a tool-use turn. API-key providers
|
||||
* pass the key straight through; OAuth/subscription providers parse the stored
|
||||
* credential JSON, refresh the access token when it has expired (persisting the
|
||||
* rotated token), and hand the turn a usable access token plus the extras
|
||||
* (accountId / projectId / enterpriseUrl) its backend needs.
|
||||
*/
|
||||
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
|
||||
import type { AuthBrokerService } from "../auth-broker-service";
|
||||
import { loadCredential, type ProviderRow } from "../provider-internal";
|
||||
import {
|
||||
parseAnthropicCompatProfile,
|
||||
parseApiKey,
|
||||
parseAzureProfile,
|
||||
parseBedrockProfile,
|
||||
parseOpenAiCompatProfile,
|
||||
parseVertexProfile,
|
||||
requireAnthropicCompatProfile,
|
||||
requireApiKeyProfile,
|
||||
requireAzureProfile,
|
||||
requireBedrockProfile,
|
||||
requireOpenAiCompatProfile,
|
||||
requireVertexProfile,
|
||||
} from "../provider-auth-profiles";
|
||||
import type { TurnCredential } from "../provider-tool-turn";
|
||||
import type { OAuthCredentials } from "../oauth/oauth-types";
|
||||
import { refreshCodex } from "../oauth/openai-codex-flow";
|
||||
import { refreshClaudePro } from "../oauth/anthropic-claude-flow";
|
||||
import { refreshAntigravity } from "../oauth/google-antigravity-flow";
|
||||
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
|
||||
|
||||
interface StoredOAuth {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string | null;
|
||||
}
|
||||
|
||||
function parseStoredOAuth(secret: string): StoredOAuth {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(secret);
|
||||
} catch {
|
||||
throw new Error("Stored OAuth credential is not valid JSON; reconnect the provider.");
|
||||
}
|
||||
const creds = parsed as StoredOAuth;
|
||||
if (typeof creds.access !== "string" || typeof creds.refresh !== "string" || typeof creds.expires !== "number") {
|
||||
throw new Error("Stored OAuth credential is incomplete; reconnect the provider.");
|
||||
}
|
||||
return creds;
|
||||
}
|
||||
|
||||
function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCredentials> {
|
||||
switch (providerKey) {
|
||||
case "gpt-account":
|
||||
return refreshCodex(creds.refresh);
|
||||
case "claude-pro":
|
||||
return refreshClaudePro(creds.refresh);
|
||||
case "google-antigravity":
|
||||
if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider.");
|
||||
return refreshAntigravity(creds.refresh, creds.projectId);
|
||||
case "github-copilot":
|
||||
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
|
||||
default:
|
||||
throw new Error(`${providerKey} cannot refresh its credential.`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCloudCredential(providerKey: string, secret: string): TurnCredential {
|
||||
switch (providerKey) {
|
||||
case "custom-openai": {
|
||||
const profile = requireOpenAiCompatProfile(parseOpenAiCompatProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
||||
}
|
||||
case "custom-anthropic": {
|
||||
const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
||||
}
|
||||
case "azure-openai": {
|
||||
const profile = requireAzureProfile(parseAzureProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl, deployment: profile.deployment };
|
||||
}
|
||||
case "aws-bedrock": {
|
||||
const profile = requireBedrockProfile(parseBedrockProfile(secret));
|
||||
return { region: profile.region, accessKeyId: profile.accessKeyId, secretAccessKey: profile.secretAccessKey, sessionToken: profile.sessionToken };
|
||||
}
|
||||
case "vertex-ai": {
|
||||
const profile = requireVertexProfile(parseVertexProfile(secret));
|
||||
return { projectId: profile.projectId, region: profile.region, serviceAccountJson: profile.serviceAccountJson };
|
||||
}
|
||||
default:
|
||||
throw new Error(`${providerKey} does not support agent execution.`);
|
||||
}
|
||||
}
|
||||
|
||||
function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential {
|
||||
const enterpriseUrl = creds.enterpriseUrl;
|
||||
return {
|
||||
accessToken: creds.access,
|
||||
accountId: typeof creds.accountId === "string" ? creds.accountId : undefined,
|
||||
projectId: typeof creds.projectId === "string" ? creds.projectId : undefined,
|
||||
enterpriseUrl: typeof enterpriseUrl === "string" ? enterpriseUrl : enterpriseUrl === null ? null : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveTurnCredential(deps: {
|
||||
authBroker: AuthBrokerService;
|
||||
orgId: string;
|
||||
provider: ProviderRow;
|
||||
}): Promise<TurnCredential> {
|
||||
const secret = loadCredential(deps.authBroker, deps.orgId, deps.provider).secret;
|
||||
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.API_KEY) {
|
||||
return { apiKey: requireApiKeyProfile(parseApiKey(secret), deps.provider.provider_key).apiKey };
|
||||
}
|
||||
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.CLOUD) {
|
||||
return resolveCloudCredential(deps.provider.provider_key, secret);
|
||||
}
|
||||
const creds = parseStoredOAuth(secret);
|
||||
if (Date.now() < creds.expires) return toTurnCredential(creds);
|
||||
if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider.");
|
||||
const refreshed = await refreshOAuth(deps.provider.provider_key, creds);
|
||||
deps.authBroker.updateProviderSecret({
|
||||
orgId: deps.orgId,
|
||||
providerKey: deps.provider.provider_key,
|
||||
credentialId: deps.provider.credential_id,
|
||||
secret: JSON.stringify(refreshed),
|
||||
});
|
||||
return toTurnCredential(refreshed);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Mints a Google Cloud access token from a service-account JSON by signing a
|
||||
* JWT (RS256) and exchanging it at the OAuth token endpoint. Tokens are cached
|
||||
* per service account until shortly before expiry.
|
||||
*/
|
||||
import { createSign } from "node:crypto";
|
||||
|
||||
interface ServiceAccount {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
}
|
||||
|
||||
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
function base64url(value: string | Buffer): string {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
export async function getGcpAccessToken(serviceAccountJson: string): Promise<string> {
|
||||
let account: ServiceAccount;
|
||||
try {
|
||||
account = JSON.parse(serviceAccountJson) as ServiceAccount;
|
||||
} catch {
|
||||
throw new Error("Vertex service-account credential is not valid JSON.");
|
||||
}
|
||||
if (typeof account.client_email !== "string" || typeof account.private_key !== "string") {
|
||||
throw new Error("Vertex service-account JSON is missing client_email or private_key.");
|
||||
}
|
||||
const cached = TOKEN_CACHE.get(account.client_email);
|
||||
if (cached && Date.now() < cached.expiresAt) return cached.token;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||
const claims = base64url(JSON.stringify({
|
||||
iss: account.client_email,
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}));
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = base64url(createSign("RSA-SHA256").update(signingInput).sign(account.private_key));
|
||||
const assertion = `${signingInput}.${signature}`;
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Vertex authentication failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Vertex authentication response was missing an access token.");
|
||||
}
|
||||
TOKEN_CACHE.set(account.client_email, { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 - 60 * 1000 });
|
||||
return json.access_token;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Google Cloud Code Assist (Gemini) encoding for the Antigravity backend.
|
||||
* Ported from Pi's google-shared `convertMessages` / `convertTools` and the
|
||||
* Antigravity request wrapper in google-gemini-cli.
|
||||
*/
|
||||
import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
|
||||
"You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." +
|
||||
"**Absolute paths only**" +
|
||||
"**Proactiveness**";
|
||||
|
||||
function usesToolCallId(model: string): boolean {
|
||||
return model.startsWith("claude-") || model.startsWith("gpt-oss-");
|
||||
}
|
||||
|
||||
function geminiToolDefs(model: string, tools: ToolDefinition[]): unknown[] {
|
||||
const useParameters = model.startsWith("claude-");
|
||||
return [
|
||||
{
|
||||
functionDeclarations: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonRecord[] {
|
||||
const withId = usesToolCallId(model);
|
||||
const contents: JsonRecord[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
contents.push({ role: "user", parts: [{ text: message.text }] });
|
||||
} else if (message.role === "assistant") {
|
||||
const parts: unknown[] = [];
|
||||
if (message.text) parts.push({ text: message.text });
|
||||
for (const call of message.toolCalls) {
|
||||
parts.push({ functionCall: { name: call.name, args: call.arguments, ...(withId ? { id: call.id } : {}) } });
|
||||
}
|
||||
if (parts.length > 0) contents.push({ role: "model", parts });
|
||||
} else {
|
||||
const responses = message.results.map((result) => ({
|
||||
functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) },
|
||||
}));
|
||||
const last = contents[contents.length - 1];
|
||||
if (last && last.role === "user" && Array.isArray(last.parts) && last.parts.some((part) => isRecord(part) && "functionResponse" in part)) {
|
||||
(last.parts as unknown[]).push(...responses);
|
||||
} else {
|
||||
contents.push({ role: "user", parts: responses });
|
||||
}
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
/** The full Antigravity request envelope ({project, model, request, ...}). */
|
||||
export function antigravityRequestBody(input: {
|
||||
model: string;
|
||||
projectId: string;
|
||||
instructions: string | null;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
requestId: string;
|
||||
}): JsonRecord {
|
||||
const request: JsonRecord = {
|
||||
contents: toGeminiContents(input.model, input.messages),
|
||||
systemInstruction: {
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
||||
{ text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
|
||||
...(input.instructions ? [{ text: input.instructions }] : []),
|
||||
],
|
||||
},
|
||||
};
|
||||
if (input.tools.length > 0) {
|
||||
request.tools = geminiToolDefs(input.model, input.tools);
|
||||
request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
||||
}
|
||||
return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId };
|
||||
}
|
||||
|
||||
/** The Vertex AI generateContent body (Gemini, no Antigravity wrapper). */
|
||||
export function vertexRequestBody(input: {
|
||||
model: string;
|
||||
instructions: string | null;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
}): JsonRecord {
|
||||
const body: JsonRecord = { contents: toGeminiContents(input.model, input.messages) };
|
||||
if (input.instructions) body.systemInstruction = { parts: [{ text: input.instructions }] };
|
||||
if (input.tools.length > 0) {
|
||||
body.tools = geminiToolDefs(input.model, input.tools);
|
||||
body.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function partsToResult(parts: unknown[]): { text: string[]; toolCalls: ToolCall[] } {
|
||||
const text: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let counter = 0;
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (typeof part.text === "string" && part.thought !== true) text.push(part.text);
|
||||
else if (isRecord(part.functionCall)) {
|
||||
const fc = part.functionCall;
|
||||
const name = typeof fc.name === "string" ? fc.name : "";
|
||||
if (!name) continue;
|
||||
const id = typeof fc.id === "string" && fc.id ? fc.id : `${name}_${(counter += 1)}`;
|
||||
toolCalls.push({ id, name, arguments: isRecord(fc.args) ? fc.args : {} });
|
||||
}
|
||||
}
|
||||
return { text, toolCalls };
|
||||
}
|
||||
|
||||
/** Parse a non-streaming Gemini generateContent response (Vertex). */
|
||||
export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const candidates = Array.isArray(data.candidates) ? data.candidates : [];
|
||||
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
|
||||
const parts = content && Array.isArray(content.parts) ? content.parts : [];
|
||||
const result = partsToResult(parts);
|
||||
return { text: result.text.join("").trim(), toolCalls: result.toolCalls };
|
||||
}
|
||||
|
||||
export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
|
||||
const raw: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let counter = 0;
|
||||
for await (const message of messages) {
|
||||
raw.push(message.rawData);
|
||||
if (isRecord(message.data.error)) {
|
||||
const error = message.data.error;
|
||||
throw new Error(typeof error.message === "string" ? error.message : "Antigravity stream failed.");
|
||||
}
|
||||
const response = isRecord(message.data.response) ? message.data.response : null;
|
||||
if (!response) continue;
|
||||
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
||||
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
|
||||
const parts = content && Array.isArray(content.parts) ? content.parts : [];
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (typeof part.text === "string" && part.thought !== true) {
|
||||
textParts.push(part.text);
|
||||
} else if (isRecord(part.functionCall)) {
|
||||
const fc = part.functionCall;
|
||||
const name = typeof fc.name === "string" ? fc.name : "";
|
||||
if (!name) continue;
|
||||
const id = typeof fc.id === "string" && fc.id ? fc.id : `${name}_${(counter += 1)}`;
|
||||
toolCalls.push({ id, name, arguments: isRecord(fc.args) ? fc.args : {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* OpenAI Responses API encoding/decoding, shared by the openai, gpt-account
|
||||
* (Codex) and github-copilot turns. Matches Pi's `convertResponsesMessages` /
|
||||
* `convertResponsesTools` and the Responses streaming event shapes.
|
||||
*/
|
||||
import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { parseArguments, type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] {
|
||||
// `strict` must be a boolean or omitted. Passing `strict: null` (the Codex
|
||||
// path) is malformed and makes the endpoint drop the whole tool list, so the
|
||||
// model has nothing to call. Omit the field instead of sending null.
|
||||
return tools.map((tool) => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
...(strict === null ? {} : { strict }),
|
||||
}));
|
||||
}
|
||||
|
||||
export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] {
|
||||
const items: unknown[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
items.push({ role: "user", content: [{ type: "input_text", text: message.text }] });
|
||||
} else if (message.role === "assistant") {
|
||||
if (message.text) {
|
||||
items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: message.text, annotations: [] }] });
|
||||
}
|
||||
for (const call of message.toolCalls) {
|
||||
items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) });
|
||||
}
|
||||
} else {
|
||||
for (const result of message.results) {
|
||||
items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Parse a non-streaming Responses payload (`data.output[]`). */
|
||||
export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const output = Array.isArray(data.output) ? data.output : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const item of output) {
|
||||
if (!isRecord(item)) continue;
|
||||
if (item.type === "function_call") {
|
||||
const id = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : "";
|
||||
const name = typeof item.name === "string" ? item.name : "";
|
||||
if (id && name) toolCalls.push({ id, name, arguments: parseArguments(item.arguments) });
|
||||
} else if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const block of item.content) {
|
||||
if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text: textParts.join("\n").trim(), toolCalls };
|
||||
}
|
||||
|
||||
function eventType(message: ProviderEventStreamMessage): string | null {
|
||||
const type = message.data.type;
|
||||
return typeof type === "string" ? type : message.eventName;
|
||||
}
|
||||
|
||||
function errorText(data: JsonRecord): string {
|
||||
const error = data.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (isRecord(error) && typeof error.message === "string") return error.message;
|
||||
if (typeof data.message === "string") return data.message;
|
||||
return "Provider stream failed.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate a Responses SSE stream (Codex/Copilot) into the final result.
|
||||
*
|
||||
* The ChatGPT/Codex backend streams each output item in a
|
||||
* `response.output_item.done` event and then sends a `response.completed`
|
||||
* whose `response.output` is EMPTY. Reading only the completion event there
|
||||
* loses every tool call (the model acted, but the turn parses as empty). So
|
||||
* collect items as they finish and fall back to them when the completion
|
||||
* carries no output of its own.
|
||||
*/
|
||||
export async function accumulateResponsesStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
|
||||
const raw: string[] = [];
|
||||
const streamed: unknown[] = [];
|
||||
for await (const message of messages) {
|
||||
raw.push(message.rawData);
|
||||
const type = eventType(message);
|
||||
if (!type) continue;
|
||||
if (type === "error" || type === "response.failed") throw new Error(errorText(message.data));
|
||||
if (type === "response.output_item.done" && isRecord(message.data.item)) {
|
||||
streamed.push(message.data.item);
|
||||
}
|
||||
if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
|
||||
const response = isRecord(message.data.response) ? message.data.response : null;
|
||||
const completionOutput = response && Array.isArray(response.output) ? response.output : [];
|
||||
const output = completionOutput.length > 0 ? completionOutput : streamed;
|
||||
const parsed = parseResponsesOutput({ output });
|
||||
return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) };
|
||||
}
|
||||
}
|
||||
if (streamed.length > 0) {
|
||||
const parsed = parseResponsesOutput({ output: streamed });
|
||||
return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) };
|
||||
}
|
||||
throw new Error("Responses stream ended before a completion event.");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ToolTurnFn } from "../provider-tool-turn";
|
||||
import {
|
||||
makeChatCompletionsTurn,
|
||||
runAnthropicTurn,
|
||||
runAntigravityTurn,
|
||||
runAzureTurn,
|
||||
runBedrockTurn,
|
||||
runClaudeProTurn,
|
||||
runCodexTurn,
|
||||
runCopilotTurn,
|
||||
runCustomAnthropicTurn,
|
||||
runCustomOpenAiTurn,
|
||||
runOpenAiResponsesTurn,
|
||||
runVertexTurn,
|
||||
} from "./turns";
|
||||
|
||||
/** Provider key -> the tool-use turn function that drives the agent loop. */
|
||||
const TURNS: Record<string, ToolTurnFn> = {
|
||||
openai: runOpenAiResponsesTurn,
|
||||
anthropic: runAnthropicTurn,
|
||||
xai: makeChatCompletionsTurn("https://api.x.ai/v1/chat/completions", "xAI"),
|
||||
groq: makeChatCompletionsTurn("https://api.groq.com/openai/v1/chat/completions", "Groq"),
|
||||
mistral: makeChatCompletionsTurn("https://api.mistral.ai/v1/chat/completions", "Mistral"),
|
||||
cerebras: makeChatCompletionsTurn("https://api.cerebras.ai/v1/chat/completions", "Cerebras"),
|
||||
openrouter: makeChatCompletionsTurn("https://openrouter.ai/api/v1/chat/completions", "OpenRouter"),
|
||||
"gpt-account": runCodexTurn,
|
||||
"github-copilot": runCopilotTurn,
|
||||
"google-antigravity": runAntigravityTurn,
|
||||
"claude-pro": runClaudeProTurn,
|
||||
"custom-openai": runCustomOpenAiTurn,
|
||||
"custom-anthropic": runCustomAnthropicTurn,
|
||||
"azure-openai": runAzureTurn,
|
||||
"aws-bedrock": runBedrockTurn,
|
||||
"vertex-ai": runVertexTurn,
|
||||
};
|
||||
|
||||
export function supportsToolTurn(providerKey: string): boolean {
|
||||
return providerKey in TURNS;
|
||||
}
|
||||
|
||||
export function selectToolTurn(providerKey: string): ToolTurnFn {
|
||||
const turn = TURNS[providerKey];
|
||||
if (!turn) throw new Error(`${providerKey} does not support agent execution.`);
|
||||
return turn;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Per-backend tool-use turn functions. Each takes the normalized
|
||||
* {@link ProviderToolTurnInput} (with a resolved credential) and returns an
|
||||
* {@link AgentTurnResult}. Faithful ports of Pi's provider request handlers.
|
||||
*/
|
||||
import { release as osRelease, arch as osArch, platform as osPlatform } from "node:os";
|
||||
import { postEventStream, postJson, type JsonRecord } from "../provider-http";
|
||||
import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCredential } from "../provider-tool-turn";
|
||||
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format";
|
||||
import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format";
|
||||
import { chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format";
|
||||
import { accumulateGeminiStream, antigravityRequestBody, parseGeminiResponse, vertexRequestBody } from "./gemini-format";
|
||||
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedrock-format";
|
||||
import { signAwsRequest } from "./aws-sigv4";
|
||||
import { getGcpAccessToken } from "./gcp-auth";
|
||||
import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
|
||||
|
||||
const ANTIGRAVITY_VERSION = "1.21.9";
|
||||
const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
function requireApiKey(credential: TurnCredential, label: string): string {
|
||||
if (!credential.apiKey) throw new Error(`${label} requires an API key.`);
|
||||
return credential.apiKey;
|
||||
}
|
||||
|
||||
function requireAccessToken(credential: TurnCredential, label: string): string {
|
||||
if (!credential.accessToken) throw new Error(`${label} requires a signed-in access token.`);
|
||||
return credential.accessToken;
|
||||
}
|
||||
|
||||
function lastMessageIsUser(messages: AgentTurnMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
return last?.role === "user";
|
||||
}
|
||||
|
||||
// ---- OpenAI Responses (api key) ----
|
||||
|
||||
export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
input: toResponsesInput(input.messages),
|
||||
tools: responsesToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
store: false,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
};
|
||||
const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${requireApiKey(input.credential, "OpenAI")}` }, body);
|
||||
const parsed = parseResponsesOutput(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- OpenAI Chat Completions (xAI, Groq, Mistral, Cerebras, OpenRouter) ----
|
||||
|
||||
export function makeChatCompletionsTurn(endpoint: string, label: string): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
|
||||
return async (input) => {
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
messages: toChatMessages(input.instructions, input.messages),
|
||||
tools: chatToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
stream: false,
|
||||
};
|
||||
const response = await postJson(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, body);
|
||||
const parsed = parseChatChoice(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Anthropic Messages (api key) ----
|
||||
|
||||
export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
max_tokens: 4096,
|
||||
tools: anthropicToolDefs(input.tools),
|
||||
messages: toAnthropicMessages(input.messages),
|
||||
...(input.instructions ? { system: input.instructions } : {}),
|
||||
};
|
||||
const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": requireApiKey(input.credential, "Anthropic"), "anthropic-version": "2023-06-01" }, body);
|
||||
const parsed = parseAnthropicContent(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Claude Pro/Max (OAuth subscription) ----
|
||||
|
||||
export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const system: unknown[] = [
|
||||
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
||||
...(input.instructions ? [{ type: "text", text: input.instructions }] : []),
|
||||
];
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
max_tokens: 4096,
|
||||
tools: anthropicToolDefs(input.tools),
|
||||
messages: toAnthropicMessages(input.messages),
|
||||
system,
|
||||
};
|
||||
const response = await postJson("https://api.anthropic.com/v1/messages", {
|
||||
authorization: `Bearer ${requireAccessToken(input.credential, "Claude Pro")}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"user-agent": "claude-cli/2.1.75",
|
||||
"x-app": "cli",
|
||||
}, body);
|
||||
const parsed = parseAnthropicContent(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- GPT account / OpenAI Codex (OAuth, SSE) ----
|
||||
|
||||
export async function runCodexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const access = requireAccessToken(input.credential, "GPT account");
|
||||
if (!input.credential.accountId) throw new Error("GPT account credential is missing its account id.");
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
store: false,
|
||||
stream: true,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
input: toResponsesInput(input.messages),
|
||||
text: { verbosity: "medium" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
tools: responsesToolDefs(input.tools, null),
|
||||
};
|
||||
const response = await postEventStream(`${CODEX_BASE_URL}/codex/responses`, {
|
||||
Authorization: `Bearer ${access}`,
|
||||
"chatgpt-account-id": input.credential.accountId,
|
||||
originator: "agentcompany",
|
||||
"User-Agent": `agentcompany (${osPlatform()} ${osRelease()}; ${osArch()})`,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
}, body);
|
||||
return accumulateResponsesStream(response.messages);
|
||||
}
|
||||
|
||||
// ---- GitHub Copilot (OAuth, openai-responses at the proxy) ----
|
||||
|
||||
export async function runCopilotTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const access = requireAccessToken(input.credential, "GitHub Copilot");
|
||||
const baseUrl = copilotBaseUrl(access, input.credential.enterpriseUrl ?? undefined);
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
input: toResponsesInput(input.messages),
|
||||
tools: responsesToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
store: false,
|
||||
};
|
||||
const response = await postJson(`${baseUrl}/responses`, {
|
||||
Authorization: `Bearer ${access}`,
|
||||
...COPILOT_HEADERS,
|
||||
"X-Initiator": lastMessageIsUser(input.messages) ? "user" : "agent",
|
||||
"Openai-Intent": "conversation-edits",
|
||||
}, body);
|
||||
const parsed = parseResponsesOutput(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Custom OpenAI-compatible endpoint (base URL + key) ----
|
||||
|
||||
export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
if (!input.credential.baseUrl) throw new Error("Custom OpenAI-compatible provider is missing its base URL.");
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
messages: toChatMessages(input.instructions, input.messages),
|
||||
tools: chatToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
stream: false,
|
||||
};
|
||||
const response = await postJson(`${input.credential.baseUrl}/chat/completions`, { authorization: `Bearer ${requireApiKey(input.credential, "Custom OpenAI-compatible provider")}` }, body);
|
||||
const parsed = parseChatChoice(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Custom Anthropic-compatible endpoint (base URL + key) ----
|
||||
|
||||
export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
if (!input.credential.baseUrl) throw new Error("Custom Anthropic-compatible provider is missing its base URL.");
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
max_tokens: 4096,
|
||||
tools: anthropicToolDefs(input.tools),
|
||||
messages: toAnthropicMessages(input.messages),
|
||||
...(input.instructions ? { system: input.instructions } : {}),
|
||||
};
|
||||
const response = await postJson(`${input.credential.baseUrl}/v1/messages`, { "x-api-key": requireApiKey(input.credential, "Custom Anthropic-compatible provider"), "anthropic-version": "2023-06-01" }, body);
|
||||
const parsed = parseAnthropicContent(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ----
|
||||
|
||||
export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const access = requireAccessToken(input.credential, "Google Antigravity");
|
||||
if (!input.credential.projectId) throw new Error("Antigravity credential is missing its project id.");
|
||||
const requestId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const body = antigravityRequestBody({
|
||||
model: input.model,
|
||||
projectId: input.credential.projectId,
|
||||
instructions: input.instructions,
|
||||
messages: input.messages,
|
||||
tools: input.tools,
|
||||
requestId,
|
||||
});
|
||||
const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
|
||||
Authorization: `Bearer ${access}`,
|
||||
"User-Agent": `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`,
|
||||
}, body);
|
||||
return accumulateGeminiStream(response.messages);
|
||||
}
|
||||
|
||||
// ---- Azure OpenAI (Chat Completions at the resource deployment) ----
|
||||
|
||||
export async function runAzureTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const apiKey = requireApiKey(input.credential, "Azure OpenAI");
|
||||
if (!input.credential.baseUrl) throw new Error("Azure OpenAI credential is missing its base URL.");
|
||||
if (!input.credential.deployment) throw new Error("Azure OpenAI credential is missing its deployment name.");
|
||||
const body: JsonRecord = {
|
||||
model: input.credential.deployment,
|
||||
messages: toChatMessages(input.instructions, input.messages),
|
||||
tools: chatToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
stream: false,
|
||||
};
|
||||
const url = `${input.credential.baseUrl}/openai/deployments/${input.credential.deployment}/chat/completions?api-version=2024-10-21`;
|
||||
const response = await postJson(url, { "api-key": apiKey }, body);
|
||||
const parsed = parseChatChoice(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- AWS Bedrock (Converse API, SigV4) ----
|
||||
|
||||
export async function runBedrockTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const { region, accessKeyId, secretAccessKey, sessionToken } = input.credential;
|
||||
if (!region || !accessKeyId || !secretAccessKey) throw new Error("AWS Bedrock credential is incomplete.");
|
||||
const converse: JsonRecord = {
|
||||
messages: toBedrockMessages(input.messages),
|
||||
inferenceConfig: { maxTokens: 4096 },
|
||||
toolConfig: bedrockToolConfig(input.tools),
|
||||
...(input.instructions ? { system: [{ text: input.instructions }] } : {}),
|
||||
};
|
||||
const bodyString = JSON.stringify(converse);
|
||||
const signed = signAwsRequest({
|
||||
method: "POST",
|
||||
host: `bedrock-runtime.${region}.amazonaws.com`,
|
||||
rawPath: `/model/${input.model}/converse`,
|
||||
body: bodyString,
|
||||
signer: { region, service: "bedrock", accessKeyId, secretAccessKey, sessionToken },
|
||||
});
|
||||
const response = await postJson(signed.url, signed.headers, converse);
|
||||
const parsed = parseBedrockOutput(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Google Vertex AI (Gemini generateContent, service-account auth) ----
|
||||
|
||||
export async function runVertexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const { projectId, region, serviceAccountJson } = input.credential;
|
||||
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
|
||||
const token = await getGcpAccessToken(serviceAccountJson);
|
||||
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
|
||||
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
|
||||
const response = await postJson(url, { authorization: `Bearer ${token}` }, body);
|
||||
const parsed = parseGeminiResponse(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateRollbackSnapshotInput, ResolveMigrationReconciliationInput, SetRollbackSnapshotActiveInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { MigrationCutoverJournalService, type CutoverDeltaEvent } from "./migration-cutover-journal-service";
|
||||
|
||||
interface MigrationReportRow {
|
||||
id: string;
|
||||
source_ref: string;
|
||||
report_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MigrationReportBody {
|
||||
sourceFingerprint: string;
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: string;
|
||||
restoreStrategy: string;
|
||||
sourceFingerprint: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string;
|
||||
repo_url: string;
|
||||
repo_ref: string;
|
||||
is_primary: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface RollbackSnapshotRow {
|
||||
id: string;
|
||||
migration_report_id: string;
|
||||
source_fingerprint: string;
|
||||
delta_journal_json: string;
|
||||
}
|
||||
|
||||
interface MigrationReconciliationItemRow {
|
||||
id: string;
|
||||
migration_report_id: string;
|
||||
rollback_snapshot_id: string | null;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface FileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
storage_path: string;
|
||||
scan_state: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
action: string;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class RollbackService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly cutoverJournal: MigrationCutoverJournalService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.cutoverJournal = new MigrationCutoverJournalService(db);
|
||||
}
|
||||
|
||||
createSnapshot(input: CreateRollbackSnapshotInput): string {
|
||||
this.requireEmployee(input.orgId, input.createdByEmployeeId);
|
||||
const report = this.requireReport(input.orgId, input.migrationReportId);
|
||||
const body = parseMigrationReport(report.report_json);
|
||||
const sourceDatabasePath = realSourcePath(body.rollbackPack.sourceDatabasePath);
|
||||
if (sourceDatabasePath !== realSourcePath(report.source_ref)) throw new Error("Rollback source path does not match the migration report.");
|
||||
if (body.sourceFingerprint !== body.rollbackPack.sourceFingerprint) throw new Error("Rollback pack fingerprint is invalid.");
|
||||
const sourceDatabaseSha256 = fileSha256(sourceDatabasePath);
|
||||
const workspaceManifestJson = JSON.stringify(this.workspaceManifest(input.orgId));
|
||||
const workspaceManifestSha256 = sha256(workspaceManifestJson);
|
||||
const deltaJournalJson = JSON.stringify(this.deltaJournal(input.orgId, report.created_at));
|
||||
const restorePlanJson = JSON.stringify({
|
||||
migrationReportId: report.id,
|
||||
sourceDatabasePath,
|
||||
sourceFingerprint: body.sourceFingerprint,
|
||||
sourceDatabaseSha256,
|
||||
workspaceManifestSha256,
|
||||
restoreStrategy: body.rollbackPack.restoreStrategy,
|
||||
});
|
||||
const id = createId("rbs");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into rollback_snapshots(
|
||||
id, org_id, migration_report_id, source_database_path, source_fingerprint,
|
||||
source_database_sha256, workspace_manifest_json, workspace_manifest_sha256,
|
||||
delta_journal_json, restore_plan_json, active, created_by_employee_id, created_at, activated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, null)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
report.id,
|
||||
sourceDatabasePath,
|
||||
body.sourceFingerprint,
|
||||
sourceDatabaseSha256,
|
||||
workspaceManifestJson,
|
||||
workspaceManifestSha256,
|
||||
deltaJournalJson,
|
||||
restorePlanJson,
|
||||
input.createdByEmployeeId,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "rollback_snapshot",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { migrationReportId: report.id, sourceFingerprint: body.sourceFingerprint },
|
||||
});
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: report.id,
|
||||
rollbackSnapshotId: id,
|
||||
eventType: "rollback_snapshot_created",
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
payload: { sourceFingerprint: body.sourceFingerprint, sourceDatabaseSha256, workspaceManifestSha256 },
|
||||
createdAt: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setActive(input: SetRollbackSnapshotActiveInput): void {
|
||||
this.requireEmployee(input.orgId, input.employeeId);
|
||||
const snapshot = this.db.prepare(`
|
||||
select id, migration_report_id, source_fingerprint, delta_journal_json
|
||||
from rollback_snapshots
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.snapshotId, input.orgId) as RollbackSnapshotRow | undefined;
|
||||
if (!snapshot) throw new Error("Rollback snapshot was not found.");
|
||||
const now = new Date().toISOString();
|
||||
if (input.active) this.db.prepare("update rollback_snapshots set active = 0, activated_at = null where org_id = ?").run(input.orgId);
|
||||
const result = this.db.prepare("update rollback_snapshots set active = ?, activated_at = ? where id = ? and org_id = ?")
|
||||
.run(input.active ? 1 : 0, input.active ? now : null, input.snapshotId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Rollback snapshot was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "rollback_snapshot",
|
||||
entityId: input.snapshotId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "deactivated",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
const queuedReconciliationItems = input.active
|
||||
? this.cutoverJournal.queueReconciliation({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: snapshot.migration_report_id,
|
||||
rollbackSnapshotId: snapshot.id,
|
||||
events: parseDeltaJournal(snapshot.delta_journal_json),
|
||||
createdAt: now,
|
||||
})
|
||||
: 0;
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: snapshot.migration_report_id,
|
||||
rollbackSnapshotId: snapshot.id,
|
||||
eventType: input.active ? "rollback_snapshot_activated" : "rollback_snapshot_deactivated",
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
payload: { active: input.active, sourceFingerprint: snapshot.source_fingerprint, queuedReconciliationItems },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
resolveReconciliationItem(input: ResolveMigrationReconciliationInput): void {
|
||||
this.requireEmployee(input.orgId, input.employeeId);
|
||||
const item = this.db.prepare(`
|
||||
select id, migration_report_id, rollback_snapshot_id, target_type, target_id, state
|
||||
from migration_reconciliation_items
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.reconciliationItemId, input.orgId) as MigrationReconciliationItemRow | undefined;
|
||||
if (!item) throw new Error("Migration reconciliation item was not found.");
|
||||
if (item.state === "resolved") throw new Error("Migration reconciliation item is already resolved.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update migration_reconciliation_items
|
||||
set state = ?, resolved_at = ?, resolved_by_employee_id = ?
|
||||
where id = ? and org_id = ? and state = ?
|
||||
`).run("resolved", now, input.employeeId, item.id, input.orgId, "open");
|
||||
if (result.changes === 0) throw new Error("Migration reconciliation item could not be resolved.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "migration_reconciliation_item",
|
||||
entityId: item.id,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: "resolved",
|
||||
payload: { targetType: item.target_type, targetId: item.target_id, rollbackSnapshotId: item.rollback_snapshot_id },
|
||||
});
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: item.migration_report_id,
|
||||
rollbackSnapshotId: item.rollback_snapshot_id,
|
||||
eventType: "migration_reconciliation_resolved",
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
payload: { reconciliationItemId: item.id, targetType: item.target_type, targetId: item.target_id },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
private requireReport(orgId: string, reportId: string): MigrationReportRow {
|
||||
const report = this.db.prepare("select id, source_ref, report_json, created_at from migration_reports where id = ? and org_id = ?")
|
||||
.get(reportId, orgId) as MigrationReportRow | undefined;
|
||||
if (!report) throw new Error("Migration report was not found.");
|
||||
return report;
|
||||
}
|
||||
|
||||
private requireEmployee(orgId: string, employeeId: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error("Rollback employee was not found.");
|
||||
}
|
||||
|
||||
private workspaceManifest(orgId: string): { workspaces: unknown[]; files: unknown[] } {
|
||||
const workspaces = this.db.prepare(`
|
||||
select id, name, local_path, repo_url, repo_ref, is_primary, updated_at
|
||||
from workspaces
|
||||
where org_id = ?
|
||||
order by id asc
|
||||
`).all(orgId) as unknown as WorkspaceRow[];
|
||||
const files = this.db.prepare(`
|
||||
select id, name, size_bytes, sha256, storage_path, scan_state, updated_at
|
||||
from file_objects
|
||||
where org_id = ?
|
||||
order by id asc
|
||||
`).all(orgId) as unknown as FileRow[];
|
||||
return {
|
||||
workspaces: workspaces.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
localPath: row.local_path,
|
||||
repoUrl: row.repo_url,
|
||||
repoRef: row.repo_ref,
|
||||
isPrimary: row.is_primary === 1,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
files: files.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sizeBytes: row.size_bytes,
|
||||
sha256: row.sha256,
|
||||
storagePath: row.storage_path,
|
||||
scanState: row.scan_state,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private deltaJournal(orgId: string, since: string): unknown[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at
|
||||
from audit_events
|
||||
where org_id = ? and created_at >= ?
|
||||
order by created_at asc, id asc
|
||||
`).all(orgId, since) as unknown as AuditRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
actorType: row.actor_type,
|
||||
actorId: row.actor_id,
|
||||
action: row.action,
|
||||
payload: parseRecord(row.payload_json),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function parseMigrationReport(reportJson: string): MigrationReportBody {
|
||||
const parsed = parseRecord(reportJson);
|
||||
const rollbackPack = parsed.rollbackPack;
|
||||
if (typeof parsed.sourceFingerprint !== "string") throw new Error("Migration report is missing a source fingerprint.");
|
||||
if (typeof rollbackPack !== "object" || rollbackPack === null || Array.isArray(rollbackPack)) throw new Error("Migration report is missing a rollback pack.");
|
||||
const pack = rollbackPack as Record<string, unknown>;
|
||||
if (typeof pack.sourceDatabasePath !== "string" || typeof pack.restoreStrategy !== "string" || typeof pack.sourceFingerprint !== "string") {
|
||||
throw new Error("Migration rollback pack is invalid.");
|
||||
}
|
||||
return {
|
||||
sourceFingerprint: parsed.sourceFingerprint,
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: pack.sourceDatabasePath,
|
||||
restoreStrategy: pack.restoreStrategy,
|
||||
sourceFingerprint: pack.sourceFingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function realSourcePath(path: string): string {
|
||||
if (!existsSync(path)) throw new Error("Rollback source database was not found.");
|
||||
return realpathSync.native(path);
|
||||
}
|
||||
|
||||
function fileSha256(path: string): string {
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
for (;;) {
|
||||
const bytes = readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytes === 0) return hash.digest("hex");
|
||||
hash.update(buffer.subarray(0, bytes));
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function parseRecord(json: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JSON record is invalid.");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseDeltaJournal(json: string): CutoverDeltaEvent[] {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("Rollback delta journal is invalid.");
|
||||
return parsed.map((item) => {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("Rollback delta journal entry is invalid.");
|
||||
const record = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string"
|
||||
|| typeof record.entityType !== "string"
|
||||
|| typeof record.entityId !== "string"
|
||||
|| typeof record.actorType !== "string"
|
||||
|| typeof record.action !== "string"
|
||||
|| typeof record.createdAt !== "string"
|
||||
) {
|
||||
throw new Error("Rollback delta journal entry is invalid.");
|
||||
}
|
||||
const payload = record.payload;
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("Rollback delta journal entry is invalid.");
|
||||
return {
|
||||
id: record.id,
|
||||
entityType: record.entityType,
|
||||
entityId: record.entityId,
|
||||
actorType: record.actorType,
|
||||
actorId: typeof record.actorId === "string" ? record.actorId : null,
|
||||
action: record.action,
|
||||
payload: payload as Record<string, unknown>,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunEvalSuiteInput, RunSdlcReviewInput, SetPrBotGateInput } from "../../shared/commands";
|
||||
import {
|
||||
EVAL_SUITE_KEY,
|
||||
PR_BOT_GATE_KEY,
|
||||
SDLC_REVIEW_AGENT_KEY,
|
||||
SDLC_REVIEW_TARGET_TYPE,
|
||||
SDLC_RUN_STATE,
|
||||
type EvalSuiteKey,
|
||||
type SdlcReviewAgentKey,
|
||||
type SdlcReviewTargetType,
|
||||
} from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { providerAdapters } from "./provider-adapters";
|
||||
|
||||
const REVIEW_AGENTS = [
|
||||
{ key: SDLC_REVIEW_AGENT_KEY.CODE_REVIEWER, label: "Code Review Agent", specialty: "static quality and architecture" },
|
||||
{ key: SDLC_REVIEW_AGENT_KEY.MIGRATION_REVIEWER, label: "Migration Review Agent", specialty: "schema diff and rollback evidence" },
|
||||
{ key: SDLC_REVIEW_AGENT_KEY.POLICY_REVIEWER, label: "Policy Review Agent", specialty: "provider, approval, and access policy" },
|
||||
] as const;
|
||||
|
||||
const EVAL_SUITES = [
|
||||
{ key: EVAL_SUITE_KEY.CONTRACT, label: "Contract checks", scope: "provider adapters, event schemas, slash commands" },
|
||||
{ key: EVAL_SUITE_KEY.INTEGRATION, label: "Integration checks", scope: "chat, approval, file, skill resolution flows" },
|
||||
{ key: EVAL_SUITE_KEY.REPLAY, label: "Replay checks", scope: "audit timeline and historical decision regression" },
|
||||
] as const;
|
||||
|
||||
const EVAL_CASES = [
|
||||
{ suiteKey: EVAL_SUITE_KEY.CONTRACT, title: "Provider catalog has adapters", sourceType: "provider_gateway", sourceRef: "providers", expectedSignal: "adapter_coverage" },
|
||||
{ suiteKey: EVAL_SUITE_KEY.CONTRACT, title: "Schema ledger is current", sourceType: "database", sourceRef: "schema_migrations", expectedSignal: "migration_ledger" },
|
||||
{ suiteKey: EVAL_SUITE_KEY.INTEGRATION, title: "Approval routes are assigned", sourceType: "org_governance", sourceRef: "approval_assignments", expectedSignal: "route_coverage" },
|
||||
{ suiteKey: EVAL_SUITE_KEY.INTEGRATION, title: "Skills can resolve runtime snapshots", sourceType: "skills_platform", sourceRef: "skill_runtime_snapshots", expectedSignal: "skill_resolution" },
|
||||
{ suiteKey: EVAL_SUITE_KEY.REPLAY, title: "Audit timeline has domain events", sourceType: "audit", sourceRef: "audit_events", expectedSignal: "replayable_timeline" },
|
||||
{ suiteKey: EVAL_SUITE_KEY.REPLAY, title: "Operational signals are captured", sourceType: "operations", sourceRef: "operational_events", expectedSignal: "slo_signal" },
|
||||
] as const;
|
||||
|
||||
const PR_BOT_GATES = [
|
||||
{ key: PR_BOT_GATE_KEY.STATIC_CHECKS, label: "Static checks", gateType: "typecheck_lint_test" },
|
||||
{ key: PR_BOT_GATE_KEY.MIGRATION_DIFF, label: "Migration diff", gateType: "schema_review" },
|
||||
{ key: PR_BOT_GATE_KEY.POLICY_DIFF, label: "Policy diff", gateType: "policy_review" },
|
||||
{ key: PR_BOT_GATE_KEY.HUMAN_APPROVAL, label: "Human approval", gateType: "approval_required" },
|
||||
] as const;
|
||||
|
||||
interface ReviewAgentRow {
|
||||
id: string;
|
||||
key: SdlcReviewAgentKey;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
interface EvalSuiteRow {
|
||||
id: string;
|
||||
key: EvalSuiteKey;
|
||||
}
|
||||
|
||||
interface GateCheck {
|
||||
key: string;
|
||||
passed: boolean;
|
||||
value: number | string | boolean;
|
||||
}
|
||||
|
||||
export class SdlcService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
seedOrganizationSdlc(orgId: string, now: string): void {
|
||||
for (const agent of REVIEW_AGENTS) {
|
||||
this.db.prepare(`
|
||||
insert into sdlc_review_agents(id, org_id, key, label, specialty, enabled, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
on conflict(org_id, key) do nothing
|
||||
`).run(createId("sra"), orgId, agent.key, agent.label, agent.specialty, now, now);
|
||||
}
|
||||
for (const suite of EVAL_SUITES) {
|
||||
this.db.prepare(`
|
||||
insert into eval_suites(id, org_id, key, label, scope, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, key) do nothing
|
||||
`).run(createId("evs"), orgId, suite.key, suite.label, suite.scope, now, now);
|
||||
}
|
||||
for (const item of EVAL_CASES) {
|
||||
const suite = this.requireSuiteByKey(orgId, item.suiteKey);
|
||||
this.db.prepare(`
|
||||
insert into eval_cases(id, org_id, suite_id, title, source_type, source_ref, expected_signal, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(suite_id, title) do nothing
|
||||
`).run(createId("evc"), orgId, suite.id, item.title, item.sourceType, item.sourceRef, item.expectedSignal, now);
|
||||
}
|
||||
for (const gate of PR_BOT_GATES) {
|
||||
this.db.prepare(`
|
||||
insert into pr_bot_gates(id, org_id, key, label, gate_type, enabled, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
on conflict(org_id, key) do nothing
|
||||
`).run(createId("pbg"), orgId, gate.key, gate.label, gate.gateType, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
runReview(input: RunSdlcReviewInput): string {
|
||||
const agent = this.db.prepare("select id, key, enabled from sdlc_review_agents where id = ? and org_id = ?")
|
||||
.get(input.reviewAgentId, input.orgId) as ReviewAgentRow | undefined;
|
||||
if (!agent) throw new Error("SDLC review agent was not found.");
|
||||
if (agent.enabled !== 1) throw new Error("SDLC review agent is disabled.");
|
||||
this.assertTarget(input.orgId, input.targetType, input.targetRef);
|
||||
const checks = this.reviewChecks(input.orgId, agent.key, input.targetType);
|
||||
const state = checks.every((check) => check.passed) ? SDLC_RUN_STATE.PASSED : SDLC_RUN_STATE.FAILED;
|
||||
const id = createId("srr");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into sdlc_review_runs(id, org_id, review_agent_id, target_type, target_ref, state, result_json, created_at, completed_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.reviewAgentId, input.targetType, input.targetRef, state, JSON.stringify({ checks }), now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "sdlc_review_run",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { reviewAgentKey: agent.key, state, targetType: input.targetType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
runEvalSuite(input: RunEvalSuiteInput): string {
|
||||
const suite = this.db.prepare("select id, key from eval_suites where id = ? and org_id = ?")
|
||||
.get(input.suiteId, input.orgId) as EvalSuiteRow | undefined;
|
||||
if (!suite) throw new Error("Eval suite was not found.");
|
||||
const checks = this.evalChecks(input.orgId, suite.key);
|
||||
const passedCount = checks.filter((check) => check.passed).length;
|
||||
const failedCount = checks.length - passedCount;
|
||||
const state = failedCount === 0 ? SDLC_RUN_STATE.PASSED : SDLC_RUN_STATE.FAILED;
|
||||
const id = createId("evr");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into eval_runs(id, org_id, suite_id, state, passed_count, failed_count, result_json, created_at, completed_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.suiteId, state, passedCount, failedCount, JSON.stringify({ checks }), now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "eval_run",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { suiteKey: suite.key, state, passedCount, failedCount },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setPrBotGate(input: SetPrBotGateInput): void {
|
||||
const gate = this.db.prepare("select key from pr_bot_gates where id = ? and org_id = ?")
|
||||
.get(input.gateId, input.orgId) as { key: string } | undefined;
|
||||
if (!gate) throw new Error("PR bot gate was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare("update pr_bot_gates set enabled = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(input.enabled ? 1 : 0, now, input.gateId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("PR bot gate was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "pr_bot_gate",
|
||||
entityId: input.gateId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "updated",
|
||||
payload: { key: gate.key, enabled: input.enabled },
|
||||
});
|
||||
}
|
||||
|
||||
private requireSuiteByKey(orgId: string, key: EvalSuiteKey): { id: string } {
|
||||
const suite = this.db.prepare("select id from eval_suites where org_id = ? and key = ?")
|
||||
.get(orgId, key) as { id: string } | undefined;
|
||||
if (!suite) throw new Error("Eval suite is required.");
|
||||
return suite;
|
||||
}
|
||||
|
||||
private assertTarget(orgId: string, type: SdlcReviewTargetType, ref: string): void {
|
||||
if (!Object.values(SDLC_REVIEW_TARGET_TYPE).includes(type)) throw new Error("SDLC review target type is invalid.");
|
||||
const targetRef = ref.trim();
|
||||
if (!targetRef) throw new Error("SDLC review target is required.");
|
||||
if (type === SDLC_REVIEW_TARGET_TYPE.WORKSPACE) {
|
||||
const org = this.db.prepare("select id from organizations where id = ?").get(targetRef);
|
||||
if (!org || targetRef !== orgId) throw new Error("SDLC workspace target is invalid.");
|
||||
return;
|
||||
}
|
||||
if (type === SDLC_REVIEW_TARGET_TYPE.MIGRATION) {
|
||||
const report = this.db.prepare("select id from migration_reports where id = ? and org_id = ?").get(targetRef, orgId);
|
||||
if (!report) throw new Error("SDLC migration target was not found.");
|
||||
return;
|
||||
}
|
||||
const policy = this.db.prepare("select id from policy_rules where id = ? and org_id = ?").get(targetRef, orgId);
|
||||
if (!policy) throw new Error("SDLC policy target was not found.");
|
||||
}
|
||||
|
||||
private reviewChecks(orgId: string, agentKey: SdlcReviewAgentKey, targetType: SdlcReviewTargetType): GateCheck[] {
|
||||
if (agentKey === SDLC_REVIEW_AGENT_KEY.MIGRATION_REVIEWER) return this.migrationReviewChecks(orgId);
|
||||
if (agentKey === SDLC_REVIEW_AGENT_KEY.POLICY_REVIEWER) return this.policyReviewChecks(orgId);
|
||||
return this.codeReviewChecks(orgId, targetType);
|
||||
}
|
||||
|
||||
private codeReviewChecks(orgId: string, targetType: SdlcReviewTargetType): GateCheck[] {
|
||||
const enabledGates = count(this.db, "select count(*) as count from pr_bot_gates where org_id = ? and enabled = 1", orgId);
|
||||
const auditEvents = count(this.db, "select count(*) as count from audit_events where org_id = ?", orgId);
|
||||
return [
|
||||
{ key: "target_type", passed: targetType === SDLC_REVIEW_TARGET_TYPE.WORKSPACE, value: targetType },
|
||||
{ key: "enabled_pr_gates", passed: enabledGates >= 3, value: enabledGates },
|
||||
{ key: "recent_audit_events", passed: auditEvents > 0, value: auditEvents },
|
||||
];
|
||||
}
|
||||
|
||||
private migrationReviewChecks(orgId: string): GateCheck[] {
|
||||
const reports = this.db.prepare("select report_json from migration_reports where org_id = ?").all(orgId) as Array<{ report_json: string }>;
|
||||
const rollbackPacks = reports.filter((report) => hasObjectField(report.report_json, "rollbackPack")).length;
|
||||
const migrationGateEnabled = this.isGateEnabled(orgId, PR_BOT_GATE_KEY.MIGRATION_DIFF);
|
||||
return [
|
||||
{ key: "migration_gate_enabled", passed: migrationGateEnabled, value: migrationGateEnabled },
|
||||
{ key: "rollback_pack_coverage", passed: reports.length === rollbackPacks, value: `${rollbackPacks}/${reports.length}` },
|
||||
];
|
||||
}
|
||||
|
||||
private policyReviewChecks(orgId: string): GateCheck[] {
|
||||
const providerConflicts = count(this.db, "select count(*) as count from providers where org_id = ? and data_policy = 'zero_data_retention' and background_enabled = 1", orgId);
|
||||
const routeRules = count(this.db, "select count(*) as count from approval_routing_rules where org_id = ?", orgId);
|
||||
const policyGateEnabled = this.isGateEnabled(orgId, PR_BOT_GATE_KEY.POLICY_DIFF);
|
||||
return [
|
||||
{ key: "policy_gate_enabled", passed: policyGateEnabled, value: policyGateEnabled },
|
||||
{ key: "provider_policy_conflicts", passed: providerConflicts === 0, value: providerConflicts },
|
||||
{ key: "approval_routes", passed: routeRules > 0, value: routeRules },
|
||||
];
|
||||
}
|
||||
|
||||
private evalChecks(orgId: string, suiteKey: EvalSuiteKey): GateCheck[] {
|
||||
if (suiteKey === EVAL_SUITE_KEY.CONTRACT) return this.contractEvalChecks();
|
||||
if (suiteKey === EVAL_SUITE_KEY.INTEGRATION) return this.integrationEvalChecks(orgId);
|
||||
return this.replayEvalChecks(orgId);
|
||||
}
|
||||
|
||||
private contractEvalChecks(): GateCheck[] {
|
||||
const catalogKeys = PROVIDER_CATALOG.map((provider) => provider.key);
|
||||
const adapterKeys = Object.keys(providerAdapters);
|
||||
const missing = catalogKeys.filter((key) => !adapterKeys.includes(key));
|
||||
const runtimeContractReady = PROVIDER_CATALOG.every((provider) => {
|
||||
const adapter = providerAdapters[provider.key];
|
||||
return typeof adapter.authorize === "function"
|
||||
&& typeof adapter.refresh === "function"
|
||||
&& typeof adapter.revoke === "function"
|
||||
&& typeof adapter.listModels === "function"
|
||||
&& typeof adapter.getRateLimitState === "function"
|
||||
&& typeof adapter.streamResponse === "function"
|
||||
&& typeof adapter.cancelResponse === "function";
|
||||
});
|
||||
const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations");
|
||||
const rateLimitRegistry = count(this.db, `
|
||||
select count(*) as count
|
||||
from sqlite_master
|
||||
where type = 'table' and name = 'provider_rate_limit_observations'
|
||||
`);
|
||||
return [
|
||||
{ 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 >= 31, value: schemaVersion },
|
||||
];
|
||||
}
|
||||
|
||||
private integrationEvalChecks(orgId: string): GateCheck[] {
|
||||
const chatSpaces = count(this.db, "select count(*) as count from chat_spaces where org_id = ?", orgId);
|
||||
const approvalRoutes = count(this.db, "select count(*) as count from approval_routing_rules where org_id = ?", orgId);
|
||||
const skillSnapshots = count(this.db, `
|
||||
select count(*) as count
|
||||
from skill_runtime_snapshots
|
||||
join agents on agents.id = skill_runtime_snapshots.agent_id
|
||||
where agents.org_id = ?
|
||||
`, orgId);
|
||||
return [
|
||||
{ key: "chat_spaces", passed: chatSpaces > 0, value: chatSpaces },
|
||||
{ key: "approval_routes", passed: approvalRoutes > 0, value: approvalRoutes },
|
||||
{ key: "skill_runtime_snapshots", passed: skillSnapshots > 0, value: skillSnapshots },
|
||||
];
|
||||
}
|
||||
|
||||
private replayEvalChecks(orgId: string): GateCheck[] {
|
||||
const auditEvents = count(this.db, "select count(*) as count from audit_events where org_id = ?", orgId);
|
||||
const operationalEvents = count(this.db, "select count(*) as count from operational_events where org_id = ?", orgId);
|
||||
return [
|
||||
{ key: "audit_events", passed: auditEvents > 0, value: auditEvents },
|
||||
{ key: "operational_events", passed: operationalEvents > 0, value: operationalEvents },
|
||||
];
|
||||
}
|
||||
|
||||
private isGateEnabled(orgId: string, key: string): boolean {
|
||||
const row = this.db.prepare("select enabled from pr_bot_gates where org_id = ? and key = ?")
|
||||
.get(orgId, key) as { enabled: number } | undefined;
|
||||
if (!row) throw new Error("PR bot gate is required.");
|
||||
return row.enabled === 1;
|
||||
}
|
||||
}
|
||||
|
||||
function count(db: DatabaseSync, sql: string, ...params: string[]): number {
|
||||
return (db.prepare(sql).get(...params) as { count: number }).count;
|
||||
}
|
||||
|
||||
function hasObjectField(json: string, field: string): boolean {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
return typeof parsed[field] === "object" && parsed[field] !== null;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SearchInput } from "../../shared/commands";
|
||||
import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain";
|
||||
|
||||
interface SearchRow {
|
||||
entity_type: SearchEntityType;
|
||||
entity_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
facet_json: string;
|
||||
}
|
||||
|
||||
interface SearchDoc {
|
||||
entityType: SearchEntityType;
|
||||
entityId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
facets: Record<string, string>;
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
search(input: SearchInput): SearchResultRecord[] {
|
||||
this.assertEmployee(input.orgId, input.employeeId);
|
||||
const query = normalizeQuery(input.query);
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 20, 50));
|
||||
const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]);
|
||||
this.reindexOrg(input.orgId);
|
||||
const rows = query
|
||||
? this.db.prepare(`
|
||||
select entity_type, entity_id, title, body, facet_json
|
||||
from search_documents
|
||||
where search_documents match ? and org_id = ?
|
||||
limit ?
|
||||
`).all(query, input.orgId, limit * 4) as unknown as SearchRow[]
|
||||
: this.db.prepare(`
|
||||
select entity_type, entity_id, title, body, facet_json
|
||||
from search_documents
|
||||
where org_id = ?
|
||||
order by rowid desc
|
||||
limit ?
|
||||
`).all(input.orgId, limit * 4) as unknown as SearchRow[];
|
||||
return rows
|
||||
.filter((row) => entityTypes.has(row.entity_type))
|
||||
.filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id))
|
||||
.slice(0, limit)
|
||||
.map((row) => ({
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
title: row.title,
|
||||
preview: preview(row.body),
|
||||
facets: parseFacets(row.facet_json),
|
||||
}));
|
||||
}
|
||||
|
||||
private reindexOrg(orgId: string): void {
|
||||
this.db.exec("savepoint search_reindex");
|
||||
try {
|
||||
this.db.prepare("delete from search_documents where org_id = ?").run(orgId);
|
||||
const insert = this.db.prepare(`
|
||||
insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const doc of this.documents(orgId)) {
|
||||
insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets));
|
||||
}
|
||||
this.db.exec("release search_reindex");
|
||||
} catch (error) {
|
||||
this.db.exec("rollback to search_reindex");
|
||||
this.db.exec("release search_reindex");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private documents(orgId: string): SearchDoc[] {
|
||||
return [
|
||||
...this.employeeDocs(orgId),
|
||||
...this.agentDocs(orgId),
|
||||
...this.taskDocs(orgId),
|
||||
...this.projectDocs(orgId),
|
||||
...this.goalDocs(orgId),
|
||||
...this.workspaceDocs(orgId),
|
||||
...this.approvalDocs(orgId),
|
||||
...this.chatSpaceDocs(orgId),
|
||||
...this.chatMessageDocs(orgId),
|
||||
...this.fileDocs(orgId),
|
||||
...this.providerDocs(orgId),
|
||||
...this.socialDocs(orgId),
|
||||
...this.scheduledPostDocs(orgId),
|
||||
];
|
||||
}
|
||||
|
||||
private employeeDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, display_name, role from employees where org_id = ?").all(orgId) as Array<{ id: string; display_name: string; role: string }>;
|
||||
return rows.map((row) => doc("employee", row.id, row.display_name, row.role, { type: "People" }));
|
||||
}
|
||||
|
||||
private agentDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ?").all(orgId) as Array<{ id: string; name: string; role: string; department: string; title: string }>;
|
||||
return rows.map((row) => doc("agent", row.id, row.name, [row.role, row.department, row.title].join(" "), { type: "People", department: row.department }));
|
||||
}
|
||||
|
||||
private taskDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string; risk: string }>;
|
||||
return rows.map((row) => doc("task", row.id, row.title, row.description, { type: "Work", status: row.status, risk: row.risk }));
|
||||
}
|
||||
|
||||
private projectDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, name, description, status from projects where org_id = ?").all(orgId) as Array<{ id: string; name: string; description: string; status: string }>;
|
||||
return rows.map((row) => doc("project", row.id, row.name, row.description, { type: "Work", status: row.status }));
|
||||
}
|
||||
|
||||
private goalDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, title, description, status from goals where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string }>;
|
||||
return rows.map((row) => doc("goal", row.id, row.title, row.description, { type: "Work", status: row.status }));
|
||||
}
|
||||
|
||||
private workspaceDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; local_path: string; repo_url: string }>;
|
||||
return rows.map((row) => doc("workspace", row.id, row.name, `${row.local_path} ${row.repo_url}`, { type: "Work" }));
|
||||
}
|
||||
|
||||
private approvalDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ?").all(orgId) as Array<{ id: string; title: string; details: string; state: string; risk: string }>;
|
||||
return rows.map((row) => doc("approval", row.id, row.title, row.details, { type: "Governance", state: row.state, risk: row.risk }));
|
||||
}
|
||||
|
||||
private chatSpaceDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; topic: string; visibility: string }>;
|
||||
return rows.map((row) => doc("chat_space", row.id, row.name, row.topic, { type: "Messages", visibility: row.visibility }));
|
||||
}
|
||||
|
||||
private chatMessageDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; thread_id: string; body_md: string; sender_type: string }>;
|
||||
return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, thread: row.thread_id }));
|
||||
}
|
||||
|
||||
private fileDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, name, kind, sha256, scan_state from file_objects where org_id = ?").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string }>;
|
||||
return rows.map((row) => doc("file", row.id, row.name, `${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state }));
|
||||
}
|
||||
|
||||
private providerDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ?").all(orgId) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>;
|
||||
return rows.map((row) => doc("provider", row.id, row.label, row.provider_key, { type: "Settings", auth: row.auth_state, health: row.health_state }));
|
||||
}
|
||||
|
||||
private socialDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, platform, handle, display_name from social_accounts where org_id = ?").all(orgId) as Array<{ id: string; platform: string; handle: string; display_name: string }>;
|
||||
return rows.map((row) => doc("social_account", row.id, row.display_name, `${row.platform} ${row.handle}`, { type: "Social", platform: row.platform }));
|
||||
}
|
||||
|
||||
private scheduledPostDocs(orgId: string): SearchDoc[] {
|
||||
const rows = this.db.prepare("select id, social_account_id, body_md, state from scheduled_posts where org_id = ?").all(orgId) as Array<{ id: string; social_account_id: string; body_md: string; state: string }>;
|
||||
return rows.map((row) => doc("scheduled_post", row.id, createTitle(row.body_md), row.body_md, { type: "Social", state: row.state, account: row.social_account_id }));
|
||||
}
|
||||
|
||||
private canRead(orgId: string, employeeId: string, entityType: SearchEntityType, entityId: string): boolean {
|
||||
if (entityType === "chat_space") return this.canReadSpace(entityId, employeeId);
|
||||
if (entityType === "chat_message") return this.canReadMessage(orgId, entityId, employeeId);
|
||||
if (entityType === "file") return this.canReadFile(orgId, entityId, employeeId);
|
||||
if (entityType === "social_account") return this.canReadSocialAccount(entityId, employeeId);
|
||||
if (entityType === "scheduled_post") return this.canReadScheduledPost(entityId, employeeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private canReadSpace(spaceId: string, employeeId: string): boolean {
|
||||
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = 'employee' and principal_id = ?")
|
||||
.get(spaceId, employeeId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
private canReadMessage(orgId: string, messageId: string, employeeId: string): boolean {
|
||||
const row = this.db.prepare("select space_id from chat_messages where id = ? and org_id = ?").get(messageId, orgId) as { space_id: string } | undefined;
|
||||
return row ? this.canReadSpace(row.space_id, employeeId) : false;
|
||||
}
|
||||
|
||||
private canReadFile(orgId: string, fileId: string, employeeId: string): boolean {
|
||||
const file = this.db.prepare("select owner_type, owner_id from file_objects where id = ? and org_id = ?").get(fileId, orgId) as { owner_type: string; owner_id: string | null } | undefined;
|
||||
if (!file) return false;
|
||||
if (file.owner_type === "employee" && file.owner_id === employeeId) return true;
|
||||
const workspaceGroupId = `${orgId}:workspace`;
|
||||
const grant = this.db.prepare(`
|
||||
select id from file_access_grants
|
||||
where org_id = ? and file_id = ? and access_level in ('read', 'manage')
|
||||
and ((granted_to_type = 'employee' and granted_to_id = ?) or (granted_to_type = 'org_group' and granted_to_id = ?))
|
||||
limit 1
|
||||
`).get(orgId, fileId, employeeId, workspaceGroupId);
|
||||
return Boolean(grant);
|
||||
}
|
||||
|
||||
private canReadSocialAccount(accountId: string, employeeId: string): boolean {
|
||||
const grant = this.db.prepare("select id from social_account_grants where social_account_id = ? and employee_id = ? limit 1").get(accountId, employeeId);
|
||||
return Boolean(grant);
|
||||
}
|
||||
|
||||
private canReadScheduledPost(postId: string, employeeId: string): boolean {
|
||||
const row = this.db.prepare("select social_account_id from scheduled_posts where id = ?").get(postId) as { social_account_id: string } | undefined;
|
||||
return row ? this.canReadSocialAccount(row.social_account_id, employeeId) : false;
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string): void {
|
||||
const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!row) throw new Error("Search employee was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
function doc(entityType: SearchEntityType, entityId: string, title: string, body: string, facets: Record<string, string>): SearchDoc {
|
||||
return { entityType, entityId, title: title.trim(), body: body.trim(), facets };
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
const terms = query.toLowerCase().match(/[a-z0-9_@.-]+/g) ?? [];
|
||||
return terms.map((term) => `${term.replaceAll('"', "")}*`).join(" AND ");
|
||||
}
|
||||
|
||||
function validEntityTypes(entityTypes: SearchEntityType[]): Set<SearchEntityType> {
|
||||
const allowed = new Set<string>(SEARCH_ENTITY_TYPES);
|
||||
return new Set(entityTypes.filter((type) => allowed.has(type)));
|
||||
}
|
||||
|
||||
function createTitle(value: string): string {
|
||||
const firstLine = value.split(/\r?\n/, 1)[0].trim();
|
||||
if (!firstLine) return "Untitled";
|
||||
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
||||
}
|
||||
|
||||
function preview(value: string): string {
|
||||
const text = value.replace(/\s+/g, " ").trim();
|
||||
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
|
||||
}
|
||||
|
||||
function parseFacets(value: string): Record<string, string> {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Search facet payload is invalid.");
|
||||
return Object.fromEntries(Object.entries(parsed).map(([key, item]) => [key, String(item)]));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { safeStorage } from "electron";
|
||||
|
||||
export class SecretVault {
|
||||
encryptSecret(secret: string): Buffer {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error("OS credential encryption is unavailable.");
|
||||
}
|
||||
return safeStorage.encryptString(secret);
|
||||
}
|
||||
|
||||
decryptSecret(ciphertext: Buffer): string {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error("OS credential encryption is unavailable.");
|
||||
}
|
||||
return safeStorage.decryptString(ciphertext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
||||
|
||||
export interface CopiedSkillFile {
|
||||
relativePath: string;
|
||||
objectKey: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export function copySkillRevision(sourceDir: string, artifactRoot: string, revisionId: string): CopiedSkillFile[] {
|
||||
const sourceRoot = resolve(sourceDir);
|
||||
if (!existsSync(sourceRoot) || !lstatSync(sourceRoot).isDirectory()) {
|
||||
throw new Error("Skill directory was not found.");
|
||||
}
|
||||
const targetRoot = join(artifactRoot, "skills", revisionId);
|
||||
const files = listSkillFiles(sourceRoot);
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
return files.map((relativePath) => {
|
||||
const sourcePath = pathFromRelative(sourceRoot, relativePath);
|
||||
const targetPath = pathFromRelative(targetRoot, relativePath);
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
return {
|
||||
relativePath,
|
||||
objectKey: targetPath,
|
||||
sha256: fileChecksum(targetPath),
|
||||
sizeBytes: statSync(targetPath).size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function recursiveSkillChecksum(sourceDir: string): string {
|
||||
const sourceRoot = resolve(sourceDir);
|
||||
const hash = createHash("sha256");
|
||||
for (const relativePath of listSkillFiles(sourceRoot)) {
|
||||
hash.update(relativePath);
|
||||
hash.update(readFileSync(pathFromRelative(sourceRoot, relativePath)));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export function assertSafeSkillPath(relativePath: string): string {
|
||||
const clean = relativePath.trim().replaceAll("\\", "/");
|
||||
if (!clean || isAbsolute(clean)) throw new Error(`Invalid skill artifact path: ${relativePath}`);
|
||||
const normalized = normalize(clean).replaceAll("\\", "/");
|
||||
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
|
||||
throw new Error(`Invalid skill artifact path: ${relativePath}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function listSkillFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
walk(root, root, files);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function walk(root: string, currentDir: string, files: string[]): void {
|
||||
for (const name of readdirSync(currentDir).sort()) {
|
||||
if (name.startsWith(".")) continue;
|
||||
const path = join(currentDir, name);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) throw new Error("Skill directories must not contain symbolic links.");
|
||||
if (stat.isDirectory()) {
|
||||
walk(root, path, files);
|
||||
continue;
|
||||
}
|
||||
if (stat.isFile()) files.push(relativePath(root, path));
|
||||
}
|
||||
}
|
||||
|
||||
function relativePath(root: string, path: string): string {
|
||||
return relative(root, path).split(sep).join("/");
|
||||
}
|
||||
|
||||
function pathFromRelative(root: string, relativePath: string): string {
|
||||
const safe = assertSafeSkillPath(relativePath);
|
||||
return join(root, ...safe.split("/"));
|
||||
}
|
||||
|
||||
function fileChecksum(path: string): string {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { RISK_LEVEL, type RiskLevel } from "../../shared/domain";
|
||||
import { assertSafeSkillPath } from "./skill-artifacts";
|
||||
|
||||
export interface SkillManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
entrypoint: string;
|
||||
capabilities: string[];
|
||||
requiredTools: string[];
|
||||
compatibleModels: string[];
|
||||
riskLevel: RiskLevel;
|
||||
tests: string[];
|
||||
inputs: unknown;
|
||||
outputs: unknown;
|
||||
}
|
||||
|
||||
export function loadSkillManifest(directoryPath: string, requestedVersion: string): SkillManifest {
|
||||
const manifestPath = join(directoryPath, "skill.manifest.json");
|
||||
if (!existsSync(manifestPath)) throw new Error("skill.manifest.json is required.");
|
||||
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
||||
const manifest = normalizeManifest(raw);
|
||||
if (manifest.version !== requestedVersion) {
|
||||
throw new Error("Skill manifest version must match the requested version.");
|
||||
}
|
||||
verifyDeclaredFile(directoryPath, manifest.entrypoint);
|
||||
for (const testPath of manifest.tests) verifyDeclaredFile(directoryPath, testPath);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function normalizeManifest(raw: Record<string, unknown>): SkillManifest {
|
||||
const manifest: SkillManifest = {
|
||||
name: readString(raw.name, "name"),
|
||||
version: readSemver(raw.version),
|
||||
entrypoint: assertSafeSkillPath(readString(raw.entrypoint, "entrypoint")),
|
||||
capabilities: readStringArray(raw.capabilities, "capabilities"),
|
||||
requiredTools: readStringArray(raw.required_tools ?? raw.requiredTools, "required_tools"),
|
||||
compatibleModels: readStringArray(raw.compatible_models ?? raw.compatibleModels, "compatible_models"),
|
||||
riskLevel: readRisk(raw.risk_level ?? raw.riskLevel),
|
||||
tests: readStringArray(raw.tests, "tests").map(assertSafeSkillPath),
|
||||
inputs: raw.inputs ?? {},
|
||||
outputs: raw.outputs ?? {},
|
||||
};
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function readString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`Skill manifest field ${field} is required.`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function readSemver(value: unknown): string {
|
||||
const version = readString(value, "version");
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error("Skill manifest version must be valid semver.");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) throw new Error(`Skill manifest field ${field} must be an array.`);
|
||||
return value.map((item) => readString(item, field));
|
||||
}
|
||||
|
||||
function readRisk(value: unknown): RiskLevel {
|
||||
if (value === RISK_LEVEL.LOW || value === RISK_LEVEL.MEDIUM || value === RISK_LEVEL.HIGH) return value;
|
||||
throw new Error("Skill manifest risk_level must be low, medium, or high.");
|
||||
}
|
||||
|
||||
function verifyDeclaredFile(directoryPath: string, relativePath: string): void {
|
||||
const path = join(directoryPath, ...relativePath.split("/"));
|
||||
if (!existsSync(path) || !lstatSync(path).isFile()) {
|
||||
throw new Error(`Skill manifest file was not found: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { normalize } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { BindSkillInput, ImportSkillDirectoryInput, ResolveAgentSkillsInput, RunSkillTestsInput } from "../../shared/commands";
|
||||
import { SLO_SIGNAL_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { copySkillRevision, recursiveSkillChecksum } from "./skill-artifacts";
|
||||
import { loadSkillManifest, type SkillManifest } from "./skill-manifest";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export class SkillService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly operations: OperationsService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync, private readonly artifactRoot: string) {
|
||||
this.audit = new AuditService(db);
|
||||
this.operations = new OperationsService(db);
|
||||
}
|
||||
|
||||
importDirectory(input: ImportSkillDirectoryInput): string {
|
||||
const manifest = loadSkillManifest(input.directoryPath, input.versionSemver);
|
||||
const slug = slugify(manifest.name);
|
||||
if (!slug) throw new Error("Skill name could not be derived.");
|
||||
const now = new Date().toISOString();
|
||||
const skillId = createId("skl");
|
||||
const revisionId = createId("skr");
|
||||
const checksum = recursiveSkillChecksum(input.directoryPath);
|
||||
const copiedFiles = copySkillRevision(input.directoryPath, this.artifactRoot, revisionId);
|
||||
const copiedEntrypoint = copiedFiles.find((file) => file.relativePath === manifest.entrypoint);
|
||||
if (!copiedEntrypoint) throw new Error("Skill manifest entrypoint was not found.");
|
||||
|
||||
this.db.prepare(`
|
||||
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, slug) do update set name = excluded.name, status = 'active'
|
||||
`).run(skillId, input.orgId, slug, manifest.name, "organization", "active", now);
|
||||
|
||||
const existing = this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
|
||||
.get(input.orgId, slug) as { id: string };
|
||||
|
||||
this.db.prepare(`
|
||||
insert into skill_revisions(
|
||||
id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
revisionId,
|
||||
existing.id,
|
||||
manifest.version,
|
||||
JSON.stringify(manifest),
|
||||
"local_directory",
|
||||
input.directoryPath,
|
||||
now,
|
||||
checksum,
|
||||
);
|
||||
for (const file of copiedFiles) {
|
||||
this.db.prepare(`
|
||||
insert into skill_artifacts(id, revision_id, artifact_type, object_key, sha256, size_bytes)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("ska"), revisionId, artifactType(file.relativePath, manifest), file.objectKey, file.sha256, file.sizeBytes);
|
||||
}
|
||||
for (const testPath of manifest.tests) {
|
||||
this.db.prepare(`
|
||||
insert into skill_tests(id, revision_id, test_type, expected_output_ref, last_result, last_run_at)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("skt"), revisionId, "manifest", testPath, null, null);
|
||||
}
|
||||
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_revision",
|
||||
entityId: revisionId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "published",
|
||||
payload: { slug, version: manifest.version },
|
||||
});
|
||||
return revisionId;
|
||||
}
|
||||
|
||||
bind(input: BindSkillInput): string {
|
||||
this.verifyBindingTargets(input);
|
||||
const existing = this.db.prepare("select id from skill_bindings where agent_id = ? and skill_id = ?")
|
||||
.get(input.agentId, input.skillId) as { id: string } | undefined;
|
||||
const id = existing?.id ?? createId("skb");
|
||||
if (existing) {
|
||||
this.db.prepare(`
|
||||
update skill_bindings
|
||||
set pinned_revision_id = ?, binding_mode = ?, priority = ?, enabled = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.revisionId, "pinned", 100, 1, id, input.orgId);
|
||||
} else {
|
||||
this.db.prepare(`
|
||||
insert into skill_bindings(id, org_id, agent_id, skill_id, pinned_revision_id, binding_mode, priority, enabled)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.agentId, input.skillId, input.revisionId, "pinned", 100, 1);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_binding",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "bound",
|
||||
payload: { agentId: input.agentId, skillId: input.skillId, revisionId: input.revisionId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?")
|
||||
.get(input.agentId, input.orgId) as { id: string } | undefined;
|
||||
if (!agent) throw new Error("Agent was not found.");
|
||||
const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?")
|
||||
.get(input.agentId, input.runId) as { id: string } | undefined;
|
||||
if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run.");
|
||||
const rows = this.db.prepare(`
|
||||
select
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_bindings sb
|
||||
join skill_revisions sr on sr.id = sb.pinned_revision_id
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1
|
||||
order by sb.priority asc, sr.published_at asc
|
||||
`).all(input.orgId, input.agentId) as unknown as RuntimeSkillRow[];
|
||||
if (rows.length === 0) throw new Error("Agent has no enabled skills.");
|
||||
|
||||
const compiledParts: string[] = [];
|
||||
const tools = new Set<string>();
|
||||
const revisionIds: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!existsSync(row.object_key)) throw new Error(`Skill artifact is missing: ${row.object_key}`);
|
||||
const manifest = JSON.parse(row.manifest_json) as SkillManifest;
|
||||
for (const tool of manifest.requiredTools) tools.add(tool);
|
||||
revisionIds.push(row.revision_id);
|
||||
compiledParts.push([
|
||||
`# Skill: ${manifest.name}`,
|
||||
`Version: ${row.version_semver}`,
|
||||
readFileSync(row.object_key, "utf8").trim(),
|
||||
].join("\n\n"));
|
||||
}
|
||||
|
||||
const snapshotId = createId("srs");
|
||||
this.db.prepare(`
|
||||
insert into skill_runtime_snapshots(
|
||||
id, agent_id, run_id, compiled_prompt, tools_manifest, source_revision_ids, created_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
snapshotId,
|
||||
input.agentId,
|
||||
input.runId,
|
||||
compiledParts.join("\n\n---\n\n"),
|
||||
JSON.stringify({ requiredTools: [...tools].sort() }),
|
||||
JSON.stringify(revisionIds),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_runtime_snapshot",
|
||||
entityId: snapshotId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "resolved",
|
||||
payload: { agentId: input.agentId, runId: input.runId, revisionIds },
|
||||
});
|
||||
this.operations.recordSignal({
|
||||
orgId: input.orgId,
|
||||
signalKey: SLO_SIGNAL_KEY.SKILL_RESOLVE_SUCCESS,
|
||||
source: "skill_resolver",
|
||||
success: true,
|
||||
durationMs: null,
|
||||
detail: { agentId: input.agentId, runId: input.runId, revisionIds },
|
||||
});
|
||||
return snapshotId;
|
||||
}
|
||||
|
||||
runTests(input: RunSkillTestsInput): string {
|
||||
const revision = this.db.prepare(`
|
||||
select sr.id, sr.version_semver, sd.slug
|
||||
from skill_revisions sr
|
||||
join skill_definitions sd on sd.id = sr.skill_id
|
||||
where sr.id = ? and sd.org_id = ?
|
||||
`).get(input.revisionId, input.orgId) as SkillRevisionTestRow | undefined;
|
||||
if (!revision) throw new Error("Skill revision was not found.");
|
||||
|
||||
const tests = this.db.prepare(`
|
||||
select id, test_type, expected_output_ref
|
||||
from skill_tests
|
||||
where revision_id = ?
|
||||
order by expected_output_ref asc
|
||||
`).all(input.revisionId) as unknown as SkillTestRow[];
|
||||
const artifacts = this.db.prepare(`
|
||||
select artifact_type, object_key, sha256, size_bytes
|
||||
from skill_artifacts
|
||||
where revision_id = ?
|
||||
order by artifact_type asc, object_key asc
|
||||
`).all(input.revisionId) as unknown as SkillArtifactRow[];
|
||||
const structureFailure = verifyRevisionStructure(artifacts);
|
||||
if (structureFailure && tests.length === 0) throw new Error(structureFailure.failure ?? "Skill revision structure check failed.");
|
||||
const now = new Date().toISOString();
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const result = structureFailure ?? runManifestArtifactTest(test, artifacts);
|
||||
if (result.status === "passed") passed += 1;
|
||||
if (result.status === "failed") failed += 1;
|
||||
this.db.prepare("update skill_tests set last_result = ?, last_run_at = ? where id = ?")
|
||||
.run(JSON.stringify(result), now, test.id);
|
||||
}
|
||||
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_revision",
|
||||
entityId: revision.id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "tests_run",
|
||||
payload: { slug: revision.slug, version: revision.version_semver, total: tests.length, passed, failed },
|
||||
});
|
||||
return revision.id;
|
||||
}
|
||||
|
||||
private verifyBindingTargets(input: BindSkillInput): void {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?")
|
||||
.get(input.agentId, input.orgId) as { id: string } | undefined;
|
||||
if (!agent) throw new Error("Agent was not found.");
|
||||
const skill = this.db.prepare("select id from skill_definitions where id = ? and org_id = ?")
|
||||
.get(input.skillId, input.orgId) as { id: string } | undefined;
|
||||
if (!skill) throw new Error("Skill was not found.");
|
||||
const revision = this.db.prepare("select id from skill_revisions where id = ? and skill_id = ?")
|
||||
.get(input.revisionId, input.skillId) as { id: string } | undefined;
|
||||
if (!revision) throw new Error("Skill revision was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
function artifactType(relativePath: string, manifest: SkillManifest): string {
|
||||
if (relativePath === "skill.manifest.json") return "manifest";
|
||||
if (relativePath === manifest.entrypoint) return "entrypoint";
|
||||
if (manifest.tests.includes(relativePath)) return "test";
|
||||
return "asset";
|
||||
}
|
||||
|
||||
interface RuntimeSkillRow {
|
||||
revision_id: string;
|
||||
version_semver: string;
|
||||
manifest_json: string;
|
||||
object_key: string;
|
||||
}
|
||||
|
||||
interface SkillRevisionTestRow {
|
||||
id: string;
|
||||
version_semver: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface SkillTestRow {
|
||||
id: string;
|
||||
test_type: string;
|
||||
expected_output_ref: string;
|
||||
}
|
||||
|
||||
interface SkillArtifactRow {
|
||||
artifact_type: string;
|
||||
object_key: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
interface SkillTestResult {
|
||||
status: "passed" | "failed";
|
||||
testType: string;
|
||||
expectedOutputRef: string;
|
||||
checks: Array<{ name: string; status: "passed" | "failed"; detail: string }>;
|
||||
artifactSha256?: string;
|
||||
failure?: string;
|
||||
}
|
||||
|
||||
function verifyRevisionStructure(artifacts: SkillArtifactRow[]): SkillTestResult | null {
|
||||
const requiredTypes = ["manifest", "entrypoint"];
|
||||
for (const type of requiredTypes) {
|
||||
if (!artifacts.some((artifact) => artifact.artifact_type === type)) {
|
||||
return failedSkillTest("revision_structure", type, `Skill revision artifact is missing: ${type}`);
|
||||
}
|
||||
}
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
verifyArtifactIntegrity(artifact);
|
||||
} catch (error) {
|
||||
return failedSkillTest("artifact_integrity", artifact.object_key, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runManifestArtifactTest(test: SkillTestRow, artifacts: SkillArtifactRow[]): SkillTestResult {
|
||||
if (test.test_type !== "manifest") {
|
||||
return failedSkillTest(test.test_type, test.expected_output_ref, `Unsupported skill test type: ${test.test_type}`);
|
||||
}
|
||||
const testArtifact = artifacts.find((artifact) => (
|
||||
artifact.artifact_type === "test" && artifactMatchesExpectedRef(artifact.object_key, test.expected_output_ref)
|
||||
));
|
||||
if (!testArtifact) return failedSkillTest(test.test_type, test.expected_output_ref, "Skill test artifact was not found.");
|
||||
|
||||
try {
|
||||
const content = verifyArtifactIntegrity(testArtifact).toString("utf8");
|
||||
if (!content.trim()) throw new Error("Skill test artifact is empty.");
|
||||
return {
|
||||
status: "passed",
|
||||
testType: test.test_type,
|
||||
expectedOutputRef: test.expected_output_ref,
|
||||
artifactSha256: testArtifact.sha256,
|
||||
checks: [
|
||||
{ name: "manifest_test_artifact", status: "passed", detail: "Artifact exists, checksum matches, and content is non-empty." },
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return failedSkillTest(test.test_type, test.expected_output_ref, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function verifyArtifactIntegrity(artifact: SkillArtifactRow): Buffer {
|
||||
if (!existsSync(artifact.object_key)) throw new Error(`Skill artifact is missing: ${artifact.object_key}`);
|
||||
const body = readFileSync(artifact.object_key);
|
||||
const sha256 = createHash("sha256").update(body).digest("hex");
|
||||
if (sha256 !== artifact.sha256) throw new Error(`Skill artifact checksum mismatch: ${artifact.object_key}`);
|
||||
if (body.byteLength !== artifact.size_bytes) throw new Error(`Skill artifact size mismatch: ${artifact.object_key}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function artifactMatchesExpectedRef(objectKey: string, expectedRef: string): boolean {
|
||||
const expected = normalizeSkillPath(expectedRef);
|
||||
const actual = normalizeSkillPath(objectKey);
|
||||
return actual === expected || actual.endsWith(`/${expected}`);
|
||||
}
|
||||
|
||||
function normalizeSkillPath(value: string): string {
|
||||
return normalize(value).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function failedSkillTest(testType: string, expectedOutputRef: string, failure: string): SkillTestResult {
|
||||
return {
|
||||
status: "failed",
|
||||
testType,
|
||||
expectedOutputRef,
|
||||
checks: [{ name: "skill_test", status: "failed", detail: failure }],
|
||||
failure,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import type { AppSnapshot } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type ProviderUsageWindowRow = Pick<
|
||||
AppSnapshot["providerRateLimits"][number],
|
||||
"orgId" | "providerId" | "windowStart" | "windowEnd" | "requestCount" | "inputCharCount" | "outputCharCount"
|
||||
>;
|
||||
|
||||
function all<T>(statement: StatementSync): T[] {
|
||||
return statement.all() as T[];
|
||||
}
|
||||
|
||||
function toBool(value: unknown): boolean {
|
||||
return value === 1 || value === true;
|
||||
}
|
||||
|
||||
function camel(row: Row): Row {
|
||||
const next: Row = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
next[key.replace(/_([a-z])/g, (_m, ch: string) => ch.toUpperCase())] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function list<T>(db: DatabaseSync, table: string, orderBy: string): T[] {
|
||||
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy}`)).map((row) => camel(row) as T);
|
||||
}
|
||||
|
||||
function recentList<T>(db: DatabaseSync, table: string, orderBy: string, limit: number): T[] {
|
||||
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy} limit ${limit}`)).map((row) => camel(row) as T);
|
||||
}
|
||||
|
||||
export class SnapshotService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
load(): AppSnapshot {
|
||||
const organizations = list<AppSnapshot["organizations"][number]>(this.db, "organizations", "created_at asc")
|
||||
.map((org) => ({
|
||||
...org,
|
||||
autoApproveHires: toBool(org.autoApproveHires),
|
||||
reviewDeliverables: toBool(org.reviewDeliverables),
|
||||
}));
|
||||
const activeOrgId = this.resolveActiveOrgId(organizations.map((org) => org.id));
|
||||
const agents = list<AppSnapshot["agents"][number]>(this.db, "agents", "created_at asc")
|
||||
.map((agent) => ({ ...agent, heartbeatEnabled: toBool(agent.heartbeatEnabled) }));
|
||||
const tasks = list<AppSnapshot["tasks"][number]>(this.db, "tasks", "created_at asc")
|
||||
.map((task) => ({ ...task, requiresFilesystem: toBool(task.requiresFilesystem) }));
|
||||
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), backgroundEnabled: toBool(provider.backgroundEnabled) }));
|
||||
const providerAuthProfiles = list<AppSnapshot["providerAuthProfiles"][number]>(this.db, "provider_auth_profiles", "created_at desc")
|
||||
.map((profile) => ({ ...profile, active: toBool(profile.active) }));
|
||||
const providerAuthSessions = list<AppSnapshot["providerAuthSessions"][number]>(this.db, "provider_auth_sessions", "created_at desc");
|
||||
const providerRateLimitObservations = list<AppSnapshot["providerRateLimitObservations"][number]>(
|
||||
this.db,
|
||||
"provider_rate_limit_observations",
|
||||
"observed_at desc",
|
||||
);
|
||||
const providerUsageWindows = list<ProviderUsageWindowRow>(this.db, "provider_usage_windows", "window_start desc");
|
||||
const providerRateLimits = buildProviderRateLimits(providers, providerUsageWindows);
|
||||
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")
|
||||
.map((file) => ({ ...file, pinned: toBool(file.pinned) }));
|
||||
const legalHolds = list<AppSnapshot["legalHolds"][number]>(this.db, "legal_holds", "created_at desc")
|
||||
.map((hold) => ({ ...hold, active: toBool(hold.active) }));
|
||||
const retentionFreezes = list<AppSnapshot["retentionFreezes"][number]>(this.db, "retention_freezes", "created_at desc")
|
||||
.map((freeze) => ({ ...freeze, active: toBool(freeze.active) }));
|
||||
const employeeIdentities = list<AppSnapshot["employeeIdentities"][number]>(this.db, "employee_identities", "created_at desc")
|
||||
.map((identity) => ({ ...identity, active: toBool(identity.active) }));
|
||||
const featureFlags = list<AppSnapshot["featureFlags"][number]>(this.db, "feature_flags", "key asc")
|
||||
.map((flag) => ({ ...flag, enabled: toBool(flag.enabled) }));
|
||||
const operationalEvents = list<AppSnapshot["operationalEvents"][number]>(this.db, "operational_events", "created_at desc")
|
||||
.map((event) => ({ ...event, success: toBool(event.success) }));
|
||||
const chaosDrills = list<AppSnapshot["chaosDrills"][number]>(this.db, "chaos_drills", "key asc")
|
||||
.map((drill) => ({ ...drill, enabled: toBool(drill.enabled) }));
|
||||
const sdlcReviewAgents = list<AppSnapshot["sdlcReviewAgents"][number]>(this.db, "sdlc_review_agents", "key asc")
|
||||
.map((agent) => ({ ...agent, enabled: toBool(agent.enabled) }));
|
||||
const prBotGates = list<AppSnapshot["prBotGates"][number]>(this.db, "pr_bot_gates", "key asc")
|
||||
.map((gate) => ({ ...gate, enabled: toBool(gate.enabled) }));
|
||||
const rollbackSnapshots = list<AppSnapshot["rollbackSnapshots"][number]>(this.db, "rollback_snapshots", "created_at desc")
|
||||
.map((snapshot) => ({ ...snapshot, active: toBool(snapshot.active) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
organizations,
|
||||
employees: list(this.db, "employees", "created_at asc"),
|
||||
agents,
|
||||
tasks,
|
||||
workspaces,
|
||||
goals: list(this.db, "goals", "created_at asc"),
|
||||
projects: list(this.db, "projects", "created_at asc"),
|
||||
taskRuns: recentList<AppSnapshot["taskRuns"][number]>(this.db, "task_runs", "created_at desc", 200).reverse(),
|
||||
approvals: list(this.db, "approvals", "created_at desc"),
|
||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
|
||||
chatMessages: recentList<AppSnapshot["chatMessages"][number]>(this.db, "chat_messages", "created_at desc", 500).reverse(),
|
||||
chatThreads: recentList(this.db, "chat_threads", "updated_at desc", 200),
|
||||
chatAttachments: recentList(this.db, "chat_attachments", "message_id desc", 500),
|
||||
chatMentions: recentList(this.db, "chat_mentions", "message_id desc, offset_start asc", 500),
|
||||
chatSlashInvocations: recentList(this.db, "chat_slash_invocations", "created_at desc", 200),
|
||||
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
|
||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||
files,
|
||||
fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"),
|
||||
fileHandoffRecords: list(this.db, "file_handoff_records", "created_at desc"),
|
||||
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
||||
providers,
|
||||
providerAuthProfiles,
|
||||
providerAuthSessions,
|
||||
providerCatalog: PROVIDER_CATALOG,
|
||||
providerRequests: recentList(this.db, "provider_requests", "created_at desc", 100),
|
||||
providerRateLimits,
|
||||
providerRateLimitObservations,
|
||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
||||
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
|
||||
skillBindings,
|
||||
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
|
||||
skillTests: list(this.db, "skill_tests", "last_run_at desc, id asc"),
|
||||
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
||||
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
|
||||
browserSessionContainers: list<AppSnapshot["browserSessionContainers"][number]>(this.db, "browser_session_containers", "created_at asc")
|
||||
.map((container) => ({ ...container, sandboxEnabled: toBool(container.sandboxEnabled) })),
|
||||
browserActionPolicies: list<AppSnapshot["browserActionPolicies"][number]>(this.db, "browser_action_policies", "created_at asc")
|
||||
.map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })),
|
||||
socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"),
|
||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||
socialAuditRecords: list(this.db, "social_audit_records", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
rollbackSnapshots,
|
||||
migrationCutoverEvents: list(this.db, "migration_cutover_events", "created_at desc"),
|
||||
migrationReconciliationItems: list(this.db, "migration_reconciliation_items", "created_at desc"),
|
||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
employeeIdentities,
|
||||
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
|
||||
roleGrants: list(this.db, "role_grants", "created_at desc"),
|
||||
accessDecisions: recentList<AppSnapshot["accessDecisions"][number]>(this.db, "access_decisions", "created_at desc", 200),
|
||||
orgDepartments: list(this.db, "org_departments", "name asc"),
|
||||
hiringPolicies: list(this.db, "hiring_policies", "department_name asc"),
|
||||
approvalRoutingRules: list(this.db, "approval_routing_rules", "created_at asc"),
|
||||
approvalAssignments: list(this.db, "approval_assignments", "created_at desc"),
|
||||
featureFlags,
|
||||
sloTargets: list(this.db, "slo_targets", "signal_key asc"),
|
||||
operationalEvents,
|
||||
chaosDrills,
|
||||
chaosDrillRuns: list(this.db, "chaos_drill_runs", "started_at desc"),
|
||||
sdlcReviewAgents,
|
||||
sdlcReviewRuns: list(this.db, "sdlc_review_runs", "created_at desc"),
|
||||
evalSuites: list(this.db, "eval_suites", "key asc"),
|
||||
evalCases: list(this.db, "eval_cases", "title asc"),
|
||||
evalRuns: list(this.db, "eval_runs", "created_at desc"),
|
||||
prBotGates,
|
||||
auditReplayRuns: list(this.db, "audit_replay_runs", "created_at desc"),
|
||||
auditEvents: recentList<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc", 200),
|
||||
documents: list(this.db, "documents", "updated_at desc"),
|
||||
knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"),
|
||||
automationRules: list<AppSnapshot["automationRules"][number]>(this.db, "automation_rules", "priority asc")
|
||||
.map((rule) => ({ ...rule, enabled: toBool(rule.enabled) })),
|
||||
meetings: list(this.db, "meetings", "scheduled_at desc"),
|
||||
sprints: list(this.db, "sprints", "start_date desc"),
|
||||
composioSettings: list(this.db, "composio_settings", "org_id asc, key asc"),
|
||||
connectorAccounts: list(this.db, "connector_accounts", "toolkit_slug asc"),
|
||||
connectorGrants: list(this.db, "agent_connector_grants", "toolkit_slug asc, created_at asc"),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveActiveOrgId(known: string[]): string | null {
|
||||
const row = this.db.prepare("select active_org_id from app_state where id = 1")
|
||||
.get() as { active_org_id: string | null } | undefined;
|
||||
const stored = row?.active_org_id ?? null;
|
||||
if (stored && known.includes(stored)) return stored;
|
||||
return known[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderRateLimits(
|
||||
providers: AppSnapshot["providers"],
|
||||
providerUsageWindows: ProviderUsageWindowRow[],
|
||||
): AppSnapshot["providerRateLimits"] {
|
||||
const window = currentProviderUsageWindow();
|
||||
return providers.map((provider) => {
|
||||
const usage = providerUsageWindows.find((item) => item.providerId === provider.id && item.windowStart === window.start);
|
||||
const requestCount = usage?.requestCount ?? 0;
|
||||
const inputCharCount = usage?.inputCharCount ?? 0;
|
||||
const outputCharCount = usage?.outputCharCount ?? 0;
|
||||
const remainingRequestCount = Math.max(0, provider.monthlyRequestLimit - requestCount);
|
||||
return {
|
||||
orgId: provider.orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.providerKey,
|
||||
windowStart: usage?.windowStart ?? window.start,
|
||||
windowEnd: usage?.windowEnd ?? window.end,
|
||||
monthlyRequestLimit: provider.monthlyRequestLimit,
|
||||
requestCount,
|
||||
inputCharCount,
|
||||
outputCharCount,
|
||||
remainingRequestCount,
|
||||
exhausted: remainingRequestCount === 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
type SocialAuditMetadata = Record<string, unknown>;
|
||||
|
||||
export interface RecordSocialAuditInput {
|
||||
orgId: string;
|
||||
socialAccountId: string | null;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
eventType: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
policyDecision: string;
|
||||
evidenceFileId?: string | null;
|
||||
metadata: SocialAuditMetadata;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class SocialAuditService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
record(input: RecordSocialAuditInput): string {
|
||||
const id = createId("saud");
|
||||
this.db.prepare(`
|
||||
insert into social_audit_records(
|
||||
id, org_id, social_account_id, source_type, source_id, event_type,
|
||||
actor_type, actor_id, policy_decision, evidence_file_id, metadata_json, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
requireText(input.sourceType, "Social audit source type is required."),
|
||||
input.sourceId,
|
||||
requireText(input.eventType, "Social audit event type is required."),
|
||||
requireText(input.actorType, "Social audit actor type is required."),
|
||||
input.actorId,
|
||||
requireText(input.policyDecision, "Social audit policy decision is required."),
|
||||
input.evidenceFileId ?? null,
|
||||
JSON.stringify(input.metadata),
|
||||
input.createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
function requireText(value: string, message: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error(message);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
export const SOCIAL_BROWSER_ACTION = {
|
||||
LOGIN: "login",
|
||||
DRAFT: "draft",
|
||||
APPROVE: "approve",
|
||||
} as const;
|
||||
|
||||
export type SocialBrowserAction = typeof SOCIAL_BROWSER_ACTION[keyof typeof SOCIAL_BROWSER_ACTION];
|
||||
|
||||
const PLATFORM_HOSTS: Record<SocialPlatform, string[]> = {
|
||||
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
|
||||
};
|
||||
|
||||
const ACTION_POLICIES: ReadonlyArray<{
|
||||
actionType: SocialBrowserAction;
|
||||
contentCategory: string;
|
||||
riskLevel: RiskLevel;
|
||||
approvalRequired: boolean;
|
||||
approvalKind: string | null;
|
||||
}> = [
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.LOGIN, contentCategory: "auth", riskLevel: "low", approvalRequired: false, approvalKind: null },
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.DRAFT, contentCategory: "content", riskLevel: "low", approvalRequired: false, approvalKind: null },
|
||||
{ actionType: SOCIAL_BROWSER_ACTION.APPROVE, contentCategory: "content", riskLevel: "medium", approvalRequired: true, approvalKind: "social_publish" },
|
||||
];
|
||||
|
||||
export interface BrowserSessionContainerRow {
|
||||
id: string;
|
||||
partition_key: string;
|
||||
host_allowlist_json: string;
|
||||
permission_policy: string;
|
||||
sandbox_enabled: number;
|
||||
}
|
||||
|
||||
export interface BrowserActionPolicyRow {
|
||||
id: string;
|
||||
action_type: SocialBrowserAction;
|
||||
risk_level: RiskLevel;
|
||||
approval_required: number;
|
||||
approval_kind: string | null;
|
||||
}
|
||||
|
||||
export interface SocialApprovalRouteRow {
|
||||
id: string;
|
||||
approval_kind: string;
|
||||
route_to_type: string;
|
||||
route_to_id: string | null;
|
||||
}
|
||||
|
||||
export class SocialBrowserPolicyService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
seedAccountControls(input: {
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
platform: SocialPlatform;
|
||||
authorizedEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert or ignore into browser_session_containers(
|
||||
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
|
||||
permission_policy, sandbox_enabled, created_at, last_used_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null)
|
||||
`).run(
|
||||
createId("bsc"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
input.platform,
|
||||
`persist:agentcompany-social-${input.socialAccountId}`,
|
||||
JSON.stringify(PLATFORM_HOSTS[input.platform]),
|
||||
"deny_all",
|
||||
1,
|
||||
input.createdAt,
|
||||
);
|
||||
for (const policy of ACTION_POLICIES) {
|
||||
this.db.prepare(`
|
||||
insert or ignore into browser_action_policies(
|
||||
id, org_id, social_account_id, action_type, content_category, risk_level,
|
||||
approval_required, approval_kind, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("bap"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
policy.actionType,
|
||||
policy.contentCategory,
|
||||
policy.riskLevel,
|
||||
policy.approvalRequired ? 1 : 0,
|
||||
policy.approvalKind,
|
||||
input.createdAt,
|
||||
);
|
||||
}
|
||||
this.db.prepare(`
|
||||
insert or ignore into social_approval_routes(
|
||||
id, org_id, social_account_id, action_type, risk_level, approval_kind,
|
||||
route_to_type, route_to_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("sar"),
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
"medium",
|
||||
"social_publish",
|
||||
"authorized_employee",
|
||||
input.authorizedEmployeeId,
|
||||
input.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
requireSessionContainer(orgId: string, socialAccountId: string): BrowserSessionContainerRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, partition_key, host_allowlist_json, permission_policy, sandbox_enabled
|
||||
from browser_session_containers
|
||||
where org_id = ? and social_account_id = ?
|
||||
`).get(orgId, socialAccountId) as BrowserSessionContainerRow | undefined;
|
||||
if (!row) throw new Error("Browser session container is required.");
|
||||
this.parseHostAllowlist(row.host_allowlist_json);
|
||||
if (row.permission_policy !== "deny_all") throw new Error("Browser permission policy is invalid.");
|
||||
if (row.sandbox_enabled !== 1) throw new Error("Browser sandbox policy is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
allowedHosts(container: BrowserSessionContainerRow): string[] {
|
||||
return this.parseHostAllowlist(container.host_allowlist_json);
|
||||
}
|
||||
|
||||
markSessionUsed(containerId: string, usedAt: string): void {
|
||||
this.db.prepare("update browser_session_containers set last_used_at = ? where id = ?").run(usedAt, containerId);
|
||||
}
|
||||
|
||||
requireActionPolicy(orgId: string, socialAccountId: string, actionType: SocialBrowserAction): BrowserActionPolicyRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, action_type, risk_level, approval_required, approval_kind
|
||||
from browser_action_policies
|
||||
where org_id = ? and social_account_id = ? and action_type = ?
|
||||
`).get(orgId, socialAccountId, actionType) as BrowserActionPolicyRow | undefined;
|
||||
if (!row) throw new Error("Browser action policy is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
requireApprovalRoute(orgId: string, socialAccountId: string, actionType: SocialBrowserAction, riskLevel: RiskLevel): SocialApprovalRouteRow {
|
||||
const row = this.db.prepare(`
|
||||
select id, approval_kind, route_to_type, route_to_id
|
||||
from social_approval_routes
|
||||
where org_id = ? and social_account_id = ? and action_type = ? and risk_level = ?
|
||||
`).get(orgId, socialAccountId, actionType, riskLevel) as SocialApprovalRouteRow | undefined;
|
||||
if (!row) throw new Error("Social approval route is required.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private parseHostAllowlist(raw: string): string[] {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string" && item.trim())) {
|
||||
throw new Error("Browser host allowlist is invalid.");
|
||||
}
|
||||
return parsed.map((item) => item.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BrowserWindow, session } from "electron";
|
||||
|
||||
export interface SocialLoginWindowInput {
|
||||
title: string;
|
||||
loginUrl: string;
|
||||
partitionKey: string;
|
||||
allowedHosts: string[];
|
||||
sandboxEnabled: boolean;
|
||||
}
|
||||
|
||||
export type SocialLoginWindowLauncher = (input: SocialLoginWindowInput) => void;
|
||||
|
||||
export function openSocialLoginWindow(input: SocialLoginWindowInput): void {
|
||||
const socialSession = session.fromPartition(input.partitionKey);
|
||||
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
socialSession.setPermissionCheckHandler(() => false);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 860,
|
||||
title: input.title,
|
||||
webPreferences: {
|
||||
session: socialSession,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: input.sandboxEnabled,
|
||||
},
|
||||
});
|
||||
attachNavigationPolicy(win, input.allowedHosts);
|
||||
win.loadURL(input.loginUrl);
|
||||
}
|
||||
|
||||
function attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void {
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
const guard = (event: Electron.Event, url: string) => {
|
||||
if (!isAllowedNavigation(allowedHosts, url)) event.preventDefault();
|
||||
};
|
||||
win.webContents.on("will-navigate", guard);
|
||||
win.webContents.on("will-redirect", guard);
|
||||
}
|
||||
|
||||
function isAllowedNavigation(allowedHosts: string[], url: string): boolean {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`));
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CreateScheduledPostInput,
|
||||
CreateSocialAccountInput,
|
||||
LaunchSocialLoginInput,
|
||||
SetScheduledPostStateInput,
|
||||
} from "../../shared/commands";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { SocialAuditService } from "./social-audit-service";
|
||||
import { openSocialLoginWindow, type SocialLoginWindowLauncher } from "./social-login-window";
|
||||
import {
|
||||
SOCIAL_BROWSER_ACTION,
|
||||
SocialBrowserPolicyService,
|
||||
type BrowserActionPolicyRow,
|
||||
type SocialApprovalRouteRow,
|
||||
type SocialBrowserAction,
|
||||
} from "./social-browser-policy-service";
|
||||
|
||||
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
|
||||
[SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login",
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: "https://www.linkedin.com/login",
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: "https://accounts.google.com/",
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
|
||||
};
|
||||
|
||||
const STATE_GRANT_ACTION: Record<SetScheduledPostStateInput["state"], SocialBrowserAction> = {
|
||||
draft: SOCIAL_BROWSER_ACTION.DRAFT,
|
||||
scheduled: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
published: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
cancelled: SOCIAL_BROWSER_ACTION.APPROVE,
|
||||
};
|
||||
|
||||
const GRANT_SCOPE = {
|
||||
actions: [SOCIAL_BROWSER_ACTION.LOGIN, SOCIAL_BROWSER_ACTION.DRAFT, SOCIAL_BROWSER_ACTION.APPROVE],
|
||||
riskCeiling: RISK_LEVEL.MEDIUM,
|
||||
} as const;
|
||||
|
||||
const ACTION_REQUIRED_RISK: Record<SocialBrowserAction, RiskLevel> = {
|
||||
login: RISK_LEVEL.LOW,
|
||||
draft: RISK_LEVEL.LOW,
|
||||
approve: RISK_LEVEL.MEDIUM,
|
||||
};
|
||||
|
||||
const RISK_WEIGHT: Record<RiskLevel, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
interface SocialAccountRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
platform: SocialPlatform;
|
||||
handle: string;
|
||||
authorized_employee_id: string | null;
|
||||
}
|
||||
|
||||
interface ScheduledPostRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
social_account_id: string;
|
||||
body_md: string;
|
||||
state: SetScheduledPostStateInput["state"];
|
||||
approval_id: string | null;
|
||||
created_by_employee_id: string | null;
|
||||
}
|
||||
|
||||
interface GrantScope {
|
||||
actions: string[];
|
||||
riskCeiling: RiskLevel;
|
||||
}
|
||||
|
||||
export type { SocialLoginWindowInput, SocialLoginWindowLauncher } from "./social-login-window";
|
||||
|
||||
export class SocialService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
private readonly socialAudit: SocialAuditService;
|
||||
private readonly browserPolicy: SocialBrowserPolicyService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly launchSocialLoginWindow: SocialLoginWindowLauncher = openSocialLoginWindow,
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
this.socialAudit = new SocialAuditService(db);
|
||||
this.browserPolicy = new SocialBrowserPolicyService(db);
|
||||
}
|
||||
|
||||
createScheduledPost(input: CreateScheduledPostInput): string {
|
||||
this.requireAccount(input.orgId, input.socialAccountId);
|
||||
const policy = this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_ACTION.DRAFT);
|
||||
const body = this.requireText(input.bodyMd, "Post body is required.");
|
||||
const scheduledFor = this.requireSchedule(input.scheduledFor);
|
||||
const id = createId("spost");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into scheduled_posts(
|
||||
id, org_id, social_account_id, body_md, scheduled_for,
|
||||
state, approval_id, created_by_employee_id, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.socialAccountId,
|
||||
body,
|
||||
scheduledFor,
|
||||
"draft",
|
||||
null,
|
||||
input.createdByEmployeeId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "scheduled_post",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "drafted",
|
||||
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: "draft" },
|
||||
});
|
||||
this.socialAudit.record({
|
||||
orgId: input.orgId,
|
||||
socialAccountId: input.socialAccountId,
|
||||
sourceType: "scheduled_post",
|
||||
sourceId: id,
|
||||
eventType: "draft_created",
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
policyDecision: "allowed_by_policy",
|
||||
metadata: { scheduledFor, actionType: policy.action_type, riskLevel: policy.risk_level },
|
||||
createdAt: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setScheduledPostState(input: SetScheduledPostStateInput): void {
|
||||
const post = this.requireScheduledPost(input.orgId, input.scheduledPostId);
|
||||
const action = STATE_GRANT_ACTION[input.state];
|
||||
const policy = this.browserPolicy.requireActionPolicy(input.orgId, post.social_account_id, action);
|
||||
this.requireGrant(input.orgId, post.social_account_id, input.employeeId, action);
|
||||
this.assertStateTransition(post, input.state);
|
||||
const approvalId = this.resolveApprovalForState(input, post, policy);
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update scheduled_posts set state = ?, approval_id = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.state, approvalId, now, input.scheduledPostId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Scheduled post was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "scheduled_post",
|
||||
entityId: input.scheduledPostId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: `state_${input.state}`,
|
||||
payload: { state: input.state, approvalId },
|
||||
});
|
||||
this.socialAudit.record({
|
||||
orgId: input.orgId,
|
||||
socialAccountId: post.social_account_id,
|
||||
sourceType: "scheduled_post",
|
||||
sourceId: input.scheduledPostId,
|
||||
eventType: `state_${input.state}`,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
policyDecision: policyDecisionForState(input.state, policy.approval_required === 1),
|
||||
metadata: { state: input.state, approvalId, actionType: policy.action_type, riskLevel: policy.risk_level },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
createAccount(input: CreateSocialAccountInput): string {
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertPlatform(input.platform);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Creating employee was not found.");
|
||||
const handle = this.requireText(input.handle, "Social handle is required.");
|
||||
const displayName = this.requireText(input.displayName, "Social display name is required.");
|
||||
this.assertUniqueAccount(input.orgId, input.platform, handle);
|
||||
const id = createId("soc");
|
||||
const now = new Date().toISOString();
|
||||
if (input.authorizedEmployeeId) {
|
||||
this.assertEmployee(input.orgId, input.authorizedEmployeeId, "Authorized employee was not found.");
|
||||
}
|
||||
this.db.prepare(`
|
||||
insert into social_accounts(id, org_id, platform, handle, display_name, authorized_employee_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.platform,
|
||||
handle,
|
||||
displayName,
|
||||
input.authorizedEmployeeId,
|
||||
now,
|
||||
);
|
||||
this.browserPolicy.seedAccountControls({
|
||||
orgId: input.orgId,
|
||||
socialAccountId: id,
|
||||
platform: input.platform,
|
||||
authorizedEmployeeId: input.authorizedEmployeeId ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
if (input.authorizedEmployeeId) {
|
||||
this.db.prepare(`
|
||||
insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("sgr"),
|
||||
input.orgId,
|
||||
id,
|
||||
input.authorizedEmployeeId,
|
||||
"operator",
|
||||
JSON.stringify(GRANT_SCOPE),
|
||||
now,
|
||||
);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "social_account",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { platform: input.platform, handle },
|
||||
});
|
||||
this.socialAudit.record({
|
||||
orgId: input.orgId,
|
||||
socialAccountId: id,
|
||||
sourceType: "social_account",
|
||||
sourceId: id,
|
||||
eventType: "account_created",
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
policyDecision: "allowed_employee_authorization",
|
||||
metadata: { platform: input.platform, handle, authorizedEmployeeId: input.authorizedEmployeeId ?? null },
|
||||
createdAt: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
launchLoginWindow(input: LaunchSocialLoginInput): void {
|
||||
const account = this.requireAccount(input.orgId, input.socialAccountId);
|
||||
const container = this.browserPolicy.requireSessionContainer(input.orgId, input.socialAccountId);
|
||||
this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.LOGIN);
|
||||
this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_BROWSER_ACTION.LOGIN);
|
||||
const allowedHosts = this.browserPolicy.allowedHosts(container);
|
||||
const loginUrl = PLATFORM_LOGIN_URL[account.platform];
|
||||
this.launchSocialLoginWindow({
|
||||
title: account.handle,
|
||||
loginUrl,
|
||||
partitionKey: container.partition_key,
|
||||
allowedHosts,
|
||||
sandboxEnabled: container.sandbox_enabled === 1,
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
this.browserPolicy.markSessionUsed(container.id, now);
|
||||
const browserActionId = createId("bact");
|
||||
const metadata = loginEvidenceMetadata({
|
||||
platform: account.platform,
|
||||
loginUrl,
|
||||
containerId: container.id,
|
||||
partitionKey: container.partition_key,
|
||||
hostAllowlist: allowedHosts,
|
||||
permissionPolicy: container.permission_policy,
|
||||
sandboxEnabled: container.sandbox_enabled === 1,
|
||||
actorId: input.employeeId,
|
||||
});
|
||||
this.db.prepare(`
|
||||
insert into browser_actions(
|
||||
id, org_id, social_account_id, action_type, policy_decision,
|
||||
evidence_file_id, metadata_json, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, JSON.stringify(metadata), now);
|
||||
this.audit.record({
|
||||
orgId: account.org_id,
|
||||
entityType: "social_account",
|
||||
entityId: account.id,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: "login_window_opened",
|
||||
payload: { platform: account.platform },
|
||||
});
|
||||
this.socialAudit.record({
|
||||
orgId: account.org_id,
|
||||
socialAccountId: account.id,
|
||||
sourceType: "browser_action",
|
||||
sourceId: browserActionId,
|
||||
eventType: "login_window_opened",
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
policyDecision: "allowed_human_login",
|
||||
metadata,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
private requireAccount(orgId: string, accountId: string): SocialAccountRow {
|
||||
const account = this.db.prepare("select * from social_accounts where id = ? and org_id = ?")
|
||||
.get(accountId, orgId) as SocialAccountRow | undefined;
|
||||
if (!account) throw new Error("Social account was not found.");
|
||||
return account;
|
||||
}
|
||||
|
||||
private requireScheduledPost(orgId: string, scheduledPostId: string): ScheduledPostRow {
|
||||
const post = this.db.prepare("select * from scheduled_posts where id = ? and org_id = ?")
|
||||
.get(scheduledPostId, orgId) as ScheduledPostRow | undefined;
|
||||
if (!post) throw new Error("Scheduled post was not found.");
|
||||
return post;
|
||||
}
|
||||
|
||||
private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialBrowserAction): void {
|
||||
this.assertEmployee(orgId, employeeId, "Social account grant is required.");
|
||||
const row = this.db.prepare(`
|
||||
select scope_json from social_account_grants
|
||||
where org_id = ? and social_account_id = ? and employee_id = ?
|
||||
`).get(orgId, socialAccountId, employeeId) as { scope_json: string } | undefined;
|
||||
if (!row) throw new Error("Social account grant is required.");
|
||||
const scope = parseGrantScope(row.scope_json);
|
||||
if (!scope.actions.includes(action)) throw new Error("Social account grant is required.");
|
||||
if (RISK_WEIGHT[scope.riskCeiling] < RISK_WEIGHT[ACTION_REQUIRED_RISK[action]]) {
|
||||
throw new Error("Social account grant risk ceiling is insufficient.");
|
||||
}
|
||||
}
|
||||
|
||||
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string | null {
|
||||
if (input.state === "scheduled") {
|
||||
return policy.approval_required === 1
|
||||
? post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post, policy)
|
||||
: post.approval_id;
|
||||
}
|
||||
if (input.state === "published") {
|
||||
if (policy.approval_required === 1) this.assertApprovalApproved(input.orgId, post.approval_id);
|
||||
return post.approval_id;
|
||||
}
|
||||
return post.approval_id;
|
||||
}
|
||||
|
||||
private createPublishApproval(orgId: string, employeeId: string, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string {
|
||||
const route = this.browserPolicy.requireApprovalRoute(orgId, post.social_account_id, policy.action_type, policy.risk_level);
|
||||
const approvalId = this.approvals.create({
|
||||
orgId,
|
||||
kind: route.approval_kind,
|
||||
title: "Approve social post",
|
||||
details: post.body_md,
|
||||
risk: policy.risk_level,
|
||||
requestedByType: "employee",
|
||||
requestedById: employeeId,
|
||||
}, { sourceEntityType: "scheduled_post", sourceEntityId: post.id });
|
||||
this.assignSocialApprovalRoute(orgId, approvalId, route);
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
private assignSocialApprovalRoute(orgId: string, approvalId: string, route: SocialApprovalRouteRow): void {
|
||||
if (route.route_to_type !== "authorized_employee") throw new Error("Social approval route target type is invalid.");
|
||||
if (!route.route_to_id) throw new Error("Social approval route target is required.");
|
||||
this.assertEmployee(orgId, route.route_to_id, "Social approval route employee was not found.");
|
||||
this.db.prepare(`
|
||||
insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at)
|
||||
values (?, ?, ?, ?, ?, null, ?)
|
||||
on conflict(approval_id, assignee_type, assignee_id) do nothing
|
||||
`).run(createId("aas"), orgId, approvalId, ROUTE_TO_TYPE.EMPLOYEE, route.route_to_id, new Date().toISOString());
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "social_route_assigned",
|
||||
payload: { routeId: route.id, routeToType: route.route_to_type, routeToId: route.route_to_id },
|
||||
});
|
||||
}
|
||||
|
||||
private assertApprovalApproved(orgId: string, approvalId: string | null): void {
|
||||
if (!approvalId) throw new Error("Approved social approval is required.");
|
||||
const approval = this.db.prepare("select state from approvals where id = ? and org_id = ? and kind = ?")
|
||||
.get(approvalId, orgId, "social_publish") as { state: string } | undefined;
|
||||
if (!approval || approval.state !== APPROVAL_STATE.APPROVED) {
|
||||
throw new Error("Approved social approval is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertStateTransition(post: ScheduledPostRow, nextState: SetScheduledPostStateInput["state"]): void {
|
||||
if (post.state === nextState) throw new Error("Scheduled post already has this state.");
|
||||
const allowed = post.state === "draft"
|
||||
? ["scheduled", "cancelled"]
|
||||
: post.state === "scheduled"
|
||||
? ["published", "cancelled"]
|
||||
: [];
|
||||
if (!allowed.includes(nextState)) throw new Error("Scheduled post state transition is invalid.");
|
||||
}
|
||||
|
||||
private assertOrg(orgId: string): void {
|
||||
const org = this.db.prepare("select id from organizations where id = ?").get(orgId);
|
||||
if (!org) throw new Error("Organization was not found.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error(message);
|
||||
}
|
||||
|
||||
private assertPlatform(platform: SocialPlatform): void {
|
||||
if (!Object.values(SOCIAL_PLATFORM).includes(platform)) throw new Error("Social platform is unsupported.");
|
||||
}
|
||||
|
||||
private assertUniqueAccount(orgId: string, platform: SocialPlatform, handle: string): void {
|
||||
const account = this.db.prepare("select id from social_accounts where org_id = ? and platform = ? and lower(handle) = lower(?)")
|
||||
.get(orgId, platform, handle);
|
||||
if (account) throw new Error("Social account already exists.");
|
||||
}
|
||||
|
||||
private requireText(value: string, message: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error(message);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private requireSchedule(value: string): string {
|
||||
const scheduledFor = this.requireText(value, "Scheduled date is required.");
|
||||
const date = new Date(scheduledFor);
|
||||
if (Number.isNaN(date.getTime())) throw new Error("Scheduled date is invalid.");
|
||||
return scheduledFor;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function parseGrantScope(raw: string): GrantScope {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Social account grant scope is invalid.");
|
||||
}
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.actions) || !parsed.actions.every((action) => typeof action === "string")) {
|
||||
throw new Error("Social account grant scope is invalid.");
|
||||
}
|
||||
if (!isRiskLevel(parsed.riskCeiling)) throw new Error("Social account grant scope is invalid.");
|
||||
return { actions: parsed.actions, riskCeiling: parsed.riskCeiling };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isRiskLevel(value: unknown): value is RiskLevel {
|
||||
return Object.values(RISK_LEVEL).includes(value as RiskLevel);
|
||||
}
|
||||
|
||||
function policyDecisionForState(state: SetScheduledPostStateInput["state"], approvalRequired: boolean): string {
|
||||
if (state === "scheduled" && approvalRequired) return "approval_requested";
|
||||
if (state === "published" && approvalRequired) return "allowed_after_approval";
|
||||
return "allowed_by_policy";
|
||||
}
|
||||
|
||||
function loginEvidenceMetadata(input: {
|
||||
platform: SocialPlatform;
|
||||
loginUrl: string;
|
||||
containerId: string;
|
||||
partitionKey: string;
|
||||
hostAllowlist: string[];
|
||||
permissionPolicy: string;
|
||||
sandboxEnabled: boolean;
|
||||
actorId: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
platform: input.platform,
|
||||
loginUrl: input.loginUrl,
|
||||
containerId: input.containerId,
|
||||
partitionKey: input.partitionKey,
|
||||
hostAllowlist: input.hostAllowlist,
|
||||
permissionPolicy: input.permissionPolicy,
|
||||
sandboxEnabled: input.sandboxEnabled,
|
||||
actorType: "employee",
|
||||
actorId: input.actorId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateSprintInput, DeleteSprintInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class SprintService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateSprintInput): string {
|
||||
const id = createId("spr");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into sprints(id, org_id, name, goal, status, start_date, end_date, velocity_points, completed_points, retrospective, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.name.trim(), input.goal.trim(), "planned", input.startDate, input.endDate, "", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "sprint",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
delete(input: DeleteSprintInput): void {
|
||||
const result = this.db.prepare("delete from sprints where id = ? and org_id = ?").run(input.sprintId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Sprint was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunTaskInput } from "../../shared/commands";
|
||||
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { ProviderService } from "./provider-service";
|
||||
import { OrganizationService } from "./organization-service";
|
||||
import { ChatService } from "./chat-service";
|
||||
import { SkillService } from "./skill-service";
|
||||
import { runAgentLoop } from "./agent-run-loop";
|
||||
import type { AgentToolContext } from "./agent-tools";
|
||||
import {
|
||||
buildAgentInstructions,
|
||||
buildTaskPrompt,
|
||||
type ChatContextBlock,
|
||||
type InboxContextBlock,
|
||||
type GoalRow,
|
||||
type ProjectRow,
|
||||
type SkillRuntimeSnapshotRow,
|
||||
type TaskExecutionAgentRow,
|
||||
type TaskExecutionProviderRow,
|
||||
type TaskExecutionTaskRow,
|
||||
type TaskRunIsolation,
|
||||
type WorkspaceRow,
|
||||
} from "./task-prompts";
|
||||
import { loadComposioTools } from "./composio/composio-tool-bridge";
|
||||
|
||||
const TASK_RUN_STATE = {
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running",
|
||||
COMPLETED: "completed",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
interface TaskApprovalRow {
|
||||
id: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface RunRow {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
provider_id: string;
|
||||
execution_context_id: string;
|
||||
service_identity_id: string;
|
||||
workspace_mount_json: string;
|
||||
secret_scope_json: string;
|
||||
}
|
||||
|
||||
export class TaskExecutionService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly organization: OrganizationService;
|
||||
private readonly chat: ChatService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly providers: ProviderService = new ProviderService(db),
|
||||
private readonly skills: SkillService = new SkillService(db, ""),
|
||||
/** Invoked whenever a run writes data mid-flight, so the UI can refresh while agents work. */
|
||||
private readonly notifyDataChanged: () => void = () => undefined,
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
this.organization = new OrganizationService(db);
|
||||
this.chat = new ChatService(db);
|
||||
}
|
||||
|
||||
async runTask(input: RunTaskInput): Promise<string> {
|
||||
const task = this.requireTask(input.orgId, input.taskId);
|
||||
this.assertExecutionApproval(input.orgId, task);
|
||||
const agent = this.requireAssignedAgent(input.orgId, task);
|
||||
const provider = this.requireAgentProvider(input.orgId, agent);
|
||||
const workspace = this.workspace(task.workspace_id);
|
||||
const isolation = this.createIsolationContext(input.orgId, task, agent, provider, workspace);
|
||||
const runId = this.createRun(input.orgId, task.id, agent.id, provider.id, isolation);
|
||||
return this.executeAgentRun({ orgId: input.orgId, runId, task, agent, provider, workspace, isolation });
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an existing `running` task_run (claimed by the autonomous queue
|
||||
* worker). System-initiated, so it does not re-gate on the manual execution
|
||||
* approval — the operator configured the company to run autonomously.
|
||||
*/
|
||||
async executeRun(orgId: string, runId: string): Promise<string> {
|
||||
const run = this.db.prepare(`
|
||||
select task_id, agent_id, provider_id, execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json
|
||||
from task_runs where id = ? and org_id = ?
|
||||
`).get(runId, orgId) as RunRow | undefined;
|
||||
if (!run) throw new Error("Task run was not found.");
|
||||
const task = this.requireTask(orgId, run.task_id);
|
||||
const agent = this.requireAssignedAgent(orgId, task);
|
||||
const provider = this.requireAgentProvider(orgId, agent);
|
||||
const workspace = this.workspace(task.workspace_id);
|
||||
const isolation: TaskRunIsolation = {
|
||||
executionContextId: run.execution_context_id,
|
||||
serviceIdentityId: run.service_identity_id,
|
||||
workspaceMountJson: run.workspace_mount_json,
|
||||
secretScopeJson: run.secret_scope_json,
|
||||
};
|
||||
return this.executeAgentRun({ orgId, runId, task, agent, provider, workspace, isolation });
|
||||
}
|
||||
|
||||
private async executeAgentRun(args: {
|
||||
orgId: string;
|
||||
runId: string;
|
||||
task: TaskExecutionTaskRow;
|
||||
agent: TaskExecutionAgentRow;
|
||||
provider: TaskExecutionProviderRow;
|
||||
workspace: WorkspaceRow | null;
|
||||
isolation: TaskRunIsolation;
|
||||
}): Promise<string> {
|
||||
const { orgId, runId, task, agent, provider, workspace, isolation } = args;
|
||||
let providerRequestId: string | null = null;
|
||||
let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null;
|
||||
try {
|
||||
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId);
|
||||
const toolContext: AgentToolContext = {
|
||||
orgId,
|
||||
agent: { id: agent.id, modelProviderId: agent.model_provider_id, modelId: agent.model_id },
|
||||
taskProjectId: task.project_id,
|
||||
taskGoalId: task.goal_id,
|
||||
workspace: workspace ? { id: workspace.id, localPath: workspace.local_path } : null,
|
||||
};
|
||||
const chatContext = this.loadChatContext(orgId, isolation);
|
||||
const inboxContext = chatContext ? null : this.loadInboxContext(orgId, agent.id);
|
||||
const isCeo = !agent.manager_agent_id;
|
||||
const hasReports = this.agentHasReports(orgId, agent.id);
|
||||
const externalTools = await loadComposioTools(this.db, orgId, agent.id);
|
||||
const result = await runAgentLoop(
|
||||
{
|
||||
providers: this.providers,
|
||||
tools: { db: this.db, organization: this.organization, chat: this.chat },
|
||||
onToolResults: this.notifyDataChanged,
|
||||
},
|
||||
{
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
workspaceId: task.workspace_id,
|
||||
instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, externalToolCount: externalTools.length }),
|
||||
initialUserTurn: buildTaskPrompt({
|
||||
task,
|
||||
agent,
|
||||
provider,
|
||||
project: this.project(task.project_id),
|
||||
goal: this.goal(task.goal_id),
|
||||
workspace,
|
||||
isolation,
|
||||
skillRuntimeSnapshot,
|
||||
isManager: isCeo,
|
||||
hasReports,
|
||||
managerName: this.agentName(orgId, agent.manager_agent_id),
|
||||
chatContext,
|
||||
inboxContext,
|
||||
}),
|
||||
toolContext,
|
||||
externalTools,
|
||||
},
|
||||
);
|
||||
providerRequestId = result.lastRequestId;
|
||||
this.completeRun(orgId, runId, result.lastRequestId);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task_run",
|
||||
entityId: runId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "completed",
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
providerId: provider.id,
|
||||
providerRequestId: result.lastRequestId,
|
||||
iterations: result.iterations,
|
||||
toolCalls: result.toolCallCount,
|
||||
executionContextId: isolation.executionContextId,
|
||||
serviceIdentityId: isolation.serviceIdentityId,
|
||||
skillRuntimeSnapshotId: skillRuntimeSnapshot?.id ?? null,
|
||||
},
|
||||
});
|
||||
return runId;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.failRun(orgId, runId, providerRequestId, message);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task_run",
|
||||
entityId: runId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "failed",
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
providerId: provider.id,
|
||||
providerRequestId,
|
||||
executionContextId: isolation.executionContextId,
|
||||
serviceIdentityId: isolation.serviceIdentityId,
|
||||
skillRuntimeSnapshotId: skillRuntimeSnapshot?.id ?? null,
|
||||
errorMessage: message,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private requireTask(orgId: string, taskId: string): TaskExecutionTaskRow {
|
||||
const task = this.db.prepare("select * from tasks where id = ? and org_id = ?").get(taskId, orgId) as TaskExecutionTaskRow | undefined;
|
||||
if (!task) throw new Error("Task was not found.");
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireAssignedAgent(orgId: string, task: TaskExecutionTaskRow): TaskExecutionAgentRow {
|
||||
if (!task.assignee_agent_id) throw new Error("Task requires an assigned agent.");
|
||||
const agent = this.db.prepare(`
|
||||
select id, name, role, department, title, role_level, model_provider_id, model_id, manager_agent_id
|
||||
from agents
|
||||
where id = ? and org_id = ?
|
||||
`).get(task.assignee_agent_id, orgId) as TaskExecutionAgentRow | undefined;
|
||||
if (!agent) throw new Error("Task assignee was not found.");
|
||||
return agent;
|
||||
}
|
||||
|
||||
private assertExecutionApproval(orgId: string, task: TaskExecutionTaskRow): void {
|
||||
if (task.risk !== RISK_LEVEL.HIGH && task.requires_filesystem !== 1) return;
|
||||
const approval = this.db.prepare(`
|
||||
select id, state
|
||||
from approvals
|
||||
where org_id = ?
|
||||
and source_entity_type = 'task'
|
||||
and source_entity_id = ?
|
||||
and kind in ('task_risk', 'task_execution')
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`).get(orgId, task.id) as TaskApprovalRow | undefined;
|
||||
if (!approval || approval.state !== APPROVAL_STATE.APPROVED) {
|
||||
throw new Error("Approved task execution approval is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private requireAgentProvider(orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow {
|
||||
if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider.");
|
||||
const provider = this.db.prepare(`
|
||||
select id, label, provider_key, model
|
||||
from providers
|
||||
where id = ? and org_id = ?
|
||||
`).get(agent.model_provider_id, orgId) as TaskExecutionProviderRow | undefined;
|
||||
if (!provider) throw new Error("Task assignee provider was not found.");
|
||||
return provider;
|
||||
}
|
||||
|
||||
private createRun(orgId: string, taskId: string, agentId: string, providerId: string, isolation: TaskRunIsolation): string {
|
||||
const runId = createId("run");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into task_runs(
|
||||
id, org_id, task_id, agent_id, provider_id, provider_request_id,
|
||||
execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json,
|
||||
trigger, state, created_at, started_at, completed_at, error_message
|
||||
) values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, null, null)
|
||||
`).run(
|
||||
runId,
|
||||
orgId,
|
||||
taskId,
|
||||
agentId,
|
||||
providerId,
|
||||
isolation.executionContextId,
|
||||
isolation.serviceIdentityId,
|
||||
isolation.workspaceMountJson,
|
||||
isolation.secretScopeJson,
|
||||
"manual",
|
||||
TASK_RUN_STATE.RUNNING,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task_run",
|
||||
entityId: runId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "started",
|
||||
payload: { taskId, agentId, providerId, trigger: "manual", executionContextId: isolation.executionContextId, serviceIdentityId: isolation.serviceIdentityId },
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat: enqueues one run per actionable assigned task of a
|
||||
* heartbeat-enabled agent with an enabled provider that has not run yet, so
|
||||
* CEO-delegated work starts on its own. Each task is enqueued at most once
|
||||
* (guarded by existing runs), preventing runaway re-execution.
|
||||
*/
|
||||
enqueueHeartbeatRuns(): string[] {
|
||||
const rows = this.db.prepare(`
|
||||
select t.id as task_id, t.org_id as org_id
|
||||
from tasks t
|
||||
join agents a on a.id = t.assignee_agent_id and a.org_id = t.org_id
|
||||
join providers p on p.id = a.model_provider_id and p.org_id = t.org_id
|
||||
where t.status in ('backlog', 'in_progress')
|
||||
and a.heartbeat_enabled = 1
|
||||
and a.lifecycle = 'active'
|
||||
and p.enabled = 1
|
||||
and not exists (select 1 from task_runs r where r.task_id = t.id and r.state in ('queued', 'running', 'completed'))
|
||||
and (select count(*) from task_runs r where r.task_id = t.id) < 3
|
||||
`).all() as Array<{ task_id: string; org_id: string }>;
|
||||
const created: string[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
created.push(this.enqueueRun(row.org_id, row.task_id));
|
||||
} catch {
|
||||
// Skip tasks that cannot be prepared yet (e.g. provider not fully configured).
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private enqueueRun(orgId: string, taskId: string): string {
|
||||
const task = this.requireTask(orgId, taskId);
|
||||
// Autonomous execution honours the same gate as a manual run: a high-risk or
|
||||
// filesystem task whose approval is still pending (or was declined) is left
|
||||
// unqueued and retried once the operator approves it, instead of running
|
||||
// unsupervised. enqueueHeartbeatRuns swallows the throw and moves on.
|
||||
this.assertExecutionApproval(orgId, task);
|
||||
const agent = this.requireAssignedAgent(orgId, task);
|
||||
const provider = this.requireAgentProvider(orgId, agent);
|
||||
const workspace = this.workspace(task.workspace_id);
|
||||
const isolation = this.createIsolationContext(orgId, task, agent, provider, workspace);
|
||||
const runId = createId("run");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into task_runs(
|
||||
id, org_id, task_id, agent_id, provider_id, provider_request_id,
|
||||
execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json,
|
||||
trigger, state, created_at, started_at, completed_at, error_message
|
||||
) values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, 'heartbeat', ?, ?, null, null, null)
|
||||
`).run(
|
||||
runId,
|
||||
orgId,
|
||||
task.id,
|
||||
agent.id,
|
||||
provider.id,
|
||||
isolation.executionContextId,
|
||||
isolation.serviceIdentityId,
|
||||
isolation.workspaceMountJson,
|
||||
isolation.secretScopeJson,
|
||||
TASK_RUN_STATE.QUEUED,
|
||||
now,
|
||||
);
|
||||
return runId;
|
||||
}
|
||||
|
||||
private loadChatContext(orgId: string, isolation: TaskRunIsolation): ChatContextBlock | null {
|
||||
let scope: { sourceMessageId?: string; sourceThreadId?: string };
|
||||
try {
|
||||
scope = JSON.parse(isolation.secretScopeJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!scope.sourceThreadId) return null;
|
||||
const messages = this.db.prepare(`
|
||||
select m.body_md, m.sender_type, m.sender_id,
|
||||
coalesce(a.name, e.display_name, m.sender_type) as sender_name
|
||||
from chat_messages m
|
||||
left join agents a on a.id = m.sender_id and m.sender_type = 'agent'
|
||||
left join employees e on e.id = m.sender_id and m.sender_type = 'employee'
|
||||
where m.thread_id = ? and m.org_id = ?
|
||||
order by m.created_at asc
|
||||
limit 20
|
||||
`).all(scope.sourceThreadId, orgId) as Array<{ body_md: string; sender_type: string; sender_name: string }>;
|
||||
if (messages.length === 0) return null;
|
||||
return {
|
||||
threadId: scope.sourceThreadId,
|
||||
transcript: messages.map((m) => `[${m.sender_name}]: ${m.body_md}`).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
private loadInboxContext(orgId: string, agentId: string): InboxContextBlock | null {
|
||||
const messages = this.db.prepare(`
|
||||
select m.body_md,
|
||||
coalesce(a.name, e.display_name, m.sender_type) as sender_name,
|
||||
s.name as channel_name
|
||||
from chat_mentions cm
|
||||
join chat_messages m on m.id = cm.message_id
|
||||
join chat_spaces s on s.id = m.space_id
|
||||
left join agents a on a.id = m.sender_id and m.sender_type = 'agent'
|
||||
left join employees e on e.id = m.sender_id and m.sender_type = 'employee'
|
||||
where cm.target_type = 'agent' and cm.target_id = ? and m.org_id = ? and m.deleted_at is null
|
||||
order by m.created_at desc
|
||||
limit 10
|
||||
`).all(agentId, orgId) as Array<{ body_md: string; sender_name: string; channel_name: string }>;
|
||||
if (messages.length === 0) return null;
|
||||
return {
|
||||
transcript: messages.reverse().map((m) => `[${m.sender_name} in #${m.channel_name}]: ${m.body_md}`).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
private agentHasReports(orgId: string, agentId: string): boolean {
|
||||
const row = this.db.prepare("select 1 from agents where org_id = ? and manager_agent_id = ? and lifecycle = 'active' limit 1")
|
||||
.get(orgId, agentId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
private agentName(orgId: string, agentId: string | null): string | null {
|
||||
if (!agentId) return null;
|
||||
const row = this.db.prepare("select name from agents where id = ? and org_id = ?").get(agentId, orgId) as { name: string } | undefined;
|
||||
return row?.name ?? null;
|
||||
}
|
||||
|
||||
private orgName(orgId: string): string {
|
||||
const row = this.db.prepare("select name from organizations where id = ?").get(orgId) as { name: string } | undefined;
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
return row.name;
|
||||
}
|
||||
|
||||
private completeRun(orgId: string, runId: string, providerRequestId: string): void {
|
||||
this.db.prepare(`
|
||||
update task_runs
|
||||
set state = ?, provider_request_id = ?, completed_at = ?, error_message = null
|
||||
where id = ? and org_id = ?
|
||||
`).run(TASK_RUN_STATE.COMPLETED, providerRequestId, new Date().toISOString(), runId, orgId);
|
||||
}
|
||||
|
||||
private failRun(orgId: string, runId: string, providerRequestId: string | null, errorMessage: string): void {
|
||||
this.db.prepare(`
|
||||
update task_runs
|
||||
set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, runId, orgId);
|
||||
}
|
||||
|
||||
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string): SkillRuntimeSnapshotRow | null {
|
||||
const hasEnabledSkill = this.db.prepare(`
|
||||
select id from skill_bindings
|
||||
where org_id = ? and agent_id = ? and enabled = 1
|
||||
limit 1
|
||||
`).get(orgId, agentId);
|
||||
if (!hasEnabledSkill) return null;
|
||||
const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId });
|
||||
const snapshot = this.db.prepare(`
|
||||
select id, compiled_prompt, tools_manifest, source_revision_ids
|
||||
from skill_runtime_snapshots
|
||||
where id = ? and agent_id = ? and run_id = ?
|
||||
`).get(snapshotId, agentId, runId) as SkillRuntimeSnapshotRow | undefined;
|
||||
if (!snapshot) throw new Error("Skill runtime snapshot was not found.");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private project(projectId: string | null): ProjectRow | null {
|
||||
if (!projectId) return null;
|
||||
return (this.db.prepare("select name, description from projects where id = ?").get(projectId) as unknown as ProjectRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private goal(goalId: string | null): GoalRow | null {
|
||||
if (!goalId) return null;
|
||||
return (this.db.prepare("select title, description from goals where id = ?").get(goalId) as unknown as GoalRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private workspace(workspaceId: string | null): WorkspaceRow | null {
|
||||
if (!workspaceId) return null;
|
||||
return (this.db.prepare("select id, name, local_path, repo_url, repo_ref from workspaces where id = ?").get(workspaceId) as unknown as WorkspaceRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private createIsolationContext(
|
||||
orgId: string,
|
||||
task: TaskExecutionTaskRow,
|
||||
agent: TaskExecutionAgentRow,
|
||||
provider: TaskExecutionProviderRow,
|
||||
workspace: WorkspaceRow | null,
|
||||
): TaskRunIsolation {
|
||||
if (task.requires_filesystem === 1 && !workspace) throw new Error("Filesystem tasks require a workspace.");
|
||||
const serviceIdentityId = this.requireServiceIdentity(orgId, agent.id);
|
||||
const executionContextId = createId("ctx");
|
||||
const workspaceMounts = workspace ? [{
|
||||
workspaceId: workspace.id,
|
||||
name: workspace.name,
|
||||
localPath: workspace.local_path,
|
||||
repoRef: workspace.repo_ref,
|
||||
mode: task.requires_filesystem === 1 ? "read_write" : "read_only",
|
||||
}] : [];
|
||||
return {
|
||||
executionContextId,
|
||||
serviceIdentityId,
|
||||
workspaceMountJson: JSON.stringify(workspaceMounts),
|
||||
secretScopeJson: JSON.stringify({
|
||||
serviceIdentityId,
|
||||
providerIds: [provider.id],
|
||||
scope: "task_run",
|
||||
credentialAccess: "provider_runtime_only",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private requireServiceIdentity(orgId: string, agentId: string): string {
|
||||
const row = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
|
||||
.get(orgId, agentId) as { id: string } | undefined;
|
||||
if (!row) throw new Error("Agent service identity was not found.");
|
||||
return row.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
|
||||
export interface TaskExecutionTaskRow {
|
||||
id: string;
|
||||
org_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assignee_agent_id: string | null;
|
||||
risk: string;
|
||||
project_id: string | null;
|
||||
goal_id: string | null;
|
||||
workspace_id: string | null;
|
||||
priority: string;
|
||||
requires_filesystem: number;
|
||||
}
|
||||
|
||||
export interface TaskExecutionAgentRow {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
department: string;
|
||||
title: string;
|
||||
role_level: string;
|
||||
model_provider_id: string | null;
|
||||
model_id: string | null;
|
||||
manager_agent_id: string | null;
|
||||
}
|
||||
|
||||
export interface TaskExecutionProviderRow {
|
||||
id: string;
|
||||
label: string;
|
||||
provider_key: string;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectRow {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GoalRow {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string;
|
||||
repo_url: string;
|
||||
repo_ref: string;
|
||||
}
|
||||
|
||||
export interface TaskRunIsolation {
|
||||
executionContextId: string;
|
||||
serviceIdentityId: string;
|
||||
workspaceMountJson: string;
|
||||
secretScopeJson: string;
|
||||
}
|
||||
|
||||
export interface SkillRuntimeSnapshotRow {
|
||||
id: string;
|
||||
compiled_prompt: string;
|
||||
tools_manifest: string;
|
||||
source_revision_ids: string;
|
||||
}
|
||||
|
||||
export interface ChatContextBlock {
|
||||
threadId: string;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
export interface AgentInstructionContext {
|
||||
agent: TaskExecutionAgentRow;
|
||||
companyName: string;
|
||||
isCeo: boolean;
|
||||
hasReports: boolean;
|
||||
externalToolCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable system prompt: who this agent is and how they carry themselves.
|
||||
* A role-shaped persona (executive / manager / individual contributor) is what
|
||||
* turns identical model calls into distinct colleagues, so the company reads
|
||||
* like people working together rather than a queue of status updates.
|
||||
*/
|
||||
export function buildAgentInstructions(input: AgentInstructionContext): string {
|
||||
const { agent } = input;
|
||||
return [
|
||||
`You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`,
|
||||
roleVoice(input),
|
||||
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
|
||||
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name, update_task_status tracks it, write_deliverable produces real files, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.${input.externalToolCount > 0 ? ` You also have ${input.externalToolCount} external tools (composio_* prefixed) for interacting with services like GitHub, Slack, Gmail, etc. — use them when your work involves those platforms.` : ""}`,
|
||||
"Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function roleVoice(input: AgentInstructionContext): string {
|
||||
if (input.isCeo) {
|
||||
return "You run this company. Think in outcomes, make the call when it is yours to make, and put the right person on each piece instead of doing everything yourself — then tell the team the why, not just the what.";
|
||||
}
|
||||
if (input.hasReports) {
|
||||
return "You lead a team. Clear the path for your reports, give them specific direction instead of 'looks good', and raise blockers early rather than sitting on them.";
|
||||
}
|
||||
return "You own your craft. Ask the question early instead of guessing, do the work properly, and when you are stuck for more than a few minutes say so in the thread instead of spinning in place.";
|
||||
}
|
||||
|
||||
export interface InboxContextBlock {
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
export function buildTaskPrompt(input: {
|
||||
task: TaskExecutionTaskRow;
|
||||
agent: TaskExecutionAgentRow;
|
||||
provider: TaskExecutionProviderRow;
|
||||
project: ProjectRow | null;
|
||||
goal: GoalRow | null;
|
||||
workspace: WorkspaceRow | null;
|
||||
isolation: TaskRunIsolation;
|
||||
skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null;
|
||||
isManager: boolean;
|
||||
hasReports: boolean;
|
||||
managerName: string | null;
|
||||
chatContext: ChatContextBlock | null;
|
||||
inboxContext: InboxContextBlock | null;
|
||||
}): string {
|
||||
const lines = [
|
||||
"Execute this AgentCompany task.",
|
||||
"",
|
||||
`Task: ${input.task.title}`,
|
||||
`Description: ${input.task.description}`,
|
||||
`Status: ${input.task.status}`,
|
||||
`Priority: ${input.task.priority}`,
|
||||
`Risk: ${input.task.risk}`,
|
||||
`Requires filesystem: ${input.task.requires_filesystem === 1 ? "yes" : "no"}`,
|
||||
`Assignee: ${input.agent.name} (${input.agent.role})`,
|
||||
...(input.managerName ? [`Reports to: ${input.managerName}`] : []),
|
||||
`Provider: ${input.provider.label} ${input.provider.model ?? input.agent.model_id ?? ""}`.trim(),
|
||||
`Execution context: ${input.isolation.executionContextId}`,
|
||||
`Service identity: ${input.isolation.serviceIdentityId}`,
|
||||
];
|
||||
if (input.goal) lines.push("", `Goal: ${input.goal.title}`, `Goal detail: ${input.goal.description}`);
|
||||
if (input.project) lines.push("", `Project: ${input.project.name}`, `Project detail: ${input.project.description}`);
|
||||
if (input.workspace) {
|
||||
lines.push(
|
||||
"",
|
||||
`Workspace: ${input.workspace.name}`,
|
||||
`Local path: ${input.workspace.local_path}`,
|
||||
`Repo: ${input.workspace.repo_url}`,
|
||||
`Ref: ${input.workspace.repo_ref}`,
|
||||
`Mounts: ${input.isolation.workspaceMountJson}`,
|
||||
);
|
||||
}
|
||||
lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`);
|
||||
if (input.skillRuntimeSnapshot) {
|
||||
lines.push(
|
||||
"",
|
||||
"Skill runtime:",
|
||||
input.skillRuntimeSnapshot.compiled_prompt,
|
||||
"",
|
||||
`Tool allowlist: ${input.skillRuntimeSnapshot.tools_manifest}`,
|
||||
`Skill revisions: ${input.skillRuntimeSnapshot.source_revision_ids}`,
|
||||
);
|
||||
}
|
||||
if (input.chatContext) {
|
||||
lines.push(
|
||||
"",
|
||||
"The conversation in this thread so far:",
|
||||
input.chatContext.transcript,
|
||||
"",
|
||||
`You have been brought into this thread. Reply to the people here in your own voice — answer what they asked, give your honest take, push back if you disagree, or hand work over. Stay in the thread by passing thread_id="${input.chatContext.threadId}" to send_message. This is a conversation between colleagues, not a status report; only change a task's status if the work itself actually moved.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
if (input.inboxContext) {
|
||||
lines.push(
|
||||
"",
|
||||
"Recent messages that mention you:",
|
||||
input.inboxContext.transcript,
|
||||
"",
|
||||
"Respond to anyone above who needs your input before starting the task.",
|
||||
);
|
||||
}
|
||||
if (input.isManager && !input.hasReports) {
|
||||
lines.push(
|
||||
"",
|
||||
"Your team is currently empty. Hire 3-5 direct reports for the roles this goal needs (hire_agent), create and assign their first tasks (create_task), and write the initial operating plan into the workspace (write_deliverable). Then use send_message with /handoff @Name and attachment_file_ids to pass the operating plan to the relevant report. Brief EACH report in a direct message (send_message with to=\"Their Name\") about the work you assigned them — note the thread_id each returns so you can follow up in the same conversation. Post one company-wide kickoff to the #general channel. Then summarize.",
|
||||
);
|
||||
} else {
|
||||
const manager = input.managerName ?? "your manager";
|
||||
const managerReference = input.managerName ? `${manager} (${agentMention(input.managerName)})` : manager;
|
||||
lines.push(
|
||||
"",
|
||||
`First, read_messages to check if anyone has said something you should respond to — reply to them like a colleague, not a report. Then do the work: write real output with write_deliverable, raise decisions and blockers with ${managerReference} in a direct message (send_message with to=), keep team-wide topics in channels, and when the work is genuinely done move the task to done. Hand files over with /handoff and attachment_file_ids. Keep conversation going — ask questions, share your thinking, push back if something seems wrong.`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function agentMention(name: string): string {
|
||||
return `@${canonicalMentionHandle(name)}`;
|
||||
}
|
||||
Reference in new issue
Block a user