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:
ZacharyZhang-NY committed 2026-07-07 00:04:13 -04:00
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]}`;
}
+74
View File
@@ -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();
}
+100
View File
@@ -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 : "";
}
+16
View File
@@ -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}]`));
}
+429
View File
@@ -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(", ")}.`);
}
+121
View File
@@ -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);
}
}
+155
View File
@@ -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 : "";
}
+180
View File
@@ -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)}`;
}
+44
View File
@@ -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");
}
}
+87
View File
@@ -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,
});
}
+105
View File
@@ -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;
}
+471
View File
@@ -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");
}
}
+196
View File
@@ -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;
}
}
+355
View File
@@ -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;
}
+17
View File
@@ -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;
}
+43
View File
@@ -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.");
}
}
+149
View File
@@ -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.");
});
}
}
+103
View File
@@ -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;
}
+21
View File
@@ -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("/") };
}
+84
View File
@@ -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());
}
}
+36
View File
@@ -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.");
}
}
+36
View File
@@ -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;
}
}
+489
View File
@@ -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();
},
});
});
});
}
+23
View File
@@ -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;
}
+49
View File
@@ -0,0 +1,49 @@
/** Minimal HTML shown in the user's browser after the OAuth redirect. */
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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);
}
+30
View File
@@ -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();
}
},
};
+13
View File
@@ -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 };
}
+263
View File
@@ -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";
}
+233
View File
@@ -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;
}
+167
View File
@@ -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];
}
+105
View File
@@ -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;
}
+469
View File
@@ -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 },
});
}
}
+48
View File
@@ -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>;
+453
View File
@@ -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);
};
}
+165
View File
@@ -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";
}
}
+28
View File
@@ -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.`);
};
}
+229
View File
@@ -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[];
}
+101
View File
@@ -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);
}
+264
View File
@@ -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);
}
+381
View File
@@ -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);
}
+177
View File
@@ -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);
}
+76
View File
@@ -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) };
}
+369
View File
@@ -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,
};
});
}
+309
View File
@@ -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;
}
+238
View File
@@ -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)]));
}
+17
View File
@@ -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);
}
}
+85
View File
@@ -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");
}
+76
View File
@@ -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}`);
}
}
+358
View File
@@ -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,
};
}
+209
View File
@@ -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,
};
});
}
+52
View File
@@ -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());
}
}
+47
View File
@@ -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}`));
}
+483
View File
@@ -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,
};
}
+36
View File
@@ -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.");
}
}
+498
View File
@@ -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;
}
}
+203
View File
@@ -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)}`;
}