blob: 1fbc4c833d26f3d18772786d090b398dc09cf35c [file]
import type { PlanReminder, PlanReminderRunStatus } from './plan-reminders.js';
export const SOURCE_RECORD_TYPES = ['mcp', 'api', 'local'] as const;
export type SourceRecordType = (typeof SOURCE_RECORD_TYPES)[number];
export const SOURCE_AUTH_TYPES = ['oauth', 'bearer', 'none'] as const;
export type SourceAuthType = (typeof SOURCE_AUTH_TYPES)[number];
export const SOURCE_RECORD_STATUSES = ['ready', 'needs_auth', 'error', 'disabled'] as const;
export type SourceRecordStatus = (typeof SOURCE_RECORD_STATUSES)[number];
export const CAPABILITY_AUDIT_PERMISSION_MODES = ['explore', 'ask', 'execute'] as const;
export type CapabilityAuditPermissionMode = (typeof CAPABILITY_AUDIT_PERMISSION_MODES)[number];
export const AUTOMATION_RECORD_TRIGGERS = ['manual', 'schedule', 'event'] as const;
export type AutomationRecordTrigger = (typeof AUTOMATION_RECORD_TRIGGERS)[number];
export const AUTOMATION_LAST_RUN_STATUSES = ['ok', 'error', 'skipped'] as const;
export type AutomationLastRunStatus = (typeof AUTOMATION_LAST_RUN_STATUSES)[number];
export const LOCAL_SKILL_SOURCE_SLUG = 'workspace-skills';
export interface SourceRecord {
slug: string;
name: string;
type: SourceRecordType;
enabled: boolean;
authType: SourceAuthType;
scopeSummary: string[];
status: SourceRecordStatus;
lastTestAt?: number;
lastErrorReason?: string;
}
export interface CapabilityAuditSkillInput {
id: string;
name: string;
description?: string;
declaredTools?: readonly string[];
enabled?: boolean;
sourceSlug?: string;
}
export interface SkillAuditRecord {
id: string;
name: string;
description: string;
declaredTools: string[];
enabled: boolean;
sourceSlug: string;
permissionMode: Exclude<CapabilityAuditPermissionMode, 'execute'>;
}
export interface AutomationRecord {
id: string;
name: string;
enabled: boolean;
trigger: AutomationRecordTrigger;
permissionMode: CapabilityAuditPermissionMode;
lastRunAt?: number;
lastRunStatus?: AutomationLastRunStatus;
}
export interface CapabilityAuditSummary {
sourceCount: number;
readySourceCount: number;
needsAuthSourceCount: number;
errorSourceCount: number;
disabledSourceCount: number;
skillCount: number;
enabledSkillCount: number;
skillsWithDeclaredTools: number;
declaredToolKindCount: number;
automationCount: number;
enabledAutomationCount: number;
executableAutomationCount: number;
failedAutomationCount: number;
skippedAutomationCount: number;
}
export interface CapabilityAuditReport {
checkedAt: number;
sources: SourceRecord[];
skills: SkillAuditRecord[];
automations: AutomationRecord[];
summary: CapabilityAuditSummary;
}
export interface DeriveCapabilityAuditReportInput {
now?: number;
sources?: readonly SourceRecord[];
skills?: readonly CapabilityAuditSkillInput[];
planReminders?: readonly PlanReminder[];
}
export function deriveCapabilityAuditReport(
input: DeriveCapabilityAuditReportInput = {},
): CapabilityAuditReport {
const now = Math.trunc(input.now ?? Date.now());
const skills = normalizeSkillInputs(input.skills ?? []);
const declaredToolKindCount = distinctDeclaredToolKinds(skills).length;
const sources = normalizeSourceRecords(input.sources ?? []);
const needsLocalSkillSource =
sources.length === 0 || skills.some((skill) => skill.sourceSlug === LOCAL_SKILL_SOURCE_SLUG);
const allSources =
needsLocalSkillSource && !sources.some((source) => source.slug === LOCAL_SKILL_SOURCE_SLUG)
? [localSkillSource(skills.length, declaredToolKindCount, now), ...sources]
: sources;
const automations = (input.planReminders ?? []).map(planReminderToAutomationRecord);
return {
checkedAt: now,
sources: allSources,
skills,
automations,
summary: summarizeCapabilityAudit(allSources, skills, automations),
};
}
function normalizeSkillInputs(skills: readonly CapabilityAuditSkillInput[]): SkillAuditRecord[] {
return skills.map((skill, index) => {
const id = normalizeNonEmptyString(skill.id) ?? `skill-${index + 1}`;
const declaredTools = uniqueNonEmptyStrings(skill.declaredTools ?? []);
return {
id,
name: normalizeNonEmptyString(skill.name) ?? id,
description: normalizeNonEmptyString(skill.description) ?? '',
declaredTools,
enabled: skill.enabled ?? true,
sourceSlug: normalizeNonEmptyString(skill.sourceSlug) ?? LOCAL_SKILL_SOURCE_SLUG,
permissionMode: declaredTools.length > 0 ? 'ask' : 'explore',
};
});
}
function normalizeSourceRecords(sources: readonly SourceRecord[]): SourceRecord[] {
return sources.map((source, index) => {
const slug = normalizeNonEmptyString(source.slug) ?? `source-${index + 1}`;
return {
slug,
name: normalizeNonEmptyString(source.name) ?? slug,
type: source.type,
enabled: source.enabled,
authType: source.authType,
scopeSummary: uniqueNonEmptyStrings(source.scopeSummary),
status: source.status,
...(typeof source.lastTestAt === 'number'
? { lastTestAt: Math.trunc(source.lastTestAt) }
: {}),
...(source.lastErrorReason ? { lastErrorReason: source.lastErrorReason } : {}),
};
});
}
function localSkillSource(
skillCount: number,
declaredToolKindCount: number,
now: number,
): SourceRecord {
const hasSkills = skillCount > 0;
return {
slug: LOCAL_SKILL_SOURCE_SLUG,
name: '工作区 skills 目录',
type: 'local',
enabled: hasSkills,
authType: 'none',
scopeSummary: hasSkills
? [`${skillCount} 个本地 Skill`, `${declaredToolKindCount} 类声明工具`]
: ['等待添加本地 Skill'],
status: hasSkills ? 'ready' : 'disabled',
...(hasSkills ? { lastTestAt: now } : { lastErrorReason: '未检测到已安装 Skill' }),
};
}
function planReminderToAutomationRecord(reminder: PlanReminder): AutomationRecord {
return {
id: reminder.id,
name: reminder.title,
enabled: reminder.enabled && reminder.status === 'scheduled',
trigger: 'schedule',
permissionMode: planReminderPermissionMode(reminder),
...(typeof reminder.lastRun?.at === 'number' ? { lastRunAt: reminder.lastRun.at } : {}),
...(reminder.lastRun
? { lastRunStatus: mapPlanReminderRunStatus(reminder.lastRun.status) }
: {}),
};
}
function planReminderPermissionMode(reminder: PlanReminder): CapabilityAuditPermissionMode {
if (reminder.status === 'completed') return 'explore';
if (!reminder.enabled || reminder.status === 'paused') return 'ask';
return 'execute';
}
function mapPlanReminderRunStatus(status: PlanReminderRunStatus): AutomationLastRunStatus {
if (status === 'triggered') return 'ok';
if (status === 'blocked') return 'skipped';
return 'error';
}
function summarizeCapabilityAudit(
sources: readonly SourceRecord[],
skills: readonly SkillAuditRecord[],
automations: readonly AutomationRecord[],
): CapabilityAuditSummary {
return {
sourceCount: sources.length,
readySourceCount: sources.filter((source) => source.status === 'ready').length,
needsAuthSourceCount: sources.filter((source) => source.status === 'needs_auth').length,
errorSourceCount: sources.filter((source) => source.status === 'error').length,
disabledSourceCount: sources.filter((source) => source.status === 'disabled').length,
skillCount: skills.length,
enabledSkillCount: skills.filter((skill) => skill.enabled).length,
skillsWithDeclaredTools: skills.filter((skill) => skill.declaredTools.length > 0).length,
declaredToolKindCount: distinctDeclaredToolKinds(skills).length,
automationCount: automations.length,
enabledAutomationCount: automations.filter((automation) => automation.enabled).length,
executableAutomationCount: automations.filter(
(automation) => automation.permissionMode === 'execute',
).length,
failedAutomationCount: automations.filter((automation) => automation.lastRunStatus === 'error')
.length,
skippedAutomationCount: automations.filter(
(automation) => automation.lastRunStatus === 'skipped',
).length,
};
}
function distinctDeclaredToolKinds(
skills: readonly Pick<SkillAuditRecord, 'declaredTools'>[],
): string[] {
return uniqueNonEmptyStrings(skills.flatMap((skill) => skill.declaredTools));
}
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = normalizeNonEmptyString(value);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function normalizeNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.normalize('NFC').replace(/\s+/g, ' ').trim();
return normalized.length > 0 ? normalized : undefined;
}