Make the CEO the grant authority: implicit all-access, agent-tool grants, no manual assignment UI
This commit is contained in:
1 parent
934fa71486
commit
defe8f12b1
15 files changed
+327
-190
No files matched your search
@@ -28,7 +28,6 @@ import { AutomationService } from "../services/automation-service";
|
||||
import { MeetingService } from "../services/meeting-service";
|
||||
import { SprintService } from "../services/sprint-service";
|
||||
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
|
||||
import { ConnectorGrantsService } from "../services/composio/connector-grants";
|
||||
import { syncSkillsFromDisk } from "../services/skill-discovery";
|
||||
import { coalesce } from "./coalesce";
|
||||
import { normalizeExternalBrowserUrl } from "./external-url";
|
||||
@@ -257,7 +256,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
syncSkillsFromDisk(deps.connection.db, skills, input.orgId);
|
||||
return null;
|
||||
}));
|
||||
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
|
||||
@@ -327,7 +325,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
});
|
||||
|
||||
const composioConnections = new ComposioConnectionService(deps.connection.db);
|
||||
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => {
|
||||
try {
|
||||
@@ -354,19 +351,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => connectorGrants.grant({
|
||||
orgId: input.orgId,
|
||||
agentId: input.agentId,
|
||||
toolkitSlug: input.toolkitSlug,
|
||||
grantedByType: "employee",
|
||||
grantedById: input.employeeId,
|
||||
})));
|
||||
ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => connectorGrants.revoke({
|
||||
orgId: input.orgId,
|
||||
agentId: input.agentId,
|
||||
toolkitSlug: input.toolkitSlug,
|
||||
})));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import { SkillService } from "./skill-service";
|
||||
|
||||
/** Company skill library visibility for any agent: what exists, who holds it. */
|
||||
export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): string {
|
||||
const skills = deps.db.prepare(
|
||||
"select id, name, slug from skill_definitions where org_id = ? and status = 'active' order by slug asc",
|
||||
).all(ctx.orgId) as Array<{ id: string; name: string; slug: string }>;
|
||||
if (skills.length === 0) {
|
||||
return "The company has no skills yet. The board imports them on the Settings page.";
|
||||
}
|
||||
const bindings = deps.db.prepare(
|
||||
"select skill_id, agent_id from skill_bindings where org_id = ? and enabled = 1",
|
||||
).all(ctx.orgId) as Array<{ skill_id: string; agent_id: string }>;
|
||||
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 = skills.map((skill) => {
|
||||
const holders = bindings
|
||||
.filter((binding) => binding.skill_id === skill.id)
|
||||
.map((binding) => agentNames.get(binding.agent_id))
|
||||
.filter(Boolean);
|
||||
return `- ${skill.name} (${skill.slug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||
});
|
||||
return ["Company skills (the CEO carries all of them; teammates need a grant):", ...lines].join("\n");
|
||||
}
|
||||
|
||||
/** CEO-only: hand a skill to a teammate, or take it back. */
|
||||
export function grantSkillTool(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 skill grants. Ask them to run this.";
|
||||
}
|
||||
const agentName = requireString(args.agent_name, "agent_name");
|
||||
const skillSlug = requireString(args.skill, "skill").trim().toLowerCase();
|
||||
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 skill = deps.db.prepare(
|
||||
"select id, name from skill_definitions where org_id = ? and slug = ? and status = 'active' limit 1",
|
||||
).get(ctx.orgId, skillSlug) as { id: string; name: string } | undefined;
|
||||
if (!skill) return `ERROR: No skill "${skillSlug}" exists. Run list_skills to see the library.`;
|
||||
const skillService = new SkillService(deps.db, "");
|
||||
if (mode === "grant") {
|
||||
const revision = deps.db.prepare(
|
||||
"select id from skill_revisions where skill_id = ? order by published_at desc, id desc limit 1",
|
||||
).get(skill.id) as { id: string } | undefined;
|
||||
if (!revision) return `ERROR: The "${skillSlug}" skill has no published revision.`;
|
||||
skillService.bind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id, revisionId: revision.id });
|
||||
return `${target.name} now has the ${skill.name} skill; it joins their next run.`;
|
||||
}
|
||||
skillService.unbind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id });
|
||||
return `${target.name} no longer has the ${skill.name} skill.`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { grantSkillTool, listSkillsTool } from "./agent-skill-tools";
|
||||
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
|
||||
import { describeSend } from "./agent-message-presence";
|
||||
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
|
||||
@@ -172,6 +173,37 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
required: ["agent_name", "toolkit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_skills",
|
||||
description: "See the company's skill library and who holds each skill. You carry every skill automatically as CEO; teammates only run with skills you grant them.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: "grant_skill",
|
||||
description: "CEO only: grant a teammate one of the company's skills so its instructions join their next run. Match the skill to the job.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent_name: { type: "string", description: "The teammate receiving the skill." },
|
||||
skill: { type: "string", description: "Skill slug from list_skills, e.g. 'release-gate'." },
|
||||
},
|
||||
required: ["agent_name", "skill"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revoke_skill",
|
||||
description: "CEO only: withdraw a teammate's skill.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent_name: { type: "string", description: "The teammate losing the skill." },
|
||||
skill: { type: "string", description: "Skill slug, e.g. 'release-gate'." },
|
||||
},
|
||||
required: ["agent_name", "skill"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function dispatchAgentTool(
|
||||
@@ -202,6 +234,12 @@ export function dispatchAgentTool(
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "grant");
|
||||
case "revoke_connector":
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "revoke");
|
||||
case "list_skills":
|
||||
return listSkillsTool(deps, ctx);
|
||||
case "grant_skill":
|
||||
return grantSkillTool(deps, ctx, call.arguments, "grant");
|
||||
case "revoke_skill":
|
||||
return grantSkillTool(deps, ctx, call.arguments, "revoke");
|
||||
default:
|
||||
return `ERROR: unknown tool "${call.name}".`;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ export interface ConnectorGrantRow {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* company (one connected account per toolkit per org); the CEO carries every
|
||||
* active connection by role and hands individual toolkits to individual
|
||||
* agents. Both the tool loadout and the execution guard read from here.
|
||||
*/
|
||||
export class ConnectorGrantsService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
@@ -46,8 +46,17 @@ export class ConnectorGrantsService {
|
||||
.run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug));
|
||||
}
|
||||
|
||||
/** Toolkits this agent may use: granted AND actively connected. */
|
||||
/**
|
||||
* Toolkits this agent may use. The CEO carries every actively connected
|
||||
* toolkit by role; every other agent needs a grant AND an active
|
||||
* connection.
|
||||
*/
|
||||
toolkitsForAgent(orgId: string, agentId: string): string[] {
|
||||
const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(agentId, orgId) as { role_level: string } | undefined;
|
||||
if (acting?.role_level === "c_suite") {
|
||||
return this.activeAccounts(orgId).map((account) => account.toolkitSlug);
|
||||
}
|
||||
const rows = this.db.prepare(`
|
||||
select g.toolkit_slug as slug
|
||||
from agent_connector_grants g
|
||||
|
||||
@@ -122,25 +122,79 @@ export class SkillService {
|
||||
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;
|
||||
unbind(input: { orgId: string; agentId: string; skillId: string }): void {
|
||||
const existing = this.db.prepare("select id from skill_bindings where org_id = ? and agent_id = ? and skill_id = ?")
|
||||
.get(input.orgId, input.agentId, input.skillId) as { id: string } | undefined;
|
||||
if (!existing) return;
|
||||
this.db.prepare("delete from skill_bindings where id = ?").run(existing.id);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_binding",
|
||||
entityId: existing.id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "unbound",
|
||||
payload: { agentId: input.agentId, skillId: input.skillId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a run for this agent would compile at least one skill: the CEO
|
||||
* carries every active skill by role; other agents need enabled bindings.
|
||||
*/
|
||||
hasRuntimeSkills(orgId: string, agentId: string): boolean {
|
||||
if (this.isCSuite(orgId, agentId)) {
|
||||
return Boolean(this.db.prepare(`
|
||||
select sd.id from skill_definitions sd
|
||||
join skill_revisions sr on sr.skill_id = sd.id
|
||||
where sd.org_id = ? and sd.status = 'active'
|
||||
limit 1
|
||||
`).get(orgId));
|
||||
}
|
||||
return Boolean(this.db.prepare(
|
||||
"select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1",
|
||||
).get(orgId, agentId));
|
||||
}
|
||||
|
||||
private isCSuite(orgId: string, agentId: string): boolean {
|
||||
const agent = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(agentId, orgId) as { role_level: string } | undefined;
|
||||
if (!agent) throw new Error("Agent was not found.");
|
||||
return agent.role_level === "c_suite";
|
||||
}
|
||||
|
||||
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
|
||||
const cSuite = this.isCSuite(input.orgId, input.agentId);
|
||||
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[];
|
||||
const rows = (cSuite
|
||||
? this.db.prepare(`
|
||||
select
|
||||
sr.id as revision_id,
|
||||
sr.version_semver,
|
||||
sr.manifest_json,
|
||||
sa.object_key
|
||||
from skill_definitions sd
|
||||
join skill_revisions sr on sr.id = (
|
||||
select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1
|
||||
)
|
||||
join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown')
|
||||
where sd.org_id = ? and sd.status = 'active'
|
||||
order by sr.published_at asc
|
||||
`).all(input.orgId)
|
||||
: 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[] = [];
|
||||
|
||||
@@ -428,12 +428,7 @@ export class TaskExecutionService {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!this.skills.hasRuntimeSkills(orgId, agentId)) return null;
|
||||
const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId });
|
||||
const snapshot = this.db.prepare(`
|
||||
select id, compiled_prompt, tools_manifest, source_revision_ids
|
||||
|
||||
@@ -57,7 +57,6 @@ const api: DesktopApi = {
|
||||
cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input),
|
||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||
syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input),
|
||||
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
|
||||
resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input),
|
||||
runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input),
|
||||
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
|
||||
@@ -86,8 +85,6 @@ const api: DesktopApi = {
|
||||
listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog),
|
||||
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
|
||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||
subscribe: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "../shared/ui";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
import { ConnectedList } from "./connectors/ConnectedList";
|
||||
import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
|
||||
import { useConnectorCatalog } from "./connectors/useConnectorCatalog";
|
||||
|
||||
/**
|
||||
* The company's connector control room: what is connected, who may use it,
|
||||
* and the configured catalog to connect more. Connections are org-level; the
|
||||
* grant chips decide which agents carry each toolkit's tools.
|
||||
* The company's connector control room: what is connected, who holds it, and
|
||||
* the configured catalog to connect more. Connections are org-level; the CEO
|
||||
* decides which agents carry each toolkit's tools.
|
||||
*/
|
||||
export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const employee = requireActiveEmployee(snapshot);
|
||||
const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
|
||||
const accounts = useMemo(
|
||||
@@ -40,12 +39,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
const grant = (agentId: string, toolkitSlug: string) =>
|
||||
void actions.run(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id }));
|
||||
|
||||
const revoke = (agentId: string, toolkitSlug: string) =>
|
||||
void actions.run(() => window.agentCompany.revokeConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id }));
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1060 }}>
|
||||
<h2 style={{ margin: "0 0 18px", fontSize: 16, fontWeight: 600 }}>Connectors</h2>
|
||||
@@ -65,14 +58,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectedList
|
||||
accounts={accounts}
|
||||
grants={grants}
|
||||
agents={agents}
|
||||
logoBySlug={logoBySlug}
|
||||
onGrant={grant}
|
||||
onRevoke={revoke}
|
||||
/>
|
||||
<ConnectedList accounts={accounts} grants={grants} agents={agents} logoBySlug={logoBySlug} />
|
||||
|
||||
{catalog.phase === "error" && (
|
||||
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
|
||||
|
||||
@@ -14,95 +14,52 @@ const STATUS_TONE: Record<string, "green" | "amber" | "red" | undefined> = {
|
||||
|
||||
/**
|
||||
* The company's connected toolkits, each with the roster of agents the CEO
|
||||
* or the board has granted access to. Toggling a chip grants or revokes.
|
||||
* has granted access to. Grants are the CEO's call — made with its
|
||||
* grant_connector tool — so the roster here is read-only.
|
||||
*/
|
||||
export function ConnectedList({
|
||||
accounts,
|
||||
grants,
|
||||
agents,
|
||||
logoBySlug,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
accounts: ConnectorAccountRecord[];
|
||||
grants: ConnectorGrantRecord[];
|
||||
agents: AgentRecord[];
|
||||
logoBySlug: Map<string, string | null>;
|
||||
onGrant: (agentId: string, toolkitSlug: string) => void;
|
||||
onRevoke: (agentId: string, toolkitSlug: string) => void;
|
||||
}) {
|
||||
if (accounts.length === 0) return null;
|
||||
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<div className="section-label" style={{ marginBottom: 12 }}>Connected</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{accounts.map((account) => (
|
||||
<ConnectedRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
grantedAgentIds={new Set(grants.filter((grant) => grant.toolkitSlug === account.toolkitSlug).map((grant) => grant.agentId))}
|
||||
agents={agents}
|
||||
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
|
||||
onGrant={onGrant}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
))}
|
||||
{accounts.map((account) => {
|
||||
const holders = grants
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
return (
|
||||
<div key={account.id} style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<ToolkitTile
|
||||
name={account.toolkitName}
|
||||
slug={account.toolkitSlug}
|
||||
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
|
||||
size={30}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||
</div>
|
||||
{holders.length > 0 && (
|
||||
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
|
||||
{holders.map((name) => <Pill key={name}>{name}</Pill>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedRow({
|
||||
account,
|
||||
grantedAgentIds,
|
||||
agents,
|
||||
logoUrl,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
account: ConnectorAccountRecord;
|
||||
grantedAgentIds: Set<string>;
|
||||
agents: AgentRecord[];
|
||||
logoUrl: string | null;
|
||||
onGrant: (agentId: string, toolkitSlug: string) => void;
|
||||
onRevoke: (agentId: string, toolkitSlug: string) => void;
|
||||
}) {
|
||||
const active = account.status === "ACTIVE";
|
||||
return (
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} logoUrl={logoUrl} size={30} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||
</div>
|
||||
{active && agents.length > 0 && (
|
||||
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
|
||||
{agents.map((agent) => {
|
||||
const granted = grantedAgentIds.has(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
aria-pressed={granted}
|
||||
aria-label={`${granted ? "Revoke" : "Grant"} ${account.toolkitName} for ${agent.name}`}
|
||||
onClick={() => (granted ? onRevoke(agent.id, account.toolkitSlug) : onGrant(agent.id, account.toolkitSlug))}
|
||||
style={{
|
||||
border: granted ? "1px solid var(--accent)" : "1px solid var(--border-strong)",
|
||||
background: granted ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: granted ? "var(--accent)" : "var(--text-2)",
|
||||
borderRadius: 999,
|
||||
padding: "4px 12px",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 540,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
})).then((result) => { if (result) setImportOpen(false); });
|
||||
};
|
||||
|
||||
const bind = (skill: SkillDefinitionRecord, agentId: string) => {
|
||||
const latest = revisions.find((revision) => revision.skillId === skill.id);
|
||||
if (!latest) return;
|
||||
void actions.run(() => window.agentCompany.bindSkill({ orgId, agentId, skillId: skill.id, revisionId: latest.id }));
|
||||
};
|
||||
|
||||
const runTests = (revisionId: string) => {
|
||||
void actions.run(() => window.agentCompany.runSkillTests({ orgId, revisionId }));
|
||||
};
|
||||
@@ -61,9 +55,9 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)" }}
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px" }}
|
||||
>
|
||||
<div>Skill</div><div>Version</div><div>Tests</div><div>Bindings</div><div>Actions</div>
|
||||
<div>Skill</div><div>Version</div><div>Tests</div><div>Held by</div><div>Actions</div>
|
||||
</div>
|
||||
{skills.map((skill, index) => {
|
||||
const last = index === skills.length - 1;
|
||||
@@ -74,7 +68,7 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<div
|
||||
key={skill.id}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{skill.name}</div>
|
||||
@@ -87,9 +81,8 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<Pill key={binding.id}>{agentName(binding.agentId, agents)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||
<div className="row" style={{ minWidth: 0 }}>
|
||||
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>Run tests</Button>
|
||||
<BindAgent skill={skill} agents={agents} onBind={(agentId) => bind(skill, agentId)} disabled={!latest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -104,35 +97,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
);
|
||||
}
|
||||
|
||||
function BindAgent({
|
||||
skill: _skill,
|
||||
agents,
|
||||
onBind,
|
||||
disabled,
|
||||
}: {
|
||||
skill: SkillDefinitionRecord;
|
||||
agents: AgentRecord[];
|
||||
onBind: (agentId: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
if (agents.length === 0) return null;
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
style={{ height: 28, padding: "0 8px", fontSize: 12 }}
|
||||
defaultValue=""
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onBind(event.target.value);
|
||||
event.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Pick an agent…</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function agentName(agentId: string, agents: AgentRecord[]): string {
|
||||
return agents.find((agent) => agent.id === agentId)?.name ?? agentId;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,3 @@ export interface ConnectComposioToolkitResult {
|
||||
export interface RefreshComposioAccountsInput {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface ConnectorGrantInput {
|
||||
orgId: string;
|
||||
agentId: string;
|
||||
toolkitSlug: string;
|
||||
employeeId: string;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { DesktopResult } from "./result";
|
||||
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
||||
import type { ProviderModelEntry } from "./provider-models";
|
||||
import type {
|
||||
BindSkillInput,
|
||||
BootstrapOnboardingInput,
|
||||
BootstrapOnboardingSummary,
|
||||
CancelProviderResponseInput,
|
||||
@@ -67,7 +66,6 @@ import type {
|
||||
ConnectComposioToolkitInput,
|
||||
ConnectComposioToolkitResult,
|
||||
ConnectorCatalogEntry,
|
||||
ConnectorGrantInput,
|
||||
RefreshComposioAccountsInput,
|
||||
SearchInput,
|
||||
SendMessageInput,
|
||||
@@ -153,7 +151,6 @@ export interface DesktopApi {
|
||||
cancelProviderResponse(input: CancelProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
syncSkillsFromDisk(input: SyncSkillsFromDiskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
resolveAgentSkills(input: ResolveAgentSkillsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
runSkillTests(input: RunSkillTestsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
@@ -182,8 +179,6 @@ export interface DesktopApi {
|
||||
listConnectorCatalog(): Promise<DesktopResult<ConnectorCatalogEntry[]>>;
|
||||
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
|
||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
grantConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
revokeConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
|
||||
@@ -34,7 +34,6 @@ export const IPC_CHANNELS = {
|
||||
cancelProviderResponse: "agentcompany:cancel-provider-response",
|
||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||
syncSkillsFromDisk: "agentcompany:sync-skills-from-disk",
|
||||
bindSkill: "agentcompany:bind-skill",
|
||||
resolveAgentSkills: "agentcompany:resolve-agent-skills",
|
||||
runSkillTests: "agentcompany:run-skill-tests",
|
||||
createSocialAccount: "agentcompany:create-social-account",
|
||||
@@ -78,8 +77,6 @@ export const IPC_CHANNELS = {
|
||||
listConnectorCatalog: "agentcompany:list-connector-catalog",
|
||||
connectComposioToolkit: "agentcompany:connect-composio-toolkit",
|
||||
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
|
||||
grantConnector: "agentcompany:grant-connector",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
windowMinimize: "agentcompany:window-minimize",
|
||||
|
||||
@@ -83,7 +83,8 @@ describe("connector grants", () => {
|
||||
|
||||
const grants = new ConnectorGrantsService(h.connection.db);
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["github"]);
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual([]);
|
||||
// The CEO holds every active connection by role, without a grant row.
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]);
|
||||
|
||||
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}));
|
||||
expect(listing).toContain("GitHub (github): granted to Dana");
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
orgId: string;
|
||||
ceoId: string;
|
||||
danaId: string;
|
||||
ceoCtx: AgentToolContext;
|
||||
danaCtx: AgentToolContext;
|
||||
deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService };
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-skill-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite",
|
||||
});
|
||||
danaId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
const context = (agentId: string): AgentToolContext => ({
|
||||
orgId,
|
||||
agent: { id: agentId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: null,
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
orgId,
|
||||
ceoId,
|
||||
danaId,
|
||||
ceoCtx: context(ceoId),
|
||||
danaCtx: context(danaId),
|
||||
deps: { db: connection.db, organization, chat: new ChatService(connection.db) },
|
||||
};
|
||||
}
|
||||
|
||||
function importSkill(h: Harness, name: string, slug: string): void {
|
||||
const now = new Date().toISOString();
|
||||
h.connection.db.prepare(`
|
||||
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
|
||||
values (?, ?, ?, ?, 'organization', 'active', ?)
|
||||
`).run(`skl_${slug}`, h.orgId, slug, name, now);
|
||||
h.connection.db.prepare(`
|
||||
insert into skill_revisions(id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum)
|
||||
values (?, ?, '1.0.0', '{}', 'local_directory', '/tmp', ?, 'checksum')
|
||||
`).run(`skr_${slug}`, `skl_${slug}`, now);
|
||||
}
|
||||
|
||||
describe("skill grants", () => {
|
||||
test("the CEO grants and revokes skills for teammates", () => {
|
||||
const h = harness();
|
||||
importSkill(h, "Release Gate", "release-gate");
|
||||
|
||||
const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" }));
|
||||
expect(granted).toContain("Dana now has the Release Gate skill");
|
||||
|
||||
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_skills", {}));
|
||||
expect(listing).toContain("Release Gate (release-gate): granted to Dana");
|
||||
|
||||
const skills = new SkillService(h.connection.db, "");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(true);
|
||||
|
||||
const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "release-gate" }));
|
||||
expect(revoked).toContain("no longer has the Release Gate skill");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false);
|
||||
});
|
||||
|
||||
test("staff cannot hand out skills", () => {
|
||||
const h = harness();
|
||||
importSkill(h, "Release Gate", "release-gate");
|
||||
const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" }));
|
||||
expect(denied).toContain("ERROR");
|
||||
expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId)).toBe(false);
|
||||
});
|
||||
|
||||
test("the CEO carries every active skill by role, without bindings", () => {
|
||||
const h = harness();
|
||||
const skills = new SkillService(h.connection.db, "");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(false);
|
||||
importSkill(h, "Release Gate", "release-gate");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user