From b5ad1643381370c74899d6906823c8985d356222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 26 Mar 2026 00:45:03 -0400 Subject: [PATCH] feat(02-03): add 7 skill IPC channels with Zod schemas and register handlers - Add importSkill, listSkills, getSkillDetail, deleteSkill, assignSkillToAgent, removeSkillFromAgent, checkSkillUpdate IPC channels - Add Zod validation schemas for all skill payloads in contracts.ts - Create ipc-skill-handlers.ts with company existence checks and domain-changed events on mutations - Add SkillRecord, SkillImportResult, SkillUpdateStatus types to shared/types.ts - Wire registerSkillHandlers into ipc-domain-handlers.ts - Add skillService to RegisterDomainHandlersDependencies interface --- src/main/ipc-domain-common.ts | 2 + src/main/ipc-domain-handlers.ts | 7 ++ src/main/ipc-skill-handlers.ts | 119 ++++++++++++++++++++++++++++++++ src/shared/contracts.ts | 52 +++++++++++++- src/shared/types.ts | 46 ++++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc-skill-handlers.ts diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index 166cafa9..33a1a55a 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { DesktopResult } from "@shared/contracts"; import type { bootstrapOnboardingSchema } from "@shared/contracts"; import type { AppDatabase } from "./database"; +import type { createSkillService } from "./skill-service"; export interface BrowserActionControl { clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise; @@ -38,6 +39,7 @@ export interface RegisterDomainHandlersDependencies { bootstrapOnboarding: (input: z.infer) => unknown; getBrowserActions: () => BrowserActionControl | null; randomFallbackCompanyId: () => string; + skillService: ReturnType | null; } export function ok(data: T): DesktopResult { diff --git a/src/main/ipc-domain-handlers.ts b/src/main/ipc-domain-handlers.ts index 37441e2b..fd433d48 100644 --- a/src/main/ipc-domain-handlers.ts +++ b/src/main/ipc-domain-handlers.ts @@ -5,6 +5,7 @@ import { registerBrowserHandlers } from "./ipc-browser-handlers"; import { registerContentOperationsHandlers } from "./ipc-content-operations-handlers"; import { registerOnboardingHandlers } from "./ipc-onboarding-handlers"; import { registerRecoveryHandlers } from "./ipc-recovery-handlers"; +import { registerSkillHandlers } from "./ipc-skill-handlers"; export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) { registerOnboardingHandlers(dependencies); @@ -13,4 +14,10 @@ export function registerDomainHandlers(dependencies: RegisterDomainHandlersDepen registerAutomationOperationsHandlers(dependencies); registerAgentCoordinationHandlers(dependencies); registerRecoveryHandlers(dependencies); + registerSkillHandlers({ + registerHandle: dependencies.registerHandle, + ensureCompanyExists: dependencies.ensureCompanyExists, + publishDomainChanged: dependencies.publishDomainChanged, + skillService: dependencies.skillService, + }); } diff --git a/src/main/ipc-skill-handlers.ts b/src/main/ipc-skill-handlers.ts new file mode 100644 index 00000000..156c40af --- /dev/null +++ b/src/main/ipc-skill-handlers.ts @@ -0,0 +1,119 @@ +import { + IPC_CHANNELS, + importSkillSchema, + listSkillsSchema, + getSkillDetailSchema, + deleteSkillSchema, + assignSkillToAgentSchema, + removeSkillFromAgentSchema, + checkSkillUpdateSchema, +} from "@shared/contracts"; +import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; +import type { createSkillService } from "./skill-service"; + +interface SkillHandlerDependencies extends Pick< + RegisterDomainHandlersDependencies, + "registerHandle" | "ensureCompanyExists" | "publishDomainChanged" +> { + skillService: ReturnType | null; +} + +export function registerSkillHandlers({ + registerHandle, + ensureCompanyExists, + publishDomainChanged, + skillService, +}: SkillHandlerDependencies) { + function requireSkillService() { + if (!skillService) { + return fail("SERVICE_NOT_READY", "Skill service is not initialized."); + } + return null; + } + + // ---- Import skill from source (GitHub URL, local path, raw URL, skills.sh) ---- + registerHandle(IPC_CHANNELS.importSkill, async (_event, payload) => { + const parsed = importSkillSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + const result = await skillService!.importFromSource(parsed.companyId, parsed.source); + if (result.ok) { + publishDomainChanged(); + } + return result; + }); + + // ---- List all skills for a company ---- + registerHandle(IPC_CHANNELS.listSkills, async (_event, payload) => { + const parsed = listSkillsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + return ok(skillService!.listForCompany(parsed.companyId)); + }); + + // ---- Get skill detail ---- + registerHandle(IPC_CHANNELS.getSkillDetail, async (_event, payload) => { + const parsed = getSkillDetailSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + return skillService!.getDetail(parsed.companyId, parsed.skillId); + }); + + // ---- Delete a skill ---- + registerHandle(IPC_CHANNELS.deleteSkill, async (_event, payload) => { + const parsed = deleteSkillSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + const result = await skillService!.deleteSkill(parsed.companyId, parsed.skillId); + if (result.ok) { + publishDomainChanged(); + } + return result; + }); + + // ---- Assign skill to agent ---- + registerHandle(IPC_CHANNELS.assignSkillToAgent, async (_event, payload) => { + const parsed = assignSkillToAgentSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + const result = skillService!.assignToAgent(parsed.companyId, parsed.agentId, parsed.skillId); + if (result.ok) { + publishDomainChanged(); + } + return result; + }); + + // ---- Remove skill from agent ---- + registerHandle(IPC_CHANNELS.removeSkillFromAgent, async (_event, payload) => { + const parsed = removeSkillFromAgentSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + const result = skillService!.removeFromAgent(parsed.companyId, parsed.agentId, parsed.skillId); + if (result.ok) { + publishDomainChanged(); + } + return result; + }); + + // ---- Check if skill has update available ---- + registerHandle(IPC_CHANNELS.checkSkillUpdate, async (_event, payload) => { + const parsed = checkSkillUpdateSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + const serviceError = requireSkillService(); + if (serviceError) return serviceError; + return skillService!.checkUpdateStatus(parsed.companyId, parsed.skillId); + }); +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 5ff2552a..a714a915 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, StandupReport } from "./types"; +import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, SkillImportResult, SkillRecord, SkillUpdateStatus, StandupReport } from "./types"; export const IPC_CHANNELS = { loadSnapshot: "agent-company:load-snapshot", @@ -73,6 +73,13 @@ export const IPC_CHANNELS = { runWorkflow: "agent-company:run-workflow", autoAssignTask: "agent-company:auto-assign-task", searchMessages: "agent-company:search-messages", + importSkill: "agent-company:import-skill", + listSkills: "agent-company:list-skills", + getSkillDetail: "agent-company:get-skill-detail", + deleteSkill: "agent-company:delete-skill", + assignSkillToAgent: "agent-company:assign-skill-to-agent", + removeSkillFromAgent: "agent-company:remove-skill-from-agent", + checkSkillUpdate: "agent-company:check-skill-update", } as const; const optionalId = z.preprocess( @@ -514,6 +521,42 @@ export const autoAssignSchema = z.object({ companyId: z.string().uuid(), }); +export const importSkillSchema = z.object({ + companyId: z.string().min(1), + source: z.string().min(1), +}); + +export const listSkillsSchema = z.object({ + companyId: z.string().min(1), +}); + +export const getSkillDetailSchema = z.object({ + companyId: z.string().min(1), + skillId: z.string().min(1), +}); + +export const deleteSkillSchema = z.object({ + companyId: z.string().min(1), + skillId: z.string().min(1), +}); + +export const assignSkillToAgentSchema = z.object({ + companyId: z.string().min(1), + agentId: z.string().min(1), + skillId: z.string().min(1), +}); + +export const removeSkillFromAgentSchema = z.object({ + companyId: z.string().min(1), + agentId: z.string().min(1), + skillId: z.string().min(1), +}); + +export const checkSkillUpdateSchema = z.object({ + companyId: z.string().min(1), + skillId: z.string().min(1), +}); + export type DesktopResult = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } }; @@ -589,6 +632,13 @@ export type DesktopApi = { runWorkflow: (input: z.infer) => Promise>; autoAssignTask: (input: z.infer) => Promise>; searchMessages: (input: z.infer) => Promise>; + importSkill: (companyId: string, source: string) => Promise>; + listSkills: (companyId: string) => Promise>; + getSkillDetail: (companyId: string, skillId: string) => Promise>; + deleteSkill: (companyId: string, skillId: string) => Promise>; + assignSkillToAgent: (companyId: string, agentId: string, skillId: string) => Promise>; + removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise>; + checkSkillUpdate: (companyId: string, skillId: string) => Promise>; recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise>; recoveryDismiss: (taskId: string) => Promise>; recoveryFail: (taskId: string) => Promise>; diff --git a/src/shared/types.ts b/src/shared/types.ts index c3cefc6a..1486a7f1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -453,6 +453,52 @@ export interface OnboardingBootstrapResult { runId: string | null; } +// --------------------------------------------------------------------------- +// Skill system types (mirrored from main/skill-types.ts for IPC boundary) +// --------------------------------------------------------------------------- + +export type SkillSourceType = "github" | "local_path" | "url" | "skills_sh" | "managed"; +export type TrustLevel = "markdown_only" | "assets" | "scripts_executables"; + +export interface FileInventoryEntry { + path: string; + kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other"; +} + +export interface SkillRecord { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: TrustLevel; + compatibility: string; + fileInventory: FileInventoryEntry[]; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +export interface SkillImportResult { + skillId: string; + key: string; + name: string; + isUpdate: boolean; +} + +export interface SkillUpdateStatus { + skillId: string; + key: string; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; +} + export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other"; export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused"; export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search";