chore(09-02): merge main to get plan 01 infrastructure
- Resolve merge conflicts in worker-service.ts and ipc-core-work-graph-handlers.ts - Keep granular emitEvent calls from plan 02 while preserving session management and task validation from main - Fix duplicate createSkillService import in service-graph.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
ffab9888ed
1169 files changed
+457249
-237
No files matched your search
+16
-1
@@ -8,6 +8,7 @@ import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, c
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { inferTaskType } from "./connector-matching";
|
||||
import { validateTaskCreation } from "./task-quality-gates";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
import type { DesktopEvent, SectionId } from "@shared/types";
|
||||
|
||||
@@ -355,7 +356,7 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
const previousStatus = task.status;
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
const success = this.db.checkoutTask(params.issueId!, token.runId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
@@ -561,6 +562,12 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
const isHeartbeat = input.title.startsWith("[Heartbeat]");
|
||||
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat);
|
||||
if (!taskValidation.valid) {
|
||||
this.json(res, 422, { error: taskValidation.errors.join("; ") });
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
@@ -1582,6 +1589,14 @@ export class AgentApiServer {
|
||||
if (actionItems.length > 0) {
|
||||
for (const item of actionItems) {
|
||||
if (item.title && typeof item.title === "string") {
|
||||
const meetingTaskValidation = validateTaskCreation(
|
||||
{ title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}` },
|
||||
false,
|
||||
);
|
||||
if (!meetingTaskValidation.valid) {
|
||||
// Log but skip invalid meeting action items rather than failing the whole request
|
||||
continue;
|
||||
}
|
||||
const taskId = this.db.saveTask({
|
||||
companyId: token.companyId,
|
||||
title: item.title,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RunStatus,
|
||||
} from "@shared/types";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
import { validateTaskCreation } from "./task-quality-gates";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
|
||||
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
|
||||
@@ -268,6 +269,12 @@ export function createAutomationService({
|
||||
const description = (config.description as string) ?? "";
|
||||
const priority = (config.priority as string) ?? "medium";
|
||||
const assigneeAgentId = (config.assigneeAgentId as string) ?? null;
|
||||
const isHeartbeat = title.startsWith("[Heartbeat]");
|
||||
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
|
||||
if (!taskValidation.valid) {
|
||||
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
|
||||
break;
|
||||
}
|
||||
const taskId = db.saveTask({
|
||||
companyId: ctx.companyId,
|
||||
projectId: ctx.projectId ?? (config.projectId as string) ?? null,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types";
|
||||
|
||||
interface BudgetEnforcementDependencies {
|
||||
db: AppDatabase;
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
notify: (options: NotificationOptions) => void;
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export interface BudgetGateResult {
|
||||
allowed: boolean;
|
||||
blockedBy: Array<{
|
||||
policyId: string;
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
spent: number;
|
||||
limit: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for a budget policy scope.
|
||||
* Looks up entity names from the database for agent and project scopes.
|
||||
*/
|
||||
function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string {
|
||||
if (policy.scopeType === "agent") {
|
||||
try {
|
||||
const agents = db.loadSnapshot(null).agents;
|
||||
const agent = agents.find((a) => a.id === policy.scopeId);
|
||||
return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`;
|
||||
} catch {
|
||||
return `Agent ${policy.scopeId.slice(0, 8)}`;
|
||||
}
|
||||
}
|
||||
if (policy.scopeType === "project") {
|
||||
try {
|
||||
const projects = db.loadSnapshot(null).projects;
|
||||
const project = projects.find((p) => p.id === policy.scopeId);
|
||||
return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`;
|
||||
} catch {
|
||||
return `Project ${policy.scopeId.slice(0, 8)}`;
|
||||
}
|
||||
}
|
||||
return "Company budget";
|
||||
}
|
||||
|
||||
export function createBudgetEnforcementService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
publishDomainChanged,
|
||||
}: BudgetEnforcementDependencies) {
|
||||
/**
|
||||
* Evaluates whether a new run should be allowed based on active budget policies.
|
||||
*
|
||||
* Per D-01 (strictest-wins): if ANY policy scope is exhausted, the run is blocked.
|
||||
* Per D-02: if no policies exist, the run is allowed (unlimited).
|
||||
* Per D-04/D-05: this only blocks NEW dispatches -- in-flight runs are unaffected.
|
||||
*/
|
||||
function evaluateBudgetGate(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
projectId: string | null,
|
||||
): BudgetGateResult {
|
||||
const policies = db.getActivePoliciesForRun(companyId, agentId, projectId);
|
||||
if (policies.length === 0) {
|
||||
return { allowed: true, blockedBy: [] };
|
||||
}
|
||||
|
||||
const blockedBy: BudgetGateResult["blockedBy"] = [];
|
||||
for (const policy of policies) {
|
||||
if (!policy.hardStopEnabled) continue;
|
||||
const spent = db.getSpentInWindow(policy);
|
||||
if (spent >= policy.amount) {
|
||||
blockedBy.push({
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
spent,
|
||||
limit: policy.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: blockedBy.length === 0, blockedBy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all active policies for the given run context and creates/updates
|
||||
* budget incidents at warn and hard-stop thresholds.
|
||||
*
|
||||
* Per D-07: fires both warn and hard-stop in the same code path.
|
||||
* Per D-08: deduplicates incidents via upsertBudgetIncident.
|
||||
*/
|
||||
function checkAndCreateIncidents(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
projectId: string | null,
|
||||
): void {
|
||||
const policies = db.getActivePoliciesForRun(companyId, agentId, projectId);
|
||||
|
||||
for (const policy of policies) {
|
||||
// Guard against division by zero
|
||||
if (policy.amount <= 0) continue;
|
||||
|
||||
const spent = db.getSpentInWindow(policy);
|
||||
const utilization = spent / policy.amount;
|
||||
const warnThreshold = policy.warnPercent / 100;
|
||||
const scopeLabel = getScopeLabel(db, policy);
|
||||
|
||||
if (utilization >= warnThreshold) {
|
||||
db.upsertBudgetIncident(policy, "warn_threshold", spent);
|
||||
if (policy.notifyEnabled) {
|
||||
notify({
|
||||
title: "Budget Warning",
|
||||
body: `${scopeLabel} at ${Math.round(utilization * 100)}% of budget ($${(spent / 100).toFixed(2)} / $${(policy.amount / 100).toFixed(2)})`,
|
||||
urgency: "informational",
|
||||
navigation: { section: "costs" },
|
||||
batchKey: "budget_warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (utilization >= 1.0 && policy.hardStopEnabled) {
|
||||
db.upsertBudgetIncident(policy, "hard_stop", spent);
|
||||
if (policy.notifyEnabled) {
|
||||
notify({
|
||||
title: "Budget Exhausted",
|
||||
body: `${scopeLabel} budget exhausted - new runs blocked`,
|
||||
urgency: "critical",
|
||||
navigation: { section: "costs" },
|
||||
batchKey: "budget_exhaustion",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a policy's limit was increased above the observed spend amount,
|
||||
* and auto-resolves open incidents if so.
|
||||
*
|
||||
* Per D-06: fires auto_resolved_limit_increase status.
|
||||
*/
|
||||
function checkAutoResolve(companyId: string, policyId: string): void {
|
||||
const policy = db.getBudgetPolicy(policyId);
|
||||
if (!policy) return;
|
||||
|
||||
const spent = db.getSpentInWindow(policy);
|
||||
let resolved = false;
|
||||
|
||||
// If spend is below hard-stop threshold, resolve all open incidents
|
||||
if (spent < policy.amount) {
|
||||
const count = db.resolveIncidentsForPolicy(policyId, "auto_resolved_limit_increase");
|
||||
if (count > 0) resolved = true;
|
||||
}
|
||||
|
||||
// If spend is below warn threshold, resolve warn incidents specifically
|
||||
const warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100);
|
||||
if (spent < warnAmount) {
|
||||
const count = db.resolveWarnIncidentsForPolicy(policyId, "auto_resolved_limit_increase");
|
||||
if (count > 0) resolved = true;
|
||||
}
|
||||
|
||||
if (resolved) {
|
||||
logger.info(`Budget auto-resolve: policy ${policyId} incidents resolved (spent=${spent}, limit=${policy.amount})`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes incidents whose calendar month window has ended.
|
||||
* Per D-06: window rollover cleanup.
|
||||
*/
|
||||
function autoCloseExpiredIncidents(companyId: string): number {
|
||||
const count = db.autoCloseExpiredIncidents(companyId);
|
||||
if (count > 0) {
|
||||
logger.info(`Budget auto-close: ${count} expired incident(s) for company ${companyId}`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a summary of all budget policies and their current spend status.
|
||||
*/
|
||||
function getBudgetStatus(companyId: string): BudgetStatusSummary {
|
||||
const policies = db.listBudgetPolicies(companyId);
|
||||
const openIncidents = db.listBudgetIncidents(companyId, "open");
|
||||
|
||||
return {
|
||||
policies: policies.map((policy) => {
|
||||
const spent = db.getSpentInWindow(policy);
|
||||
const hasOpenIncident = openIncidents.some((i) => i.policyId === policy.id);
|
||||
return {
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
amount: policy.amount,
|
||||
spent,
|
||||
warnPercent: policy.warnPercent,
|
||||
hardStopEnabled: policy.hardStopEnabled,
|
||||
hasOpenIncident,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluateBudgetGate,
|
||||
checkAndCreateIncidents,
|
||||
checkAutoResolve,
|
||||
autoCloseExpiredIncidents,
|
||||
getBudgetStatus,
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
sessionCodec: codexSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: codexModels,
|
||||
},
|
||||
listModels: async () => codexModels,
|
||||
@@ -81,6 +82,7 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
@@ -105,6 +107,7 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
nativeContextManagement: false,
|
||||
models: geminiModels,
|
||||
},
|
||||
listModels: async () => geminiModels,
|
||||
|
||||
+1054
-47
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
budgetPolicyInputSchema,
|
||||
listBudgetPoliciesSchema,
|
||||
deleteBudgetPolicySchema,
|
||||
listBudgetIncidentsSchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
getBudgetStatusSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
interface BudgetHandlerDependencies extends Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
"registerHandle" | "ensureCompanyExists" | "publishDomainChanged"
|
||||
> {
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService> | null;
|
||||
db: AppDatabase;
|
||||
}
|
||||
|
||||
export function registerBudgetHandlers({
|
||||
registerHandle,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
budgetService,
|
||||
db,
|
||||
}: BudgetHandlerDependencies) {
|
||||
function requireBudgetService() {
|
||||
if (!budgetService) {
|
||||
return fail("SERVICE_NOT_READY", "Budget enforcement service is not initialized.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Save (create/update) a budget policy ----
|
||||
registerHandle(IPC_CHANNELS.saveBudgetPolicy, async (_event, payload) => {
|
||||
const parsed = budgetPolicyInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const id = db.saveBudgetPolicy(parsed);
|
||||
// D-06: Auto-resolve incidents if limit was increased above current spend
|
||||
if (budgetService) {
|
||||
try {
|
||||
budgetService.checkAutoResolve(parsed.companyId, id);
|
||||
} catch {
|
||||
// Best effort -- service may not be ready
|
||||
}
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
// ---- Delete a budget policy ----
|
||||
registerHandle(IPC_CHANNELS.deleteBudgetPolicy, async (_event, payload) => {
|
||||
const parsed = deleteBudgetPolicySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
db.deleteBudgetPolicy(parsed.companyId, parsed.policyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
// ---- List all budget policies for a company ----
|
||||
registerHandle(IPC_CHANNELS.listBudgetPolicies, async (_event, payload) => {
|
||||
const parsed = listBudgetPoliciesSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.listBudgetPolicies(parsed.companyId));
|
||||
});
|
||||
|
||||
// ---- List budget incidents ----
|
||||
registerHandle(IPC_CHANNELS.listBudgetIncidents, async (_event, payload) => {
|
||||
const parsed = listBudgetIncidentsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.listBudgetIncidents(parsed.companyId, parsed.status));
|
||||
});
|
||||
|
||||
// ---- Resolve a budget incident manually ----
|
||||
registerHandle(IPC_CHANNELS.resolveBudgetIncident, async (_event, payload) => {
|
||||
const parsed = resolveBudgetIncidentSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
db.resolveBudgetIncident(parsed.incidentId, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
// ---- Get budget status summary ----
|
||||
registerHandle(IPC_CHANNELS.getBudgetStatus, async (_event, payload) => {
|
||||
const parsed = getBudgetStatusSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const serviceError = requireBudgetService();
|
||||
if (serviceError) return serviceError;
|
||||
return ok(budgetService!.getBudgetStatus(parsed.companyId));
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { z } from "zod";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
connectorInputSchema,
|
||||
entityGetterSchema,
|
||||
metricsQuerySchema,
|
||||
settingsSchema,
|
||||
wakeupQuerySchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
@@ -142,4 +144,69 @@ export function registerCoreReadHandlers({
|
||||
if (err) return err;
|
||||
return ok(db.getStandupReport(companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listWakeupRequests, (_event, input) => {
|
||||
const parsed = wakeupQuerySchema.parse(input);
|
||||
const err = ensureCompanyExists(parsed.companyId);
|
||||
if (err) return err;
|
||||
return ok(db.listWakeupRequests(parsed.companyId, {
|
||||
status: parsed.status,
|
||||
agentId: parsed.agentId,
|
||||
limit: parsed.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
// --- Entity getters for granular IPC updates ---
|
||||
|
||||
registerHandle(IPC_CHANNELS.getTask, (_event, payload) => {
|
||||
const parsed = entityGetterSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
try {
|
||||
const task = db.getTask(parsed.id);
|
||||
if (task.companyId !== parsed.companyId) return fail("TASK_NOT_FOUND", "Task not found in current company.");
|
||||
return ok(task);
|
||||
} catch {
|
||||
return fail("TASK_NOT_FOUND", "Task not found.");
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAgent, (_event, payload) => {
|
||||
const parsed = entityGetterSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
try {
|
||||
const agent = db.getAgent(parsed.id);
|
||||
if (agent.companyId !== parsed.companyId) return fail("AGENT_NOT_FOUND", "Agent not found in current company.");
|
||||
return ok(agent);
|
||||
} catch {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found.");
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRun, (_event, payload) => {
|
||||
const parsed = entityGetterSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
try {
|
||||
const run = db.getRun(parsed.id);
|
||||
if (run.companyId !== parsed.companyId) return fail("RUN_NOT_FOUND", "Run not found in current company.");
|
||||
return ok(run);
|
||||
} catch {
|
||||
return fail("RUN_NOT_FOUND", "Run not found.");
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getApproval, (_event, payload) => {
|
||||
const parsed = entityGetterSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
try {
|
||||
const approval = db.getApprovalRecord(parsed.id);
|
||||
if (approval.companyId !== parsed.companyId) return fail("APPROVAL_NOT_FOUND", "Approval not found in current company.");
|
||||
return ok(approval);
|
||||
} catch {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
|
||||
|
||||
export function registerCoreWorkGraphHandlers({
|
||||
registerHandle,
|
||||
@@ -316,6 +317,13 @@ export function registerCoreWorkGraphHandlers({
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
if (isNew) {
|
||||
const isHeartbeat = parsed.title.startsWith("[Heartbeat]");
|
||||
const validation = validateTaskCreation(parsed, isHeartbeat);
|
||||
if (!validation.valid) {
|
||||
return fail("TASK_VALIDATION_FAILED", validation.errors.join("; "));
|
||||
}
|
||||
}
|
||||
let previousStatus: string | null = null;
|
||||
let previousAssigneeAgentId: string | null = null;
|
||||
if (!isNew) {
|
||||
@@ -357,8 +365,17 @@ export function registerCoreWorkGraphHandlers({
|
||||
wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment");
|
||||
}
|
||||
}
|
||||
let capabilityWarning: string | undefined;
|
||||
if (isNew && parsed.assigneeAgentId) {
|
||||
try {
|
||||
const match = checkCapabilityMatch(db, parsed.assigneeAgentId, parsed.description, parsed.companyId);
|
||||
if (!match.matched) {
|
||||
capabilityWarning = match.warning;
|
||||
}
|
||||
} catch { /* capability check is advisory — never block task creation */ }
|
||||
}
|
||||
emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" });
|
||||
return ok(id);
|
||||
return ok({ id, capabilityWarning });
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
|
||||
export interface BrowserActionControl {
|
||||
clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise<void>;
|
||||
@@ -38,6 +40,8 @@ export interface RegisterDomainHandlersDependencies {
|
||||
bootstrapOnboarding: (input: z.infer<typeof bootstrapOnboardingSchema>) => unknown;
|
||||
getBrowserActions: () => BrowserActionControl | null;
|
||||
randomFallbackCompanyId: () => string;
|
||||
skillService: ReturnType<typeof createSkillService> | null;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService> | null;
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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";
|
||||
import { registerBudgetHandlers } from "./ipc-budget-handlers";
|
||||
|
||||
export function registerDomainHandlers(dependencies: RegisterDomainHandlersDependencies) {
|
||||
registerOnboardingHandlers(dependencies);
|
||||
@@ -13,4 +15,18 @@ export function registerDomainHandlers(dependencies: RegisterDomainHandlersDepen
|
||||
registerAutomationOperationsHandlers(dependencies);
|
||||
registerAgentCoordinationHandlers(dependencies);
|
||||
registerRecoveryHandlers(dependencies);
|
||||
registerSkillHandlers({
|
||||
registerHandle: dependencies.registerHandle,
|
||||
ensureCompanyExists: dependencies.ensureCompanyExists,
|
||||
publishDomainChanged: dependencies.publishDomainChanged,
|
||||
skillService: dependencies.skillService,
|
||||
db: dependencies.db,
|
||||
});
|
||||
registerBudgetHandlers({
|
||||
registerHandle: dependencies.registerHandle,
|
||||
ensureCompanyExists: dependencies.ensureCompanyExists,
|
||||
publishDomainChanged: dependencies.publishDomainChanged,
|
||||
budgetService: dependencies.budgetService,
|
||||
db: dependencies.db,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
importSkillSchema,
|
||||
listSkillsSchema,
|
||||
getSkillDetailSchema,
|
||||
deleteSkillSchema,
|
||||
assignSkillToAgentSchema,
|
||||
removeSkillFromAgentSchema,
|
||||
checkSkillUpdateSchema,
|
||||
listSkillAgentsSchema,
|
||||
listAgentSkillsSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import type { createSkillService } from "./skill-service";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
interface SkillHandlerDependencies extends Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
"registerHandle" | "ensureCompanyExists" | "publishDomainChanged"
|
||||
> {
|
||||
skillService: ReturnType<typeof createSkillService> | null;
|
||||
db: AppDatabase;
|
||||
}
|
||||
|
||||
export function registerSkillHandlers({
|
||||
registerHandle,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
skillService,
|
||||
db,
|
||||
}: 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);
|
||||
});
|
||||
|
||||
// ---- List agent IDs assigned to a skill ----
|
||||
registerHandle(IPC_CHANNELS.listSkillAgents, async (_event, payload) => {
|
||||
const parsed = listSkillAgentsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.listSkillAgentAssignments(parsed.skillId));
|
||||
});
|
||||
|
||||
// ---- List skill IDs assigned to an agent ----
|
||||
registerHandle(IPC_CHANNELS.listAgentSkills, async (_event, payload) => {
|
||||
const parsed = listAgentSkillsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.listAgentSkillAssignments(parsed.agentId));
|
||||
});
|
||||
}
|
||||
@@ -168,6 +168,8 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
bootstrapOnboarding: (input) => requireOnboardingService().bootstrapOnboarding(input),
|
||||
getBrowserActions: () => requireServiceGraph().getBrowserActionService(),
|
||||
randomFallbackCompanyId: () => requireConnectorHealthService().randomFallbackCompanyId(),
|
||||
skillService: requireServiceGraph().getOrCreateSkillService(),
|
||||
budgetService: requireServiceGraph().getOrCreateBudgetEnforcementService(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildTaskPrompt } from "./prompt-builder";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
import { sweepStaleReviews } from "./task-quality-gates";
|
||||
import { resolveApprovalDecision } from "./approval-policy";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
|
||||
type WorkspaceLockMode = "exclusive" | "shared" | "none";
|
||||
|
||||
@@ -44,6 +49,7 @@ interface EnqueueRunPayload {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
|
||||
}
|
||||
|
||||
interface OrchestrationDependencies {
|
||||
@@ -62,6 +68,9 @@ interface OrchestrationDependencies {
|
||||
hasWorkerReady: () => boolean;
|
||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||
isQuitting: () => boolean;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string }>;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
getSessionHandoff?: (taskId: string) => string | null;
|
||||
}
|
||||
|
||||
export function createOrchestrationService({
|
||||
@@ -76,6 +85,9 @@ export function createOrchestrationService({
|
||||
hasWorkerReady,
|
||||
enqueueWorkerRun,
|
||||
isQuitting,
|
||||
listRuntimeSkillEntries,
|
||||
budgetService,
|
||||
getSessionHandoff,
|
||||
}: OrchestrationDependencies) {
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const scheduleLastFiredMap = new Map<string, number>();
|
||||
@@ -120,6 +132,7 @@ export function createOrchestrationService({
|
||||
return { workspace: null, lockMode: "none", source: "none" };
|
||||
}
|
||||
|
||||
// DEPRECATED: Superseded by BudgetEnforcementService.evaluateBudgetGate (Phase 5)
|
||||
function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) {
|
||||
if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) {
|
||||
return false;
|
||||
@@ -154,14 +167,16 @@ export function createOrchestrationService({
|
||||
function getRunEligibilityFailure(
|
||||
agent: { status: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; name: string; connectorId: string; id: string; companyId: string },
|
||||
connector: Pick<ConnectorRecord, "id" | "label" | "status" | "capabilityMatrix">,
|
||||
projectId: string | null = null,
|
||||
) {
|
||||
if (["terminated", "paused", "pending_approval"].includes(agent.status)) {
|
||||
return "AGENT_NOT_RUNNABLE";
|
||||
}
|
||||
if (agent.budgetMonthlyUsd > 0 && agent.spentMonthlyUsd >= agent.budgetMonthlyUsd) {
|
||||
autoPauseBudgetExceededAgent(agent);
|
||||
logger.info(`[run] Agent ${agent.name} budget exhausted, skipping`);
|
||||
return "AGENT_BUDGET_EXHAUSTED";
|
||||
// Policy-based budget check replaces flat field check (D-03, D-04, D-05)
|
||||
const gate = budgetService.evaluateBudgetGate(agent.companyId, agent.id, projectId);
|
||||
if (!gate.allowed) {
|
||||
logger.info(`[run] Agent ${agent.name} budget policy blocked: ${gate.blockedBy.map(b => `${b.scopeType}(${b.policyId})`).join(", ")}`);
|
||||
return "BUDGET_POLICY_BLOCKED";
|
||||
}
|
||||
const connectorIssue = getConnectorExecutionReadinessIssue(connector);
|
||||
if (connectorIssue) {
|
||||
@@ -221,6 +236,16 @@ export function createOrchestrationService({
|
||||
createdAt: comment.createdAt,
|
||||
}));
|
||||
|
||||
// D-07: Load handoff from previous rotated session
|
||||
let sessionHandoff: string | null = null;
|
||||
if (getSessionHandoff) {
|
||||
try {
|
||||
sessionHandoff = getSessionHandoff(task.id);
|
||||
} catch (err) {
|
||||
logger.warn(`[run] Failed to load session handoff for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||
agent,
|
||||
apiUrl,
|
||||
@@ -272,6 +297,7 @@ export function createOrchestrationService({
|
||||
.filter((document) => document.companyId === companyId && (document.authorAgentId === agent.id || document.reviewerAgentId === agent.id || document.status === "in_review"))
|
||||
.slice(0, 5),
|
||||
allConnectors: snapshot.connectors.map((c) => ({ id: c.id, label: c.label, status: c.status })),
|
||||
sessionHandoff,
|
||||
}) : null;
|
||||
|
||||
if (apiUrl) {
|
||||
@@ -284,7 +310,14 @@ export function createOrchestrationService({
|
||||
env.AGENT_COMPANY_WAKE_REASON = trigger;
|
||||
}
|
||||
|
||||
const taskSession = db.getTaskSession(task.id);
|
||||
// SESS-01: Prefer task_sessions table, fall back to legacy tasks.session_json
|
||||
const activeSession = db.getActiveTaskSession(task.id);
|
||||
const taskSession = activeSession
|
||||
? {
|
||||
sessionParams: activeSession.sessionParamsJson ? JSON.parse(activeSession.sessionParamsJson) as Record<string, unknown> : null,
|
||||
sessionDisplayId: activeSession.sessionDisplayId,
|
||||
}
|
||||
: db.getTaskSession(task.id);
|
||||
let thinkingEffort: string | null = null;
|
||||
try {
|
||||
const meta = JSON.parse(agent.metadataJson || "{}");
|
||||
@@ -295,6 +328,14 @@ export function createOrchestrationService({
|
||||
thinkingEffort = null;
|
||||
}
|
||||
|
||||
// Fetch managed skills for this agent -- best-effort, never blocks run dispatch
|
||||
let managedSkills: Array<{ runtimeName: string; sourcePath: string }> = [];
|
||||
try {
|
||||
managedSkills = listRuntimeSkillEntries(companyId, agent.id, connector.id);
|
||||
} catch (err) {
|
||||
logger.warn(`[run] Failed to fetch managed skills for agent ${agent.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
enqueueWorkerRun({
|
||||
runId,
|
||||
companyId,
|
||||
@@ -314,6 +355,7 @@ export function createOrchestrationService({
|
||||
sessionParams: taskSession.sessionParams,
|
||||
systemPrompt,
|
||||
trigger,
|
||||
managedSkills,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -331,7 +373,7 @@ export function createOrchestrationService({
|
||||
return null;
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
if (getRunEligibilityFailure(agent, connector, null)) {
|
||||
return null;
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId })) {
|
||||
@@ -408,8 +450,20 @@ export function createOrchestrationService({
|
||||
logger.info(`[heartbeat] Created bootstrap task for ${agent.name}: ${taskTitle}`);
|
||||
}
|
||||
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because task ${task.title} already has an active run.`);
|
||||
// Generate runId upfront for CAS checkout (EXEC-01, EXEC-02)
|
||||
const runId = randomUUID();
|
||||
const checkoutSuccess = db.checkoutTask(task.id, runId);
|
||||
if (!checkoutSuccess) {
|
||||
// D-04: Activity log entry only, no toast/popup
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "execution.checkout_rejected",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
detail: `Agent ${agent.name} lost checkout race on task "${task.title}"`,
|
||||
});
|
||||
logger.info(`[heartbeat] Agent ${agent.name} lost checkout race on task ${task.title}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -420,6 +474,7 @@ export function createOrchestrationService({
|
||||
);
|
||||
|
||||
const run = db.createRun({
|
||||
id: runId,
|
||||
companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
@@ -473,9 +528,16 @@ export function createOrchestrationService({
|
||||
}
|
||||
|
||||
const agentBusy = hasActiveRun({ agentId, companyId });
|
||||
if (agentBusy) {
|
||||
// Agent is busy — enqueue wakeup with lifecycle tracking (D-05)
|
||||
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const runId = startAgentHeartbeatRun(agentId, companyId, trigger);
|
||||
if (!runId && agentBusy) {
|
||||
db.enqueuePendingWake(companyId, agentId, trigger);
|
||||
if (!runId) {
|
||||
// Run failed to start but agent not busy — still enqueue for retry
|
||||
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`);
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
@@ -493,21 +555,18 @@ export function createOrchestrationService({
|
||||
if (companyStatus !== "active") {
|
||||
return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active.");
|
||||
}
|
||||
if (task.activeRunId || hasActiveRun({ taskId: task.id, companyId: task.companyId })) {
|
||||
return fail("TASK_ALREADY_RUNNING", "This task already has an active run.");
|
||||
}
|
||||
if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run.");
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (agent.companyId !== task.companyId) {
|
||||
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const eligibilityFailure = getRunEligibilityFailure(agent, connector);
|
||||
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null);
|
||||
if (eligibilityFailure === "AGENT_NOT_RUNNABLE") {
|
||||
return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated.");
|
||||
}
|
||||
if (eligibilityFailure === "AGENT_BUDGET_EXHAUSTED") {
|
||||
return fail("AGENT_BUDGET_EXHAUSTED", "The assigned agent has exhausted its monthly budget.");
|
||||
if (eligibilityFailure === "BUDGET_POLICY_BLOCKED") {
|
||||
return fail("BUDGET_POLICY_BLOCKED", "A budget policy blocks this run. Review budget settings.");
|
||||
}
|
||||
if (eligibilityFailure === "CONNECTOR_NOT_READY") {
|
||||
return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready.");
|
||||
@@ -526,6 +585,13 @@ export function createOrchestrationService({
|
||||
);
|
||||
}
|
||||
|
||||
// CAS checkout — atomic claim (EXEC-01, EXEC-02)
|
||||
const runId = randomUUID();
|
||||
const checkoutSuccess = db.checkoutTask(task.id, runId);
|
||||
if (!checkoutSuccess) {
|
||||
return fail("CHECKOUT_RACE_LOST", "Another run claimed this task. Refresh and try again.");
|
||||
}
|
||||
|
||||
const workspaceResolution = resolveWorkspace(task, agent, task.companyId);
|
||||
const workspace = workspaceResolution.workspace;
|
||||
if (workspace && workspace.companyId !== task.companyId) {
|
||||
@@ -533,6 +599,7 @@ export function createOrchestrationService({
|
||||
}
|
||||
|
||||
const run = db.createRun({
|
||||
id: runId,
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
agentId: agent.id,
|
||||
@@ -567,6 +634,27 @@ export function createOrchestrationService({
|
||||
}
|
||||
|
||||
function resumeQueuedRuns() {
|
||||
// D-02: Stale lock recovery at app startup (EXEC-03)
|
||||
try {
|
||||
const recovered = db.recoverStaleLocks();
|
||||
for (const task of recovered) {
|
||||
db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor: "system",
|
||||
action: "execution.lock_recovered",
|
||||
entityType: "task",
|
||||
entityId: task.taskId,
|
||||
detail: `Recovered stale lock on task "${task.title}" after 15min timeout (startup sweep)`,
|
||||
});
|
||||
logger.info(`[init] Recovered stale lock on task ${task.taskId}: "${task.title}"`);
|
||||
}
|
||||
if (recovered.length > 0) {
|
||||
logger.info(`[init] Recovered ${recovered.length} stale lock(s) at startup`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[init] Stale lock recovery at startup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (isQuitting() || !hasWorkerReady()) {
|
||||
return;
|
||||
}
|
||||
@@ -593,7 +681,7 @@ export function createOrchestrationService({
|
||||
const task = db.getTask(run.taskId);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
const connector = db.getConnector(run.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector)) {
|
||||
if (getRunEligibilityFailure(agent, connector, task.projectId ?? null)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceResolution = resolveWorkspace(task, agent, run.companyId);
|
||||
@@ -622,6 +710,26 @@ export function createOrchestrationService({
|
||||
heartbeatTimer = setInterval(() => {
|
||||
try {
|
||||
if (isQuitting() || !hasWorkerReady()) return;
|
||||
|
||||
// D-02: Stale lock recovery piggybacked on heartbeat cycle (EXEC-03)
|
||||
try {
|
||||
const recovered = db.recoverStaleLocks();
|
||||
for (const task of recovered) {
|
||||
// D-04: Activity log entry only
|
||||
db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor: "system",
|
||||
action: "execution.lock_recovered",
|
||||
entityType: "task",
|
||||
entityId: task.taskId,
|
||||
detail: `Recovered stale lock on task "${task.title}" after 15min timeout`,
|
||||
});
|
||||
logger.info(`[heartbeat] Recovered stale lock on task ${task.taskId}: "${task.title}"`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[heartbeat] Stale lock recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const activeCompanies = db.listActiveCompanyIds();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -680,6 +788,40 @@ export function createOrchestrationService({
|
||||
db.setLastBudgetResetMonth(company.id, currentMonth);
|
||||
logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`);
|
||||
}
|
||||
|
||||
// D-06: Auto-close incidents from previous budget windows
|
||||
const closedIncidents = budgetService.autoCloseExpiredIncidents(company.id);
|
||||
if (closedIncidents > 0) {
|
||||
logger.info(`[heartbeat] Auto-closed ${closedIncidents} expired budget incident(s) for company ${company.name}`);
|
||||
}
|
||||
|
||||
// TASK-04: Stale review escalation sweep
|
||||
try {
|
||||
const escalated = sweepStaleReviews(company.id, {
|
||||
db: db as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["db"],
|
||||
dispatchAgentMessage: dispatchAgentMessage as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["dispatchAgentMessage"],
|
||||
resolveApprovalDecision: resolveApprovalDecision as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["resolveApprovalDecision"],
|
||||
notify: notify as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["notify"],
|
||||
handleTaskStatusChange: (_taskId: string, _companyId: string, _prev: string, _next: string) => {
|
||||
publishDomainChanged();
|
||||
},
|
||||
handleApprovalResolved: (_approvalId: string, _companyId: string, _decision: string) => {
|
||||
publishDomainChanged();
|
||||
},
|
||||
emitEvent: (..._args: unknown[]) => {},
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible: (agentId: string, cId: string, trigger: string) => {
|
||||
wakeAgentIfPossible(agentId, cId, trigger);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
if (escalated > 0) {
|
||||
logger.info(`[heartbeat] Auto-escalated ${escalated} stale review(s) for company ${company.name}`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Stale review sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -691,9 +833,9 @@ export function createOrchestrationService({
|
||||
logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
const stalePendingWakes = db.purgeExpiredPendingWakes();
|
||||
if (stalePendingWakes > 0) {
|
||||
logger.info(`[heartbeat] Purged ${stalePendingWakes} stale pending wake(s)`);
|
||||
const staleWakeups = db.purgeExpiredWakeupRequests();
|
||||
if (staleWakeups > 0) {
|
||||
logger.info(`[heartbeat] Purged ${staleWakeups} expired wakeup request(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
@@ -42,6 +42,7 @@ interface RunJob {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
|
||||
}
|
||||
|
||||
type ParentMessage =
|
||||
@@ -64,6 +65,8 @@ type ChildMessage =
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
unattributedCost: boolean;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
};
|
||||
|
||||
const parentPort = process.parentPort;
|
||||
@@ -95,6 +98,7 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
|
||||
cwd: job.workspacePath ?? process.cwd(),
|
||||
model: job.model,
|
||||
env: sanitizedEnv,
|
||||
managedSkills: job.managedSkills,
|
||||
};
|
||||
|
||||
switch (job.connectorId) {
|
||||
@@ -234,6 +238,8 @@ async function startRun(job: RunJob) {
|
||||
sessionDisplayId: result.sessionDisplayId ?? null,
|
||||
costUsd: typeof result.costUsd === "number" ? result.costUsd : null,
|
||||
unattributedCost: !result.costUsd,
|
||||
inputTokens: result.usage?.inputTokens ?? null,
|
||||
outputTokens: result.usage?.outputTokens ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
post({
|
||||
@@ -249,6 +255,8 @@ async function startRun(job: RunJob) {
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
});
|
||||
} finally {
|
||||
cancelledRuns.delete(job.runId);
|
||||
@@ -277,6 +285,8 @@ function cancelRun(runId: string) {
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ interface PromptContext {
|
||||
activeSprint?: SprintRecord | null;
|
||||
recentDocuments?: DocumentRecord[];
|
||||
allConnectors?: Array<{ id: string; label: string; status: string }>;
|
||||
/** D-07: Handoff markdown from a rotated session, injected as context for the agent */
|
||||
sessionHandoff?: string | null;
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
@@ -195,6 +197,15 @@ ${relevantProjects.map((p) => `- [${p.id}] ${p.name}: ${p.description || ""} (st
|
||||
`);
|
||||
}
|
||||
|
||||
// D-07: Inject handoff context from previous rotated session
|
||||
if (ctx.sessionHandoff) {
|
||||
sections.push(`# Session Handoff
|
||||
The following context was preserved from your previous session on this task:
|
||||
|
||||
${ctx.sessionHandoff}
|
||||
`);
|
||||
}
|
||||
|
||||
if (ctx.task) {
|
||||
sections.push(`# Current Task
|
||||
- ID: ${ctx.task.id}
|
||||
|
||||
@@ -8,9 +8,12 @@ import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
import { createOnboardingService } from "./onboarding-service";
|
||||
import { createOrchestrationService } from "./orchestration-service";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import { createUpdaterService } from "./updater-service";
|
||||
import { createWorkerService } from "./worker-service";
|
||||
import { createAppLifecycleService } from "./app-lifecycle-service";
|
||||
import { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
import { createSessionManagementService } from "./session-management-service";
|
||||
import type { DesktopEvent, RunRecord } from "@shared/types";
|
||||
import type { AutomationEventContext } from "./automation-service";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
@@ -67,8 +70,44 @@ export function createServiceGraph({
|
||||
let workerService: ReturnType<typeof createWorkerService> | null = null;
|
||||
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
|
||||
let skillService: ReturnType<typeof createSkillService> | null = null;
|
||||
let budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService> | null = null;
|
||||
let sessionManagementService: ReturnType<typeof createSessionManagementService> | null = null;
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
function getOrCreateSkillService() {
|
||||
if (!skillService) {
|
||||
skillService = createSkillService({
|
||||
db,
|
||||
profileDir: db.profileDir,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
return skillService;
|
||||
}
|
||||
|
||||
function getOrCreateBudgetEnforcementService() {
|
||||
if (!budgetEnforcementService) {
|
||||
budgetEnforcementService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
publishDomainChanged,
|
||||
});
|
||||
}
|
||||
return budgetEnforcementService;
|
||||
}
|
||||
|
||||
function getOrCreateSessionManagementService() {
|
||||
if (!sessionManagementService) {
|
||||
sessionManagementService = createSessionManagementService({
|
||||
db,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
return sessionManagementService;
|
||||
}
|
||||
|
||||
const wakeAgentIfPossible =(agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
@@ -163,6 +202,8 @@ export function createServiceGraph({
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion: (run, event) => processRunCompletion(run, event),
|
||||
notify,
|
||||
budgetService: getOrCreateBudgetEnforcementService(),
|
||||
sessionService: getOrCreateSessionManagementService(),
|
||||
});
|
||||
|
||||
orchestrationService = createOrchestrationService({
|
||||
@@ -179,6 +220,27 @@ export function createServiceGraph({
|
||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
listRuntimeSkillEntries: (() => {
|
||||
let cachedSkillService: ReturnType<typeof createSkillService> | null = null;
|
||||
return (companyId: string, agentId: string, connectorId: string) => {
|
||||
try {
|
||||
if (!cachedSkillService) {
|
||||
cachedSkillService = createSkillService({ db, profileDir: db.profileDir, logger });
|
||||
}
|
||||
return cachedSkillService.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
})(),
|
||||
budgetService: getOrCreateBudgetEnforcementService(),
|
||||
getSessionHandoff: (taskId: string) => {
|
||||
try {
|
||||
return getOrCreateSessionManagementService().getHandoffContext(taskId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
workerService.createWorker();
|
||||
|
||||
@@ -237,6 +299,9 @@ export function createServiceGraph({
|
||||
orchestrationService = null;
|
||||
updaterService = null;
|
||||
appLifecycleService = null;
|
||||
skillService = null;
|
||||
budgetEnforcementService = null;
|
||||
sessionManagementService = null;
|
||||
db.close();
|
||||
// Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux)
|
||||
// Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely
|
||||
@@ -256,5 +321,8 @@ export function createServiceGraph({
|
||||
getOnboardingService: () => onboardingService,
|
||||
getUpdaterService: () => updaterService,
|
||||
getAppLifecycleService: () => appLifecycleService,
|
||||
getOrCreateSkillService,
|
||||
getOrCreateBudgetEnforcementService,
|
||||
getOrCreateSessionManagementService,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { ConnectorId, TaskSessionRecord } from "@shared/types";
|
||||
|
||||
interface SessionManagementDependencies {
|
||||
db: AppDatabase;
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
// D-01/D-02: Exported for testing and external override. Conservative defaults.
|
||||
export const DEFAULT_THRESHOLDS = {
|
||||
maxRuns: 20,
|
||||
maxTokens: 500_000,
|
||||
maxAgeHours: 72,
|
||||
};
|
||||
|
||||
// D-01: Type for per-adapter or company-level threshold overrides
|
||||
export type RotationThresholds = Partial<typeof DEFAULT_THRESHOLDS>;
|
||||
|
||||
// D-04: Handoff size cap to prevent prompt bloat
|
||||
const MAX_HANDOFF_LENGTH = 2000;
|
||||
const MAX_HANDOFF_RUNS = 5;
|
||||
|
||||
export function createSessionManagementService({ db, logger }: SessionManagementDependencies) {
|
||||
|
||||
// D-08: Accumulate token usage on task_sessions row (called for ALL adapters per D-05)
|
||||
function accumulateTokenUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string): void {
|
||||
db.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId);
|
||||
}
|
||||
|
||||
// D-01: Any-of thresholds with optional overrides. D-04: Check nativeContextManagement.
|
||||
// Per D-01: "Configurable per-adapter with company-level overrides" -- accepts optional
|
||||
// thresholds parameter that merges with defaults. Callers can pass per-adapter or
|
||||
// company-level overrides; omitted fields fall back to DEFAULT_THRESHOLDS.
|
||||
function evaluateRotation(
|
||||
session: TaskSessionRecord,
|
||||
connectorId: string,
|
||||
thresholdsOverride?: RotationThresholds,
|
||||
): { shouldRotate: boolean; reason: string | null } {
|
||||
// D-04: Check adapter exemption
|
||||
try {
|
||||
const connector = getConnectorDefinition(connectorId as ConnectorId);
|
||||
if (connector.module.nativeContextManagement === true) {
|
||||
return { shouldRotate: false, reason: null };
|
||||
}
|
||||
} catch {
|
||||
// Unknown connector - proceed with rotation check
|
||||
}
|
||||
|
||||
// Merge overrides with defaults (per D-01 configurability)
|
||||
const thresholds = { ...DEFAULT_THRESHOLDS, ...thresholdsOverride };
|
||||
|
||||
// D-01: Any-of thresholds - first trigger wins
|
||||
if (session.runCount >= thresholds.maxRuns) {
|
||||
return { shouldRotate: true, reason: "max_runs" };
|
||||
}
|
||||
|
||||
const totalTokens = session.totalInputTokens + session.totalOutputTokens;
|
||||
if (totalTokens >= thresholds.maxTokens) {
|
||||
return { shouldRotate: true, reason: "max_tokens" };
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(session.createdAt).getTime();
|
||||
const ageHours = ageMs / (1000 * 60 * 60);
|
||||
if (ageHours >= thresholds.maxAgeHours) {
|
||||
return { shouldRotate: true, reason: "max_age_hours" };
|
||||
}
|
||||
|
||||
return { shouldRotate: false, reason: null };
|
||||
}
|
||||
|
||||
// D-06: Build handoff markdown from structured data, no AI call
|
||||
function generateHandoff(taskId: string, session: TaskSessionRecord): string {
|
||||
let task: { title: string; description: string } | null = null;
|
||||
try {
|
||||
task = db.getTask(taskId);
|
||||
} catch {
|
||||
task = null;
|
||||
}
|
||||
|
||||
const recentRuns = db.getRecentRunsForTask(taskId, MAX_HANDOFF_RUNS);
|
||||
const errorRuns = recentRuns.filter(r => r.errorMessage);
|
||||
const activityEntries = db.getRecentActivityForTask(taskId, 10);
|
||||
const decisionEntries = activityEntries.filter(a =>
|
||||
a.action.includes("decision") || a.action.includes("approval") || a.action.includes("resolved")
|
||||
);
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push("# Session Handoff");
|
||||
sections.push("");
|
||||
|
||||
if (task) {
|
||||
sections.push("## Task");
|
||||
sections.push(`**${task.title}**`);
|
||||
const descSnippet = task.description.length > 200 ? task.description.slice(0, 200) + "..." : task.description;
|
||||
sections.push(descSnippet);
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (recentRuns.length > 0) {
|
||||
sections.push("## Recent Run History");
|
||||
for (const run of recentRuns) {
|
||||
const summaryText = run.summary ? run.summary.slice(0, 100) : "(no summary)";
|
||||
sections.push(`- [${run.status}] ${summaryText} (${run.finishedAt ?? "in progress"})`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (errorRuns.length > 0) {
|
||||
sections.push("## Errors Encountered");
|
||||
for (const run of errorRuns) {
|
||||
sections.push(`- ${run.errorMessage!.slice(0, 150)} (${run.finishedAt ?? "unknown"})`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (decisionEntries.length > 0) {
|
||||
sections.push("## Key Decisions");
|
||||
for (const entry of decisionEntries.slice(0, 5)) {
|
||||
sections.push(`- ${entry.detail.slice(0, 120)} (${entry.createdAt})`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
sections.push(`*Session stats: ${session.runCount} runs, ${session.totalInputTokens + session.totalOutputTokens} total tokens*`);
|
||||
|
||||
let result = sections.join("\n");
|
||||
// Cap handoff size per research pitfall #5
|
||||
if (result.length > MAX_HANDOFF_LENGTH) {
|
||||
result = result.slice(0, MAX_HANDOFF_LENGTH - 50) + "\n\n... (earlier content omitted)";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Archive current session, generate handoff, create new session, update task pointer
|
||||
function rotateSession(session: TaskSessionRecord): string {
|
||||
const reason = "threshold_exceeded";
|
||||
const handoff = generateHandoff(session.taskId, session);
|
||||
|
||||
logger.info(`[session] Rotating session ${session.id} for task ${session.taskId}: ${reason}`);
|
||||
|
||||
// 1. Archive old session
|
||||
db.archiveSession(session.id, reason, handoff);
|
||||
|
||||
// 2. Create new session row with fresh counters, carry forward session_params
|
||||
const newSessionId = db.createNewSession({
|
||||
taskId: session.taskId,
|
||||
companyId: session.companyId,
|
||||
agentId: session.agentId,
|
||||
adapterType: session.adapterType,
|
||||
sessionParamsJson: session.sessionParamsJson,
|
||||
sessionDisplayId: session.sessionDisplayId,
|
||||
});
|
||||
|
||||
logger.info(`[session] New session ${newSessionId} created for task ${session.taskId}`);
|
||||
return newSessionId;
|
||||
}
|
||||
|
||||
// D-07: Read handoff from most recently rotated session for a task
|
||||
function getHandoffContext(taskId: string): string | null {
|
||||
// Find the most recent archived session for this task (the one just before current)
|
||||
const row = db.queryAll<{ handoffMarkdown: string | null }>(
|
||||
`SELECT handoff_markdown as handoffMarkdown FROM task_sessions
|
||||
WHERE task_id = ? AND rotated_at IS NOT NULL
|
||||
ORDER BY rotated_at DESC LIMIT 1`,
|
||||
taskId
|
||||
);
|
||||
return row[0]?.handoffMarkdown ?? null;
|
||||
}
|
||||
|
||||
// D-09: Snapshot token totals for delta tracking
|
||||
function snapshotUsage(session: TaskSessionRecord): { inputTokens: number; outputTokens: number } {
|
||||
return {
|
||||
inputTokens: session.totalInputTokens,
|
||||
outputTokens: session.totalOutputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// D-09: Compute delta from snapshot
|
||||
function getUsageDelta(
|
||||
sessionId: string,
|
||||
snapshot: { inputTokens: number; outputTokens: number }
|
||||
): { inputTokensDelta: number; outputTokensDelta: number } {
|
||||
// Re-read current totals from DB
|
||||
const current = db.queryAll<{ totalInputTokens: number; totalOutputTokens: number }>(
|
||||
`SELECT total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens
|
||||
FROM task_sessions WHERE id = ?`,
|
||||
sessionId
|
||||
);
|
||||
if (!current[0]) return { inputTokensDelta: 0, outputTokensDelta: 0 };
|
||||
return {
|
||||
inputTokensDelta: current[0].totalInputTokens - snapshot.inputTokens,
|
||||
outputTokensDelta: current[0].totalOutputTokens - snapshot.outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accumulateTokenUsage,
|
||||
evaluateRotation,
|
||||
generateHandoff,
|
||||
rotateSession,
|
||||
getHandoffContext,
|
||||
snapshotUsage,
|
||||
getUsageDelta,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// SKILL.md format parser, inventory classifier, and trust level derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { extname } from "node:path";
|
||||
import type { FileInventoryEntry, TrustLevel } from "./skill-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal YAML frontmatter parsing (no external YAML library)
|
||||
// Follows the Paperclip reference pattern for SKILL.md files.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseYamlScalar(rawValue: string): unknown {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (trimmed === "null" || trimmed === "~") return null;
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "[]") return [];
|
||||
if (trimmed === "{}") return {};
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if (
|
||||
trimmed.startsWith('"') ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.startsWith("{")
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function prepareYamlLines(
|
||||
raw: string,
|
||||
): Array<{ indent: number; content: string }> {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => ({
|
||||
indent: line.match(/^ */)?.[0].length ?? 0,
|
||||
content: line.trim(),
|
||||
}))
|
||||
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
||||
}
|
||||
|
||||
function parseYamlBlock(
|
||||
lines: Array<{ indent: number; content: string }>,
|
||||
startIndex: number,
|
||||
indentLevel: number,
|
||||
): { value: unknown; nextIndex: number } {
|
||||
let index = startIndex;
|
||||
while (index < lines.length && lines[index]!.content.length === 0)
|
||||
index += 1;
|
||||
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
||||
return { value: {}, nextIndex: index };
|
||||
}
|
||||
|
||||
const isArray =
|
||||
lines[index]!.indent === indentLevel &&
|
||||
lines[index]!.content.startsWith("-");
|
||||
|
||||
if (isArray) {
|
||||
const values: unknown[] = [];
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!;
|
||||
if (line.indent < indentLevel) break;
|
||||
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
||||
const remainder = line.content.slice(1).trim();
|
||||
index += 1;
|
||||
if (!remainder) {
|
||||
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||
values.push(nested.value);
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const inlineObjectSeparator = remainder.indexOf(":");
|
||||
if (
|
||||
inlineObjectSeparator > 0 &&
|
||||
!remainder.startsWith('"') &&
|
||||
!remainder.startsWith("{") &&
|
||||
!remainder.startsWith("[")
|
||||
) {
|
||||
const key = remainder.slice(0, inlineObjectSeparator).trim();
|
||||
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
|
||||
const nextObject: Record<string, unknown> = {
|
||||
[key]: parseYamlScalar(rawValue),
|
||||
};
|
||||
if (index < lines.length && lines[index]!.indent > indentLevel) {
|
||||
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||
if (isPlainRecord(nested.value)) {
|
||||
Object.assign(nextObject, nested.value);
|
||||
}
|
||||
index = nested.nextIndex;
|
||||
}
|
||||
values.push(nextObject);
|
||||
continue;
|
||||
}
|
||||
values.push(parseYamlScalar(remainder));
|
||||
}
|
||||
return { value: values, nextIndex: index };
|
||||
}
|
||||
|
||||
// Object parsing
|
||||
const record: Record<string, unknown> = {};
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!;
|
||||
if (line.indent < indentLevel) break;
|
||||
const colonIndex = line.content.indexOf(":");
|
||||
if (colonIndex < 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const key = line.content.slice(0, colonIndex).trim();
|
||||
const remainder = line.content.slice(colonIndex + 1).trim();
|
||||
index += 1;
|
||||
if (!remainder && index < lines.length && lines[index]!.indent > indentLevel) {
|
||||
const nested = parseYamlBlock(lines, index, lines[index]!.indent);
|
||||
record[key] = nested.value;
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
record[key] = parseYamlScalar(remainder);
|
||||
}
|
||||
return { value: record, nextIndex: index };
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
||||
const prepared = prepareYamlLines(raw);
|
||||
if (prepared.length === 0) return {};
|
||||
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
||||
return isPlainRecord(parsed.value) ? parsed.value : {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a SKILL.md file with optional YAML frontmatter.
|
||||
* Normalizes CRLF to LF before parsing.
|
||||
*/
|
||||
export function parseFrontmatterMarkdown(
|
||||
raw: string,
|
||||
): { frontmatter: Record<string, unknown>; body: string } {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: normalized.trim() };
|
||||
}
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) {
|
||||
// Check for closing delimiter at EOF (no trailing newline)
|
||||
const eofClosing = normalized.indexOf("\n---", 4);
|
||||
if (eofClosing >= 0 && eofClosing + 4 >= normalized.length) {
|
||||
const frontmatterRaw = normalized.slice(4, eofClosing).trim();
|
||||
return {
|
||||
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
||||
body: "",
|
||||
};
|
||||
}
|
||||
return { frontmatter: {}, body: normalized.trim() };
|
||||
}
|
||||
const frontmatterRaw = normalized.slice(4, closing).trim();
|
||||
const body = normalized.slice(closing + 5).trim();
|
||||
return {
|
||||
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a file path within a skill package into its inventory kind.
|
||||
* Uses directory prefixes and file extensions for classification.
|
||||
*/
|
||||
export function classifyInventoryKind(
|
||||
relativePath: string,
|
||||
): FileInventoryEntry["kind"] {
|
||||
// Normalize path separators and lowercase for matching
|
||||
const normalized = relativePath.replace(/\\/g, "/").toLowerCase();
|
||||
|
||||
if (normalized.endsWith("/skill.md") || normalized === "skill.md")
|
||||
return "skill";
|
||||
if (normalized.startsWith("references/")) return "reference";
|
||||
if (normalized.startsWith("scripts/")) return "script";
|
||||
if (normalized.startsWith("assets/")) return "asset";
|
||||
if (normalized.endsWith(".md")) return "markdown";
|
||||
|
||||
const ext = extname(normalized).toLowerCase();
|
||||
const scriptExtensions = new Set([
|
||||
".sh",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".py",
|
||||
".rb",
|
||||
".bash",
|
||||
]);
|
||||
if (scriptExtensions.has(ext)) return "script";
|
||||
|
||||
const assetExtensions = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
".pdf",
|
||||
]);
|
||||
if (assetExtensions.has(ext)) return "asset";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the trust level for a skill based on its file inventory.
|
||||
* Scripts/executables -> highest risk; assets -> medium; markdown only -> lowest.
|
||||
*/
|
||||
export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel {
|
||||
if (inventory.some((entry) => entry.kind === "script"))
|
||||
return "scripts_executables";
|
||||
if (
|
||||
inventory.some(
|
||||
(entry) => entry.kind === "asset" || entry.kind === "other",
|
||||
)
|
||||
)
|
||||
return "assets";
|
||||
return "markdown_only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the token count for a text string.
|
||||
* Uses a rough 1:4 character-to-token ratio.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (text.length === 0) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub REST API skill fetcher -- imports skills without git clone (D-02)
|
||||
// Public repos only (D-03). Uses raw.githubusercontent.com for content
|
||||
// downloads (does not count against API rate limits per research).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Data returned for each skill found in a GitHub repository.
|
||||
*/
|
||||
export interface GitHubSkillData {
|
||||
skillMdPath: string;
|
||||
markdown: string;
|
||||
inventory: string[];
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit SHA resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
|
||||
* Returns the 40-character hex SHA on success, null on error.
|
||||
*/
|
||||
export async function resolveGitHubCommitSha(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
logger?: Logger,
|
||||
): Promise<string | null> {
|
||||
const target = ref || "HEAD";
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(target)}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/vnd.github.sha" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
||||
logger?.warn(
|
||||
`GitHub API rate limit hit for ${owner}/${repo}`,
|
||||
resetHeader ? `resets at ${new Date(Number(resetHeader) * 1000).toISOString()}` : "",
|
||||
);
|
||||
} else {
|
||||
logger?.warn(`GitHub commit resolve failed: ${response.status} for ${owner}/${repo}@${target}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const sha = (await response.text()).trim();
|
||||
return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
|
||||
} catch (err) {
|
||||
logger?.error("GitHub commit resolve network error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub tree + SKILL.md fetcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch all SKILL.md files from a GitHub repository via the REST API.
|
||||
*
|
||||
* 1. GET the recursive git tree to find all SKILL.md paths
|
||||
* 2. Optionally filter to a specific skill slug directory
|
||||
* 3. Download each SKILL.md via raw.githubusercontent.com
|
||||
* 4. Collect sibling files as file inventory
|
||||
*/
|
||||
export async function fetchGitHubSkills(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
requestedSlug: string | null,
|
||||
logger?: Logger,
|
||||
): Promise<{ skills: GitHubSkillData[]; commitSha: string }> {
|
||||
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
||||
try {
|
||||
const treeResponse = await fetch(treeUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!treeResponse.ok) {
|
||||
logger?.warn(`GitHub tree fetch failed: ${treeResponse.status} for ${owner}/${repo}@${ref}`);
|
||||
return { skills: [], commitSha: ref };
|
||||
}
|
||||
const treeData = (await treeResponse.json()) as {
|
||||
tree?: Array<{ path: string; type: string }>;
|
||||
sha?: string;
|
||||
};
|
||||
const treeSha = typeof treeData.sha === "string" ? treeData.sha : ref;
|
||||
const allEntries = (treeData.tree ?? []).filter(
|
||||
(entry) => entry.type === "blob" && typeof entry.path === "string",
|
||||
);
|
||||
const allPaths = allEntries.map((entry) => entry.path);
|
||||
|
||||
// Find SKILL.md files (at root or nested)
|
||||
let skillMdPaths = allPaths.filter(
|
||||
(p) => p.endsWith("/SKILL.md") || p === "SKILL.md",
|
||||
);
|
||||
|
||||
// Filter to requested slug directory if provided
|
||||
if (requestedSlug) {
|
||||
skillMdPaths = skillMdPaths.filter((p) => {
|
||||
const dir = p === "SKILL.md" ? "" : p.slice(0, p.lastIndexOf("/"));
|
||||
const dirName = dir.split("/").pop() ?? "";
|
||||
return dirName.toLowerCase() === requestedSlug.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
if (skillMdPaths.length === 0) {
|
||||
logger?.info(`No SKILL.md files found in ${owner}/${repo}@${ref}`);
|
||||
return { skills: [], commitSha: treeSha };
|
||||
}
|
||||
|
||||
const skills: GitHubSkillData[] = [];
|
||||
|
||||
for (const skillMdPath of skillMdPaths) {
|
||||
try {
|
||||
// Download content via raw.githubusercontent.com (no API rate limit)
|
||||
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${skillMdPath}`;
|
||||
const contentResponse = await fetch(rawUrl);
|
||||
if (!contentResponse.ok) {
|
||||
logger?.warn(`Failed to download ${rawUrl}: ${contentResponse.status}`);
|
||||
continue;
|
||||
}
|
||||
const markdown = await contentResponse.text();
|
||||
|
||||
// Collect sibling files in the same directory (or subdirectories)
|
||||
const skillDir =
|
||||
skillMdPath === "SKILL.md"
|
||||
? ""
|
||||
: skillMdPath.slice(0, skillMdPath.lastIndexOf("/"));
|
||||
const prefix = skillDir ? `${skillDir}/` : "";
|
||||
const inventory = allPaths.filter(
|
||||
(p) => p !== skillMdPath && (prefix ? p.startsWith(prefix) : !p.includes("/")),
|
||||
);
|
||||
|
||||
skills.push({
|
||||
skillMdPath,
|
||||
markdown,
|
||||
inventory,
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.error(`Error downloading skill ${skillMdPath}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { skills, commitSha: treeSha };
|
||||
} catch (err) {
|
||||
logger?.error(`GitHub tree fetch network error for ${owner}/${repo}:`, err);
|
||||
return { skills: [], commitSha: ref };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical skill key derivation and skill import source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import type { SkillSourceType } from "./skill-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slug normalization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SLUG_DELIM_RE = /[^a-z0-9]+/g;
|
||||
const SLUG_TRIM_RE = /^-+|-+$/g;
|
||||
|
||||
/**
|
||||
* Normalize a value into a URL-safe slug.
|
||||
* Returns null if the value is falsy or results in an empty string.
|
||||
*/
|
||||
export function normalizeSkillSlug(
|
||||
value: string | undefined | null,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const normalized = trimmed
|
||||
.toLowerCase()
|
||||
.replace(SLUG_DELIM_RE, "-")
|
||||
.replace(SLUG_TRIM_RE, "");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the first 10 characters of a SHA-256 hex digest.
|
||||
* Used for hash segments in canonical keys.
|
||||
*/
|
||||
export function hashSkillValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical key derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic canonical key for a skill.
|
||||
*
|
||||
* Key formats by source type:
|
||||
* - GitHub/skills_sh with owner+repo: `{owner}/{repo}/{slug}`
|
||||
* - URL with sourceLocator: `url/{normalizedHost}/{hash10}/{slug}`
|
||||
* - local_path with sourceLocator: `local/{hash10 of resolved path}/{slug}`
|
||||
* - Fallback (managed or unknown): `company/{companyId}/{slug}`
|
||||
*/
|
||||
export function deriveCanonicalSkillKey(
|
||||
companyId: string,
|
||||
input: {
|
||||
slug: string;
|
||||
sourceType: SkillSourceType;
|
||||
sourceLocator: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
},
|
||||
): string {
|
||||
const slug = normalizeSkillSlug(input.slug) ?? "skill";
|
||||
const metadata = isPlainRecord(input.metadata) ? input.metadata : null;
|
||||
|
||||
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||
|
||||
// GitHub or skills.sh with owner/repo metadata
|
||||
if (
|
||||
(input.sourceType === "github" || input.sourceType === "skills_sh") &&
|
||||
owner &&
|
||||
repo
|
||||
) {
|
||||
return `${owner}/${repo}/${slug}`;
|
||||
}
|
||||
|
||||
// URL source with locator
|
||||
if (input.sourceType === "url") {
|
||||
const locator = asString(input.sourceLocator);
|
||||
if (locator) {
|
||||
try {
|
||||
const url = new URL(locator);
|
||||
const host = normalizeSkillSlug(url.host) ?? "url";
|
||||
return `url/${host}/${hashSkillValue(locator)}/${slug}`;
|
||||
} catch {
|
||||
return `url/unknown/${hashSkillValue(locator)}/${slug}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local path source with locator
|
||||
if (input.sourceType === "local_path") {
|
||||
const locator = asString(input.sourceLocator);
|
||||
if (locator) {
|
||||
return `local/${hashSkillValue(resolve(locator))}/${slug}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: company-scoped key
|
||||
return `company/${companyId}/${slug}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill import source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Three-part key: org/repo/skill
|
||||
const THREE_PART_KEY_RE =
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
// Two-part key: org/repo
|
||||
const TWO_PART_KEY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
// skills.sh URL pattern
|
||||
const SKILLS_SH_RE =
|
||||
/^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i;
|
||||
|
||||
export interface ParsedSkillImportSource {
|
||||
resolvedSource: string;
|
||||
requestedSkillSlug: string | null;
|
||||
originalSkillsShUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw skill import source string and resolve it to a normalized form.
|
||||
*
|
||||
* Supported input formats:
|
||||
* - `owner/repo/skill` -> GitHub URL with skills.sh origin tracking
|
||||
* - `owner/repo` -> GitHub URL
|
||||
* - `https://skills.sh/owner/repo/skill` -> GitHub URL with skills.sh origin
|
||||
* - `https://github.com/owner/repo` -> pass through
|
||||
* - `/local/path` -> pass through
|
||||
*/
|
||||
export function parseSkillImportSource(
|
||||
rawInput: string,
|
||||
): ParsedSkillImportSource {
|
||||
const trimmed = rawInput.trim();
|
||||
|
||||
// Three-part key (org/repo/skill) -- originates from skills.sh registry
|
||||
if (
|
||||
!/^https?:\/\//i.test(trimmed) &&
|
||||
THREE_PART_KEY_RE.test(trimmed)
|
||||
) {
|
||||
const [owner, repo, skillSlugRaw] = trimmed.split("/");
|
||||
return {
|
||||
resolvedSource: `https://github.com/${owner}/${repo}`,
|
||||
requestedSkillSlug: normalizeSkillSlug(skillSlugRaw),
|
||||
originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Two-part key (org/repo) -- resolve to GitHub
|
||||
if (
|
||||
!/^https?:\/\//i.test(trimmed) &&
|
||||
TWO_PART_KEY_RE.test(trimmed)
|
||||
) {
|
||||
return {
|
||||
resolvedSource: `https://github.com/${trimmed}`,
|
||||
requestedSkillSlug: null,
|
||||
originalSkillsShUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
// skills.sh URL -> resolve to GitHub
|
||||
const skillsShMatch = trimmed.match(SKILLS_SH_RE);
|
||||
if (skillsShMatch) {
|
||||
const [, owner, repo, skillSlugRaw] = skillsShMatch;
|
||||
return {
|
||||
resolvedSource: `https://github.com/${owner}/${repo}`,
|
||||
requestedSkillSlug: skillSlugRaw
|
||||
? normalizeSkillSlug(skillSlugRaw)
|
||||
: null,
|
||||
originalSkillsShUrl: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Everything else -- pass through as resolvedSource
|
||||
return {
|
||||
resolvedSource: trimmed,
|
||||
requestedSkillSlug: null,
|
||||
originalSkillsShUrl: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillService -- full skill lifecycle: import, CRUD, assignment, token budget,
|
||||
// update detection. Factory pattern following createOrchestrationService.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, basename, dirname, relative, posix } from "node:path";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type {
|
||||
FileInventoryEntry,
|
||||
SkillRecord,
|
||||
SkillImportResult,
|
||||
SkillSourceType,
|
||||
SkillUpdateStatus,
|
||||
RuntimeSkillEntry,
|
||||
} from "./skill-types.js";
|
||||
import {
|
||||
parseFrontmatterMarkdown,
|
||||
classifyInventoryKind,
|
||||
deriveTrustLevel,
|
||||
estimateTokens,
|
||||
} from "./skill-format.js";
|
||||
import {
|
||||
deriveCanonicalSkillKey,
|
||||
normalizeSkillSlug,
|
||||
hashSkillValue,
|
||||
parseSkillImportSource,
|
||||
} from "./skill-keys.js";
|
||||
import {
|
||||
resolveGitHubCommitSha,
|
||||
fetchGitHubSkills,
|
||||
} from "./skill-github.js";
|
||||
import { TOKEN_BUDGETS } from "./skill-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkillServiceDependencies {
|
||||
db: AppDatabase;
|
||||
profileDir: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) {
|
||||
// ---- Result helpers ----
|
||||
function ok<T>(data: T): DesktopResult<T> {
|
||||
return { ok: true, data };
|
||||
}
|
||||
function fail(code: string, message: string): DesktopResult<never> {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
// ---- Path helpers (D-10: skills stored in profileDir/skills/{companyId}/) ----
|
||||
function skillsRoot(companyId: string): string {
|
||||
return join(profileDir, "skills", companyId);
|
||||
}
|
||||
|
||||
function skillFilesDir(companyId: string, slug: string): string {
|
||||
return join(skillsRoot(companyId), slug);
|
||||
}
|
||||
|
||||
function runtimeDir(companyId: string): string {
|
||||
return join(skillsRoot(companyId), "__runtime__");
|
||||
}
|
||||
|
||||
// ---- File materialization helpers ----
|
||||
|
||||
/**
|
||||
* Write SKILL.md (and tracked inventory files from GitHub downloads)
|
||||
* to the local skill directory.
|
||||
*/
|
||||
async function materializeSkillFiles(
|
||||
companyId: string,
|
||||
slug: string,
|
||||
markdown: string,
|
||||
_inventory: FileInventoryEntry[],
|
||||
): Promise<string> {
|
||||
const dir = skillFilesDir(companyId, slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "SKILL.md"), markdown, "utf-8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Remove a skill's file directory from disk. */
|
||||
async function cleanupSkillFiles(companyId: string, slug: string): Promise<void> {
|
||||
const dir = skillFilesDir(companyId, slug);
|
||||
if (existsSync(dir)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GitHub URL parser ----
|
||||
|
||||
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "github.com") return null;
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return {
|
||||
owner: parts[0]!,
|
||||
repo: parts[1]!.replace(/\.git$/i, ""),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Import routing ----
|
||||
|
||||
/**
|
||||
* Import skills from any supported source.
|
||||
* Routes to the correct handler based on parseSkillImportSource.
|
||||
*/
|
||||
async function importFromSource(
|
||||
companyId: string,
|
||||
rawSource: string,
|
||||
): Promise<DesktopResult<SkillImportResult[]>> {
|
||||
try {
|
||||
const parsed = parseSkillImportSource(rawSource);
|
||||
const source = parsed.resolvedSource;
|
||||
|
||||
// skills.sh resolved sources are always GitHub URLs
|
||||
if (parsed.originalSkillsShUrl) {
|
||||
const results = await importFromGitHub(
|
||||
companyId,
|
||||
source,
|
||||
parsed.requestedSkillSlug,
|
||||
parsed.originalSkillsShUrl,
|
||||
);
|
||||
return ok(results);
|
||||
}
|
||||
|
||||
// GitHub URL
|
||||
if (source.includes("github.com")) {
|
||||
const results = await importFromGitHub(
|
||||
companyId,
|
||||
source,
|
||||
parsed.requestedSkillSlug,
|
||||
null,
|
||||
);
|
||||
return ok(results);
|
||||
}
|
||||
|
||||
// Local path (absolute path, drive letter on Windows, or ~ home dir)
|
||||
if (
|
||||
source.startsWith("/") ||
|
||||
/^[A-Za-z]:/.test(source) ||
|
||||
source.startsWith("~")
|
||||
) {
|
||||
const results = await importFromLocal(companyId, source);
|
||||
return ok(results);
|
||||
}
|
||||
|
||||
// HTTP/HTTPS URL
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const results = await importFromUrl(companyId, source);
|
||||
return ok(results);
|
||||
}
|
||||
|
||||
return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Skill import failed:", message);
|
||||
return fail("import_error", message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GitHub import (SKILL-01) ----
|
||||
|
||||
async function importFromGitHub(
|
||||
companyId: string,
|
||||
url: string,
|
||||
requestedSlug: string | null,
|
||||
originalSkillsShUrl: string | null,
|
||||
): Promise<SkillImportResult[]> {
|
||||
const ghParsed = parseGitHubUrl(url);
|
||||
if (!ghParsed) {
|
||||
throw new Error(`Invalid GitHub URL: ${url}`);
|
||||
}
|
||||
const { owner, repo } = ghParsed;
|
||||
|
||||
// Resolve latest commit SHA
|
||||
const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger);
|
||||
if (!sha) {
|
||||
throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
// Fetch SKILL.md files from repo
|
||||
const { skills: githubSkills, commitSha } = await fetchGitHubSkills(
|
||||
owner, repo, sha, requestedSlug, logger,
|
||||
);
|
||||
|
||||
if (githubSkills.length === 0) {
|
||||
throw new Error(`No SKILL.md files found in ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
const results: SkillImportResult[] = [];
|
||||
const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github";
|
||||
const nowTs = new Date().toISOString();
|
||||
|
||||
for (const ghSkill of githubSkills) {
|
||||
const parsed = parseFrontmatterMarkdown(ghSkill.markdown);
|
||||
const rawSlug =
|
||||
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
|
||||
normalizeSkillSlug(
|
||||
ghSkill.skillMdPath === "SKILL.md"
|
||||
? repo
|
||||
: basename(dirname(ghSkill.skillMdPath)),
|
||||
) ??
|
||||
repo;
|
||||
|
||||
// Build file inventory from the tree paths
|
||||
const skillDir =
|
||||
ghSkill.skillMdPath === "SKILL.md"
|
||||
? ""
|
||||
: ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/"));
|
||||
const prefix = skillDir ? `${skillDir}/` : "";
|
||||
const inventory: FileInventoryEntry[] = [
|
||||
{ path: "SKILL.md", kind: "skill" as const },
|
||||
...ghSkill.inventory.map((filePath) => {
|
||||
const relativePath = prefix ? filePath.slice(prefix.length) : filePath;
|
||||
return {
|
||||
path: relativePath,
|
||||
kind: classifyInventoryKind(relativePath),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
sourceKind: "github",
|
||||
owner,
|
||||
repo,
|
||||
ref: commitSha,
|
||||
trackingRef: "HEAD",
|
||||
...(originalSkillsShUrl ? { originalSkillsShUrl } : {}),
|
||||
};
|
||||
|
||||
const key = deriveCanonicalSkillKey(companyId, {
|
||||
slug: rawSlug,
|
||||
sourceType,
|
||||
sourceLocator: url,
|
||||
metadata,
|
||||
});
|
||||
|
||||
const trustLevel = deriveTrustLevel(inventory);
|
||||
const name =
|
||||
(typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ??
|
||||
rawSlug;
|
||||
const description =
|
||||
typeof parsed.frontmatter.description === "string"
|
||||
? parsed.frontmatter.description
|
||||
: null;
|
||||
|
||||
const skillId = randomUUID();
|
||||
|
||||
// Materialize files to disk
|
||||
await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory);
|
||||
|
||||
// Upsert to database
|
||||
const { isUpdate } = db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key,
|
||||
slug: rawSlug,
|
||||
name,
|
||||
description,
|
||||
markdown: ghSkill.markdown,
|
||||
sourceType,
|
||||
sourceLocator: url,
|
||||
sourceRef: commitSha,
|
||||
trustLevel,
|
||||
compatibility: "compatible",
|
||||
fileInventory: JSON.stringify(inventory),
|
||||
metadata: JSON.stringify(metadata),
|
||||
createdAt: nowTs,
|
||||
updatedAt: nowTs,
|
||||
});
|
||||
|
||||
results.push({ skillId, key, name, isUpdate });
|
||||
logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Local path import (SKILL-02, D-04) ----
|
||||
|
||||
async function importFromLocal(
|
||||
companyId: string,
|
||||
localPath: string,
|
||||
): Promise<SkillImportResult[]> {
|
||||
const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? ""));
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`Local path does not exist: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath);
|
||||
const results: SkillImportResult[] = [];
|
||||
const nowTs = new Date().toISOString();
|
||||
|
||||
if (stat.isFile()) {
|
||||
// Single file treated as a skill
|
||||
const markdown = readFileSync(resolvedPath, "utf-8");
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const rawSlug =
|
||||
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
|
||||
normalizeSkillSlug(basename(resolvedPath, ".md")) ??
|
||||
"skill";
|
||||
const inventory: FileInventoryEntry[] = [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
];
|
||||
const trustLevel = deriveTrustLevel(inventory);
|
||||
const contentHash = hashSkillValue(markdown);
|
||||
|
||||
const key = deriveCanonicalSkillKey(companyId, {
|
||||
slug: rawSlug,
|
||||
sourceType: "local_path",
|
||||
sourceLocator: resolvedPath,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
const skillId = randomUUID();
|
||||
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
|
||||
|
||||
const { isUpdate } = db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key,
|
||||
slug: rawSlug,
|
||||
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
|
||||
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
|
||||
markdown,
|
||||
sourceType: "local_path",
|
||||
sourceLocator: resolvedPath,
|
||||
sourceRef: contentHash,
|
||||
trustLevel,
|
||||
compatibility: "compatible",
|
||||
fileInventory: JSON.stringify(inventory),
|
||||
metadata: null,
|
||||
createdAt: nowTs,
|
||||
updatedAt: nowTs,
|
||||
});
|
||||
|
||||
results.push({ skillId, key, name: rawSlug, isUpdate });
|
||||
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Directory: walk to find SKILL.md files
|
||||
const skillMdFiles = findSkillMdFiles(resolvedPath);
|
||||
if (skillMdFiles.length === 0) {
|
||||
throw new Error(`No SKILL.md files found in ${resolvedPath}`);
|
||||
}
|
||||
|
||||
for (const skillMdPath of skillMdFiles) {
|
||||
const skillDir = dirname(skillMdPath);
|
||||
const markdown = readFileSync(skillMdPath, "utf-8");
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const rawSlug =
|
||||
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
|
||||
normalizeSkillSlug(basename(skillDir)) ??
|
||||
"skill";
|
||||
|
||||
// Build inventory from directory listing
|
||||
const inventory = buildLocalInventory(skillDir, skillMdPath);
|
||||
const trustLevel = deriveTrustLevel(inventory);
|
||||
const contentHash = hashSkillValue(markdown);
|
||||
|
||||
const key = deriveCanonicalSkillKey(companyId, {
|
||||
slug: rawSlug,
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
const skillId = randomUUID();
|
||||
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
|
||||
|
||||
const { isUpdate } = db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key,
|
||||
slug: rawSlug,
|
||||
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
|
||||
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
|
||||
markdown,
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
sourceRef: contentHash,
|
||||
trustLevel,
|
||||
compatibility: "compatible",
|
||||
fileInventory: JSON.stringify(inventory),
|
||||
metadata: null,
|
||||
createdAt: nowTs,
|
||||
updatedAt: nowTs,
|
||||
});
|
||||
|
||||
results.push({ skillId, key, name: rawSlug, isUpdate });
|
||||
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- URL import (SKILL-03, D-05) ----
|
||||
|
||||
async function importFromUrl(
|
||||
companyId: string,
|
||||
url: string,
|
||||
): Promise<SkillImportResult[]> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`);
|
||||
}
|
||||
const markdown = await response.text();
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const nowTs = new Date().toISOString();
|
||||
|
||||
// Derive slug from frontmatter or URL path
|
||||
let urlSlug: string | null = null;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split("/").filter(Boolean);
|
||||
const lastPart = pathParts[pathParts.length - 1] ?? "";
|
||||
urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, ""));
|
||||
} catch {
|
||||
// ignore URL parsing errors
|
||||
}
|
||||
const rawSlug =
|
||||
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
|
||||
urlSlug ??
|
||||
"skill";
|
||||
|
||||
// Single-file skill: only SKILL.md
|
||||
const inventory: FileInventoryEntry[] = [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
];
|
||||
const trustLevel: "markdown_only" = "markdown_only";
|
||||
|
||||
const key = deriveCanonicalSkillKey(companyId, {
|
||||
slug: rawSlug,
|
||||
sourceType: "url",
|
||||
sourceLocator: url,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
const skillId = randomUUID();
|
||||
await materializeSkillFiles(companyId, rawSlug, markdown, inventory);
|
||||
|
||||
const { isUpdate } = db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key,
|
||||
slug: rawSlug,
|
||||
name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug,
|
||||
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null,
|
||||
markdown,
|
||||
sourceType: "url",
|
||||
sourceLocator: url,
|
||||
sourceRef: null,
|
||||
trustLevel,
|
||||
compatibility: "compatible",
|
||||
fileInventory: JSON.stringify(inventory),
|
||||
metadata: null,
|
||||
createdAt: nowTs,
|
||||
updatedAt: nowTs,
|
||||
});
|
||||
|
||||
const results: SkillImportResult[] = [
|
||||
{ skillId, key, name: rawSlug, isUpdate },
|
||||
];
|
||||
logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
function listForCompany(companyId: string): SkillRecord[] {
|
||||
return db.listCompanySkills(companyId);
|
||||
}
|
||||
|
||||
function getDetail(companyId: string, skillId: string): DesktopResult<SkillRecord> {
|
||||
const skill = db.getCompanySkill(companyId, skillId);
|
||||
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
|
||||
return ok(skill);
|
||||
}
|
||||
|
||||
/** Delete a skill: (1) assignments cascade via FK, (2) files from disk, (3) DB record per D-12. */
|
||||
async function deleteSkill(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
): Promise<DesktopResult<void>> {
|
||||
const skill = db.getCompanySkill(companyId, skillId);
|
||||
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
|
||||
|
||||
try {
|
||||
// D-12 order: (1) assignments cascade via FK on delete,
|
||||
// (2) delete files from disk
|
||||
await cleanupSkillFiles(companyId, skill.slug);
|
||||
// (3) delete DB record (cascades agent_skill_assignments via FK)
|
||||
db.deleteCompanySkill(companyId, skillId);
|
||||
logger.info(`Skill deleted: ${skill.key}`);
|
||||
return ok(undefined);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Skill deletion failed:", message);
|
||||
return fail("delete_error", message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Agent assignment (SKILL-07) ----
|
||||
|
||||
function assignToAgent(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
): DesktopResult<void> {
|
||||
const skill = db.getCompanySkill(companyId, skillId);
|
||||
if (!skill) return fail("not_found", `Skill ${skillId} not found for company`);
|
||||
db.assignSkillToAgent(agentId, skillId);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function removeFromAgent(
|
||||
_companyId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
): DesktopResult<void> {
|
||||
db.removeSkillFromAgent(agentId, skillId);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// ---- Update detection (SKILL-11, D-11) ----
|
||||
|
||||
async function checkUpdateStatus(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
): Promise<DesktopResult<SkillUpdateStatus>> {
|
||||
const skill = db.getCompanySkill(companyId, skillId);
|
||||
if (!skill) return fail("not_found", `Skill ${skillId} not found`);
|
||||
|
||||
// Only GitHub/skills_sh sources support update checks
|
||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
|
||||
return ok({
|
||||
skillId: skill.id,
|
||||
key: skill.key,
|
||||
currentRef: skill.sourceRef,
|
||||
latestRef: null,
|
||||
hasUpdate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse owner/repo from metadata
|
||||
const metadata = skill.metadata;
|
||||
const owner = typeof metadata?.owner === "string" ? metadata.owner : null;
|
||||
const repo = typeof metadata?.repo === "string" ? metadata.repo : null;
|
||||
|
||||
if (!owner || !repo) {
|
||||
// Try to parse from sourceLocator URL
|
||||
const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null;
|
||||
if (!ghParsed) {
|
||||
return ok({
|
||||
skillId: skill.id,
|
||||
key: skill.key,
|
||||
currentRef: skill.sourceRef,
|
||||
latestRef: null,
|
||||
hasUpdate: false,
|
||||
});
|
||||
}
|
||||
const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger);
|
||||
return ok({
|
||||
skillId: skill.id,
|
||||
key: skill.key,
|
||||
currentRef: skill.sourceRef,
|
||||
latestRef,
|
||||
hasUpdate: latestRef != null && latestRef !== skill.sourceRef,
|
||||
});
|
||||
}
|
||||
|
||||
const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined;
|
||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger);
|
||||
|
||||
return ok({
|
||||
skillId: skill.id,
|
||||
key: skill.key,
|
||||
currentRef: skill.sourceRef,
|
||||
latestRef,
|
||||
hasUpdate: latestRef != null && latestRef !== skill.sourceRef,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Runtime skill entries (SKILL-09 token budget) ----
|
||||
|
||||
/**
|
||||
* Build the list of RuntimeSkillEntry for adapter injection.
|
||||
* Applies token budget enforcement: skills sorted by creation date,
|
||||
* accumulated until budget exceeded, then skipped with warning.
|
||||
*/
|
||||
function listRuntimeSkillEntries(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
adapterType: string,
|
||||
): RuntimeSkillEntry[] {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
const agentAssignments = db.listAgentSkillAssignments(agentId);
|
||||
|
||||
// If agent has specific assignments, filter to those; otherwise use all
|
||||
const skills =
|
||||
agentAssignments.length > 0
|
||||
? allSkills.filter((s) => agentAssignments.includes(s.id))
|
||||
: allSkills;
|
||||
|
||||
// Sort by creation date (oldest first -- first imported gets priority)
|
||||
const sorted = [...skills].sort(
|
||||
(a, b) => a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
|
||||
const budget = TOKEN_BUDGETS[adapterType] ?? 30000;
|
||||
let accumulated = 0;
|
||||
const entries: RuntimeSkillEntry[] = [];
|
||||
|
||||
for (const skill of sorted) {
|
||||
const tokenEstimate = estimateTokens(skill.markdown);
|
||||
if (accumulated + tokenEstimate > budget) {
|
||||
logger.warn(
|
||||
`Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accumulated += tokenEstimate;
|
||||
entries.push({
|
||||
runtimeName: skill.slug,
|
||||
sourcePath: skillFilesDir(companyId, skill.slug),
|
||||
name: skill.name,
|
||||
tokenEstimate,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---- Local directory walker ----
|
||||
|
||||
function findSkillMdFiles(rootDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
function walk(dir: string) {
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.name === "SKILL.md") {
|
||||
found.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable directories
|
||||
}
|
||||
}
|
||||
walk(rootDir);
|
||||
return found;
|
||||
}
|
||||
|
||||
function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] {
|
||||
const inventory: FileInventoryEntry[] = [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
];
|
||||
try {
|
||||
const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const entryPath = entry.parentPath
|
||||
? join(entry.parentPath, entry.name)
|
||||
: join(skillDir, entry.name);
|
||||
const fullPath = resolve(entryPath);
|
||||
if (fullPath === skillMdPath) continue;
|
||||
const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/");
|
||||
inventory.push({
|
||||
path: relativePath,
|
||||
kind: classifyInventoryKind(relativePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If directory listing fails, return just the SKILL.md entry
|
||||
}
|
||||
return inventory;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
return {
|
||||
importFromSource,
|
||||
importFromGitHub,
|
||||
importFromLocal,
|
||||
importFromUrl,
|
||||
listForCompany,
|
||||
getDetail,
|
||||
deleteSkill,
|
||||
assignToAgent,
|
||||
removeFromAgent,
|
||||
checkUpdateStatus,
|
||||
listRuntimeSkillEntries,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill system type definitions and constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Source type for a skill -- matches the company_skills.source_type column.
|
||||
*/
|
||||
export type SkillSourceType =
|
||||
| "github"
|
||||
| "local_path"
|
||||
| "url"
|
||||
| "skills_sh"
|
||||
| "managed";
|
||||
|
||||
/**
|
||||
* Trust level derived from the file inventory of a skill package.
|
||||
* Determines what content is injected and how cautiously.
|
||||
*/
|
||||
export type TrustLevel =
|
||||
| "markdown_only"
|
||||
| "assets"
|
||||
| "scripts_executables";
|
||||
|
||||
/**
|
||||
* A single file entry within a skill package inventory.
|
||||
*/
|
||||
export interface FileInventoryEntry {
|
||||
path: string;
|
||||
kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full row interface matching the company_skills table.
|
||||
* Property names use camelCase (JS convention) even though DB columns are snake_case.
|
||||
*/
|
||||
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<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned after importing or updating a skill.
|
||||
*/
|
||||
export interface SkillImportResult {
|
||||
skillId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
isUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime representation of a skill entry for adapter injection.
|
||||
*/
|
||||
export interface RuntimeSkillEntry {
|
||||
runtimeName: string;
|
||||
sourcePath: string;
|
||||
name: string;
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal skill reference passed to worker threads.
|
||||
* Subset of RuntimeSkillEntry to minimize serialization overhead.
|
||||
*/
|
||||
export interface ManagedSkillRef {
|
||||
runtimeName: string;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill-to-agent assignment record.
|
||||
*/
|
||||
export interface SkillAssignment {
|
||||
agentId: string;
|
||||
skillId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status for a skill -- used to check if a newer version is available.
|
||||
*/
|
||||
export interface SkillUpdateStatus {
|
||||
skillId: string;
|
||||
key: string;
|
||||
currentRef: string | null;
|
||||
latestRef: string | null;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-adapter token budgets for skill content injection.
|
||||
* Values represent the maximum token count of skill content that can be
|
||||
* included in a single prompt for each adapter type.
|
||||
*/
|
||||
export const TOKEN_BUDGETS: Readonly<Record<string, number>> = {
|
||||
claude_local: 50000,
|
||||
codex_local: 30000,
|
||||
gemini_local: 50000,
|
||||
opencode_local: 30000,
|
||||
kimi_local: 20000,
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Task Quality Gates — pure validation functions for task creation.
|
||||
*
|
||||
* Called by IPC handlers, API server, and automation service to enforce
|
||||
* minimum description quality, acceptance criteria, and capability matching.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaskValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface CapabilityMatchResult {
|
||||
matched: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/** Minimal DB interface for capability matching — keeps the function testable with mocks.
|
||||
* Skill methods are optional since the skill system may not be implemented yet (graceful degradation per D-07). */
|
||||
export interface CapabilityMatchDb {
|
||||
getAgent(id: string): { name: string; capabilities: string };
|
||||
listAgentSkillAssignments?(id: string): string[];
|
||||
getCompanySkill?(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_DESCRIPTION_LENGTH = 50;
|
||||
|
||||
export const ACCEPTANCE_CRITERIA_KEYWORDS: readonly string[] = [
|
||||
"acceptance criteria",
|
||||
"done when",
|
||||
"success criteria",
|
||||
"definition of done",
|
||||
"requirements:",
|
||||
"must:",
|
||||
"should:",
|
||||
"- [ ]",
|
||||
"- [x]",
|
||||
"deliverable:",
|
||||
"expected outcome",
|
||||
"exit criteria",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkAcceptanceCriteria
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function checkAcceptanceCriteria(description: string): { hasCriteria: boolean; suggestion: string } {
|
||||
const lower = description.toLowerCase();
|
||||
const found = ACCEPTANCE_CRITERIA_KEYWORDS.some((kw) => lower.includes(kw));
|
||||
|
||||
if (found) {
|
||||
return { hasCriteria: true, suggestion: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
hasCriteria: false,
|
||||
suggestion:
|
||||
'Task description should include acceptance criteria. Add a "Done when:" or "Acceptance criteria:" section, or use checkbox items (- [ ]) to define success conditions.',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateTaskCreation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function validateTaskCreation(
|
||||
input: { title: string; description: string },
|
||||
isHeartbeat: boolean,
|
||||
): TaskValidationResult {
|
||||
// Heartbeat tasks bypass all validation
|
||||
if (isHeartbeat) {
|
||||
return { valid: true, errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// TASK-01: Minimum description length
|
||||
if (input.description.length < MIN_DESCRIPTION_LENGTH) {
|
||||
errors.push(
|
||||
`Task description must be at least ${MIN_DESCRIPTION_LENGTH} characters (currently ${input.description.length}). Add details about what needs to be done, expected behavior, and any constraints.`,
|
||||
);
|
||||
}
|
||||
|
||||
// TASK-02: Acceptance criteria detection (only check if length passes)
|
||||
if (input.description.length >= MIN_DESCRIPTION_LENGTH) {
|
||||
const criteria = checkAcceptanceCriteria(input.description);
|
||||
if (!criteria.hasCriteria) {
|
||||
errors.push(criteria.suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkCapabilityMatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tokenize text into words >3 chars, same pattern as database.ts autoAssignTask */
|
||||
function tokenize(text: string): string[] {
|
||||
return text.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
|
||||
}
|
||||
|
||||
export function checkCapabilityMatch(
|
||||
db: CapabilityMatchDb,
|
||||
agentId: string,
|
||||
taskDescription: string,
|
||||
companyId: string,
|
||||
): CapabilityMatchResult {
|
||||
const taskWords = tokenize(taskDescription);
|
||||
|
||||
// Nothing to match against — task description too short/vague
|
||||
if (taskWords.length === 0) {
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
const agent = db.getAgent(agentId);
|
||||
const capWords = tokenize(agent.capabilities);
|
||||
const capabilityOverlap = taskWords.filter((w) => capWords.includes(w)).length;
|
||||
|
||||
// Check skill assignments for additional matching (graceful degradation)
|
||||
let skillOverlap = 0;
|
||||
try {
|
||||
if (db.listAgentSkillAssignments && db.getCompanySkill) {
|
||||
const skillIds = db.listAgentSkillAssignments(agentId);
|
||||
for (const skillId of skillIds) {
|
||||
const skill = db.getCompanySkill(companyId, skillId);
|
||||
if (skill) {
|
||||
const skillWords = tokenize(`${skill.name} ${skill.description ?? ""} ${skill.slug}`);
|
||||
skillOverlap += taskWords.filter((w) => skillWords.includes(w)).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skill system may not be available — graceful degradation per D-07
|
||||
}
|
||||
|
||||
// Direct capability or skill overlap found
|
||||
if (capabilityOverlap > 0 || skillOverlap > 0) {
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
// No capabilities defined at all — graceful degradation (Pitfall 4)
|
||||
if (capWords.length === 0 && skillOverlap === 0) {
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
// Agent has capabilities but no overlap — warn
|
||||
return {
|
||||
matched: false,
|
||||
warning: `Agent "${agent.name}" may lack skills for this task. Their capabilities (${agent.capabilities.slice(0, 80)}...) have no overlap with the task description. Consider reassigning.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TASK-04: Stale Review Escalation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (D-08)
|
||||
|
||||
export interface SweepStaleReviewsDeps {
|
||||
db: {
|
||||
queryAll: <T>(sql: string, ...params: unknown[]) => T[];
|
||||
getManagerForAgent: (agentId: string) => { id: string; name: string; companyId: string } | null;
|
||||
getChainOfCommand: (agentId: string) => Array<{ id: string; name: string }>;
|
||||
advanceTaskStatus: (taskId: string, newStatus: string, comment?: string) => { previousStatus: string };
|
||||
};
|
||||
dispatchAgentMessage: (
|
||||
deps: {
|
||||
db: SweepStaleReviewsDeps["db"];
|
||||
emitEvent: (...args: unknown[]) => void;
|
||||
publishDomainChanged: () => void;
|
||||
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => void;
|
||||
},
|
||||
message: {
|
||||
companyId: string;
|
||||
fromAgentId: string;
|
||||
toAgentId: string | null;
|
||||
channel: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
},
|
||||
wakeTrigger: string,
|
||||
) => { ok: boolean };
|
||||
resolveApprovalDecision: (params: {
|
||||
db: SweepStaleReviewsDeps["db"];
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
companyId: string;
|
||||
approvalId: string;
|
||||
state: "approved" | "rejected" | "revision_requested";
|
||||
decisionNote?: string;
|
||||
detail?: string | null;
|
||||
actor?: string;
|
||||
}) => void;
|
||||
notify: (options: {
|
||||
title: string;
|
||||
body: string;
|
||||
urgency?: string;
|
||||
navigation?: { section: string; entityId?: string };
|
||||
batchKey?: string;
|
||||
}) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
emitEvent: (...args: unknown[]) => void;
|
||||
publishDomainChanged: () => void;
|
||||
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => void;
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
export function sweepStaleReviews(
|
||||
companyId: string,
|
||||
deps: SweepStaleReviewsDeps,
|
||||
now: number = Date.now(),
|
||||
): number {
|
||||
let escalatedCount = 0;
|
||||
const reviewTasks = deps.db.queryAll<{
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}>(
|
||||
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND status = 'in_review'`,
|
||||
companyId,
|
||||
);
|
||||
|
||||
for (const task of reviewTasks) {
|
||||
const reviewAge = now - new Date(task.updatedAt).getTime();
|
||||
if (reviewAge < REVIEW_TIMEOUT_MS) continue;
|
||||
if (!task.assigneeAgentId) continue;
|
||||
|
||||
// The reviewer is the assignee's direct manager
|
||||
const reviewer = deps.db.getManagerForAgent(task.assigneeAgentId);
|
||||
if (!reviewer) continue; // No manager = CEO-assigned task, skip escalation
|
||||
|
||||
// D-09: Escalate to the reviewer's manager (manager's manager)
|
||||
const chain = deps.db.getChainOfCommand(reviewer.id);
|
||||
const escalationTarget = chain.length > 0 ? chain[0] : null;
|
||||
|
||||
if (escalationTarget) {
|
||||
deps.dispatchAgentMessage(
|
||||
{
|
||||
db: deps.db,
|
||||
emitEvent: deps.emitEvent,
|
||||
publishDomainChanged: deps.publishDomainChanged,
|
||||
wakeAgentIfPossible: deps.wakeAgentIfPossible,
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
fromAgentId: reviewer.id,
|
||||
toAgentId: escalationTarget.id,
|
||||
channel: "direct",
|
||||
subject: `Review timeout: ${task.title}`,
|
||||
body: `Review by ${reviewer.name} on "${task.title}" has been pending for over 30 minutes. Auto-escalating.`,
|
||||
priority: "urgent",
|
||||
},
|
||||
"subtask_completed",
|
||||
);
|
||||
} else {
|
||||
// D-09: No higher manager (reviewer is CEO) -- notify human
|
||||
deps.notify({
|
||||
title: "Review Timeout",
|
||||
body: `"${task.title}" review has been pending over 30 minutes. Auto-approving.`,
|
||||
urgency: "critical",
|
||||
navigation: { section: "tasks", entityId: task.id },
|
||||
batchKey: "review_escalation",
|
||||
});
|
||||
}
|
||||
|
||||
// D-10: Auto-approve any pending approval linked to this task
|
||||
const pendingApprovals = deps.db.queryAll<{ id: string }>(
|
||||
`SELECT id FROM approvals
|
||||
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
|
||||
LIMIT 1`,
|
||||
companyId,
|
||||
task.id,
|
||||
);
|
||||
for (const approval of pendingApprovals) {
|
||||
try {
|
||||
deps.resolveApprovalDecision({
|
||||
db: deps.db,
|
||||
handleApprovalResolved: deps.handleApprovalResolved,
|
||||
companyId,
|
||||
approvalId: approval.id,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved: review timeout exceeded 30 minutes.",
|
||||
detail: "Auto-approved by review timeout escalation",
|
||||
actor: "system",
|
||||
});
|
||||
} catch {
|
||||
// Already decided (race condition with manual approval) -- skip silently (Pitfall 3)
|
||||
}
|
||||
}
|
||||
|
||||
// D-10: Advance task status from in_review to done
|
||||
try {
|
||||
deps.db.advanceTaskStatus(task.id, "done", "Auto-advanced: review timeout exceeded 30 minutes.");
|
||||
deps.handleTaskStatusChange(task.id, companyId, "in_review", "done");
|
||||
} catch {
|
||||
// Task may have been updated by manual action
|
||||
}
|
||||
|
||||
escalatedCount++;
|
||||
}
|
||||
|
||||
return escalatedCount;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { RunRecord } from "@shared/types";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
import type { createSessionManagementService } from "./session-management-service";
|
||||
|
||||
const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]);
|
||||
export const workerMessageSchema = z.union([
|
||||
@@ -32,6 +34,8 @@ export const workerMessageSchema = z.union([
|
||||
sessionDisplayId: z.string().nullable(),
|
||||
costUsd: z.number().nullable(),
|
||||
unattributedCost: z.boolean(),
|
||||
inputTokens: z.number().nullable(),
|
||||
outputTokens: z.number().nullable(),
|
||||
}),
|
||||
]);
|
||||
type WorkerUiEvent = Extract<z.infer<typeof workerMessageSchema>, { type: "run-log" | "run-status" }>;
|
||||
@@ -63,6 +67,8 @@ interface WorkerServiceDependencies {
|
||||
}) => void;
|
||||
processRunCompletion: (run: RunRecord, event: z.infer<typeof workerMessageSchema> & { type: "run-finished" }) => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
budgetService?: ReturnType<typeof createBudgetEnforcementService>;
|
||||
sessionService?: ReturnType<typeof createSessionManagementService>;
|
||||
}
|
||||
|
||||
export function createWorkerService({
|
||||
@@ -76,6 +82,8 @@ export function createWorkerService({
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion,
|
||||
notify,
|
||||
budgetService,
|
||||
sessionService,
|
||||
}: WorkerServiceDependencies) {
|
||||
const MAX_RETRY_ATTEMPTS = 2;
|
||||
const RETRY_COOLDOWN_MS = 30_000; // 30 seconds
|
||||
@@ -107,6 +115,7 @@ export function createWorkerService({
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const affectedAgentIds = new Set<string>();
|
||||
const affectedCompanyIds = new Map<string, string>();
|
||||
const requeueCandidates: Array<{ id: string; taskId: string; agentId: string; companyId: string; workspaceId: string | null; connectorId: string }> = [];
|
||||
|
||||
for (const run of snapshot.runs) {
|
||||
@@ -126,7 +135,10 @@ export function createWorkerService({
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
// EXEC-02: Release execution lock on crash recovery
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
affectedAgentIds.add(run.agentId);
|
||||
affectedCompanyIds.set(run.agentId, run.companyId);
|
||||
|
||||
const chain = db.getChainOfCommand(run.agentId);
|
||||
if (chain.length > 0) {
|
||||
@@ -145,10 +157,13 @@ export function createWorkerService({
|
||||
}
|
||||
|
||||
for (const agentId of affectedAgentIds) {
|
||||
const pendingWakes = db.consumePendingWakes(agentId);
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(agentId, wake.companyId, wake.trigger);
|
||||
// EXEC-05: Promote deferred wakes after crash recovery
|
||||
const cid = affectedCompanyIds.get(agentId) ?? "";
|
||||
const promoted = db.promoteWakeupRequests(agentId, cid);
|
||||
if (promoted > 0) {
|
||||
logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${agentId} after crash recovery`);
|
||||
}
|
||||
// Let heartbeat scheduler pick up promoted wakes on next tick
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
@@ -280,6 +295,31 @@ export function createWorkerService({
|
||||
entityId: run.id,
|
||||
detail: event.summary ?? event.errorMessage ?? "",
|
||||
});
|
||||
|
||||
// SESS-01/SESS-02/SESS-03/SESS-05: Token accumulation and rotation evaluation
|
||||
if (task && sessionService) {
|
||||
const activeSession = db.getActiveTaskSession(task.id);
|
||||
if (activeSession) {
|
||||
// D-08/D-05: Accumulate token usage for ALL adapters (including exempt ones)
|
||||
sessionService.accumulateTokenUsage(
|
||||
activeSession.id,
|
||||
event.inputTokens ?? 0,
|
||||
event.outputTokens ?? 0,
|
||||
event.runId,
|
||||
);
|
||||
// D-03: Evaluate rotation after accumulation (after each run completes)
|
||||
const rotationResult = sessionService.evaluateRotation(activeSession, run.connectorId);
|
||||
if (rotationResult.shouldRotate) {
|
||||
// Re-read to get updated counts after accumulation
|
||||
const updatedSession = db.getActiveTaskSession(task.id);
|
||||
if (updatedSession) {
|
||||
sessionService.rotateSession(updatedSession);
|
||||
logger.info(`[session] Rotated session for task ${task.id}: ${rotationResult.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent({ type: "run-updated", runId: run.id, action: "completed" });
|
||||
emitEvent({
|
||||
type: "run-status",
|
||||
@@ -323,6 +363,16 @@ export function createWorkerService({
|
||||
emitEvent({ type: "agent-updated", agentId: updatedAgent.id, action: "paused" });
|
||||
}
|
||||
|
||||
// Policy-based incident check after run completion (Phase 5)
|
||||
if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) {
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null);
|
||||
} catch (err) {
|
||||
logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
|
||||
const retryCount = db.getTaskRetryCount(run.taskId);
|
||||
|
||||
@@ -381,10 +431,15 @@ export function createWorkerService({
|
||||
}
|
||||
}
|
||||
|
||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||
if (pendingWakes.length > 0 && !isQuitting() && !destroyed) {
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||
// EXEC-05: Promote deferred wakes after run completion
|
||||
let hadDeferredWakes = false;
|
||||
if (!isQuitting() && !destroyed) {
|
||||
const promoted = db.promoteWakeupRequests(run.agentId, run.companyId);
|
||||
if (promoted > 0) {
|
||||
hadDeferredWakes = true;
|
||||
logger.info(`[worker] Promoted ${promoted} deferred wake(s) for agent ${run.agentId}`);
|
||||
// Trigger processing of newly-promoted wakes
|
||||
wakeAgentIfPossible(run.agentId, run.companyId, "deferred_wake_promoted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +459,7 @@ export function createWorkerService({
|
||||
}
|
||||
|
||||
// Self-wake with circuit breaker to prevent infinite loops
|
||||
if (pendingWakes.length === 0 && !isQuitting() && !destroyed) {
|
||||
if (!hadDeferredWakes && !isQuitting() && !destroyed) {
|
||||
try {
|
||||
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
||||
if (actionableTasks.length > 0) {
|
||||
|
||||
@@ -76,12 +76,41 @@ const api: DesktopApi = {
|
||||
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
|
||||
runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input),
|
||||
autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input),
|
||||
importSkill: (companyId, source) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.importSkill, { companyId, source }),
|
||||
listSkills: (companyId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.listSkills, { companyId }),
|
||||
getSkillDetail: (companyId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getSkillDetail, { companyId, skillId }),
|
||||
deleteSkill: (companyId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.deleteSkill, { companyId, skillId }),
|
||||
assignSkillToAgent: (companyId, agentId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.assignSkillToAgent, { companyId, agentId, skillId }),
|
||||
removeSkillFromAgent: (companyId, agentId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.removeSkillFromAgent, { companyId, agentId, skillId }),
|
||||
checkSkillUpdate: (companyId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.checkSkillUpdate, { companyId, skillId }),
|
||||
listSkillAgents: (companyId, skillId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.listSkillAgents, { companyId, skillId }),
|
||||
listAgentSkills: (companyId, agentId) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.listAgentSkills, { companyId, agentId }),
|
||||
listWakeupRequests: (input) => ipcRenderer.invoke(IPC_CHANNELS.listWakeupRequests, input),
|
||||
saveBudgetPolicy: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveBudgetPolicy, input),
|
||||
deleteBudgetPolicy: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteBudgetPolicy, input),
|
||||
listBudgetPolicies: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.listBudgetPolicies, { companyId }),
|
||||
listBudgetIncidents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBudgetIncidents, input),
|
||||
resolveBudgetIncident: (input) => ipcRenderer.invoke(IPC_CHANNELS.resolveBudgetIncident, input),
|
||||
getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }),
|
||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }),
|
||||
recoveryDismiss: (taskId: string) =>
|
||||
ipcRenderer.invoke("recovery:dismiss", { taskId }),
|
||||
recoveryFail: (taskId: string) =>
|
||||
ipcRenderer.invoke("recovery:fail", { taskId }),
|
||||
getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input),
|
||||
getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input),
|
||||
getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input),
|
||||
getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input),
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||
|
||||
+65
-15
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
CaretUpDown,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MagnifyingGlass,
|
||||
Megaphone,
|
||||
Plus,
|
||||
PuzzlePiece,
|
||||
UsersThree,
|
||||
ShieldCheck,
|
||||
Trash,
|
||||
@@ -59,6 +60,8 @@ import {
|
||||
WorkspaceInspector,
|
||||
WorkspaceList,
|
||||
} from "./components/execution";
|
||||
import { PolicyList } from "./components/execution/PolicyList";
|
||||
import { IncidentList } from "./components/execution/IncidentList";
|
||||
import {
|
||||
AgentCreator,
|
||||
AgentEditor,
|
||||
@@ -75,6 +78,7 @@ import { SocialAccountsPanel } from "./components/social";
|
||||
import { AutomationPanel } from "./components/automation";
|
||||
import { CommunicationHub } from "./components/communication";
|
||||
import { HiringPipeline } from "./components/hiring";
|
||||
import { SkillsSection } from "./components/skills";
|
||||
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations";
|
||||
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
|
||||
import ToastContainer from "./components/Toast";
|
||||
@@ -94,6 +98,7 @@ const sidebarNavDefs: NavItemDef[] = [
|
||||
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
|
||||
{ id: "tasks", labelKey: "nav.work", icon: ListChecks },
|
||||
{ id: "communication", labelKey: "nav.communication", icon: ChatCircle },
|
||||
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
|
||||
{ id: "documents", labelKey: "nav.documents", icon: FileText },
|
||||
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
|
||||
{ id: "social", labelKey: "nav.social", icon: Megaphone },
|
||||
@@ -172,6 +177,8 @@ export function App() {
|
||||
const browserActions = useAppStore((s) => s.browserActions);
|
||||
const setBrowserActions = useAppStore((s) => s.setBrowserActions);
|
||||
|
||||
const [costsTab, setCostsTab] = useState<"costs" | "policies" | "incidents">("costs");
|
||||
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
const store = useAppStore.getState();
|
||||
store.clearCompanyScopedSelections();
|
||||
@@ -181,6 +188,10 @@ export function App() {
|
||||
store.setSocialAccounts([]);
|
||||
store.setBrowserActions([]);
|
||||
store.setBadges(null);
|
||||
store.setSkills([]);
|
||||
store.setSelectedSkillId(null);
|
||||
store.setSelectedSkillFilePath("SKILL.md");
|
||||
store.setSkillImportModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -783,6 +794,7 @@ export function App() {
|
||||
{section === "overview" ? (
|
||||
<Dashboard
|
||||
companyName={currentCompany?.name}
|
||||
companyId={currentCompany?.id}
|
||||
agents={companyAgents}
|
||||
tasks={tasks}
|
||||
runs={runs}
|
||||
@@ -1101,20 +1113,54 @@ export function App() {
|
||||
) : null}
|
||||
|
||||
{section === "costs" ? (
|
||||
<EntityWorkspace
|
||||
title="Costs"
|
||||
eyebrow={undefined}
|
||||
primary={
|
||||
<Panel title="Recent cost entries">
|
||||
<CostList costs={companyCosts} agents={companyAgents} runs={runs} />
|
||||
</Panel>
|
||||
}
|
||||
secondary={
|
||||
<Panel title="Budget posture">
|
||||
<BudgetSummary agents={companyAgents} costs={companyCosts} />
|
||||
</Panel>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-1 border-b border-[color:var(--line)] px-4 pt-2">
|
||||
{(["costs", "policies", "incidents"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setCostsTab(tab)}
|
||||
className={`rounded-t px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
costsTab === tab
|
||||
? "border-b-2 border-[color:var(--accent)] text-[color:var(--text)]"
|
||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{tab === "costs" ? "Cost Entries" : tab === "policies" ? "Policies" : "Incidents"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Tab content */}
|
||||
{costsTab === "costs" ? (
|
||||
<EntityWorkspace
|
||||
title="Costs"
|
||||
eyebrow={undefined}
|
||||
primary={
|
||||
<Panel title="Recent cost entries">
|
||||
<CostList costs={companyCosts} agents={companyAgents} runs={runs} />
|
||||
</Panel>
|
||||
}
|
||||
secondary={
|
||||
<Panel title="Budget posture">
|
||||
<BudgetSummary agents={companyAgents} costs={companyCosts} companyId={currentCompany?.id} />
|
||||
</Panel>
|
||||
}
|
||||
/>
|
||||
) : costsTab === "policies" ? (
|
||||
<PolicyList
|
||||
companyId={currentCompany!.id}
|
||||
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
|
||||
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
) : (
|
||||
<IncidentList
|
||||
companyId={currentCompany!.id}
|
||||
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
|
||||
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section === "activity" ? (
|
||||
@@ -1193,6 +1239,10 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "skills" && currentCompany ? (
|
||||
<SkillsSection companyId={currentCompany.id} agents={companyAgents} onRefresh={refresh} />
|
||||
) : null}
|
||||
|
||||
{section === "automation" && currentCompany ? (
|
||||
<AutomationPanel
|
||||
key={currentCompany.id}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Warning, X } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import type { AgentRecord, SectionId } from "@shared/types";
|
||||
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
|
||||
import { useReducedMotion } from "../ui";
|
||||
|
||||
export interface BudgetAlertBannerProps {
|
||||
agents: AgentRecord[];
|
||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface AgentOverThreshold {
|
||||
@@ -15,10 +16,21 @@ interface AgentOverThreshold {
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) {
|
||||
export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
|
||||
|
||||
// Load policy-based incidents when companyId is provided
|
||||
useEffect(() => {
|
||||
if (!companyId) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" });
|
||||
if (result.ok) setIncidents(result.data);
|
||||
})();
|
||||
}, [companyId]);
|
||||
|
||||
// Fallback: agent-level threshold detection (existing logic)
|
||||
const agentsOverThreshold = useMemo<AgentOverThreshold[]>(() => {
|
||||
return agents
|
||||
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
|
||||
@@ -30,34 +42,102 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
}, [agents]);
|
||||
|
||||
// Reset dismissed state when the set of agents over threshold changes
|
||||
const overThresholdKey = useMemo(
|
||||
() => agentsOverThreshold.map((a) => a.id).sort().join(","),
|
||||
[agentsOverThreshold],
|
||||
);
|
||||
// Determine whether to use policy-based or agent-based alerts
|
||||
const usePolicyAlerts = companyId && incidents.length > 0;
|
||||
|
||||
// Reset dismissed state when the set of alerts changes
|
||||
const alertKey = useMemo(() => {
|
||||
if (usePolicyAlerts) {
|
||||
return incidents.map((i) => i.id).sort().join(",");
|
||||
}
|
||||
return agentsOverThreshold.map((a) => a.id).sort().join(",");
|
||||
}, [usePolicyAlerts, incidents, agentsOverThreshold]);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(false);
|
||||
}, [overThresholdKey]);
|
||||
}, [alertKey]);
|
||||
|
||||
const handleDismiss = useCallback(() => setDismissed(true), []);
|
||||
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
|
||||
|
||||
if (agentsOverThreshold.length === 0) return null;
|
||||
// Nothing to show
|
||||
if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
const content = (
|
||||
function getScopeName(scopeType: string, scopeId: string): string {
|
||||
if (scopeType === "company") return "Company";
|
||||
if (scopeType === "agent") {
|
||||
const agent = agents.find((a) => a.id === scopeId);
|
||||
return agent ? agent.name : scopeId.slice(0, 8);
|
||||
}
|
||||
return scopeId.slice(0, 8);
|
||||
}
|
||||
|
||||
// Policy-based alert content
|
||||
const policyContent = usePolicyAlerts ? (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((incident) => {
|
||||
const isHardStop = incident.thresholdType === "hard_stop";
|
||||
const borderColor = isHardStop ? "border-[color:var(--danger)]" : "border-[color:var(--warn)]";
|
||||
const bgColor = isHardStop ? "bg-[color:var(--danger-soft)]" : "bg-[color:var(--warn-soft)]";
|
||||
const textColor = isHardStop ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]";
|
||||
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={incident.id}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={`rounded-[8px] border ${borderColor} border-opacity-30 ${bgColor} px-4 py-3`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<Warning size={18} weight="bold" className={textColor} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-semibold ${textColor}`}>
|
||||
{isHardStop ? "Budget Hard Stop" : "Budget Warning"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[13px] text-[color:var(--muted-strong)]">
|
||||
{getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewCosts}
|
||||
className="mt-1.5 text-[11px] font-medium text-[color:var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
View Costs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss budget alerts"
|
||||
aria-label="Dismiss budget alerts"
|
||||
className="shrink-0 rounded p-0.5 text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Fallback agent-level alert content (existing logic)
|
||||
const agentContent = !usePolicyAlerts ? (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<Warning size={18} weight="bold" className="text-[color:var(--warn)]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-semibold text-[color:var(--warn)]">
|
||||
Budget Alert
|
||||
@@ -88,8 +168,6 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
View Costs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
@@ -101,9 +179,9 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
) : null;
|
||||
|
||||
if (dismissed) return null;
|
||||
const content = usePolicyAlerts ? policyContent : agentContent;
|
||||
|
||||
if (reducedMotion) {
|
||||
return content;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
|
||||
import { useReducedMotion } from "../ui";
|
||||
import {
|
||||
aggregateRunsByDay,
|
||||
aggregateCostsByDay,
|
||||
aggregateAgentWorkload,
|
||||
} from "./charts/chart-utils";
|
||||
import { ChartCard } from "./charts/ChartCard";
|
||||
import { RunActivityChart } from "./charts/RunActivityChart";
|
||||
import { SuccessRateChart } from "./charts/SuccessRateChart";
|
||||
import { CostTrendChart } from "./charts/CostTrendChart";
|
||||
import { AgentWorkloadChart } from "./charts/AgentWorkloadChart";
|
||||
import { BudgetUtilization } from "./charts/BudgetUtilization";
|
||||
|
||||
interface ChartSectionProps {
|
||||
runs: RunRecord[];
|
||||
costs: CostRecord[];
|
||||
agents: AgentRecord[];
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]);
|
||||
const dailyCosts = useMemo(() => aggregateCostsByDay(costs, 14), [costs]);
|
||||
const agentWorkload = useMemo(
|
||||
() => aggregateAgentWorkload(runs, agents),
|
||||
[runs, agents],
|
||||
);
|
||||
|
||||
const hasRunData = dailyRuns.some((d) => d.total > 0);
|
||||
const hasCostData = dailyCosts.some((d) => d.total > 0);
|
||||
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
|
||||
Performance
|
||||
</div>
|
||||
|
||||
{/* 2-column chart grid */}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<ChartCard
|
||||
title="Run Activity"
|
||||
ariaLabel={`Run activity chart showing ${dailyRuns.reduce((s, d) => s + d.total, 0)} total runs over 14 days`}
|
||||
isEmpty={!hasRunData}
|
||||
>
|
||||
<RunActivityChart data={dailyRuns} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Success Rate"
|
||||
ariaLabel="Success rate chart showing percentage of succeeded runs over 14 days"
|
||||
isEmpty={!hasRunData}
|
||||
>
|
||||
<SuccessRateChart data={dailyRuns} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Cost Trend"
|
||||
ariaLabel="Cost trend chart showing daily spend over 14 days"
|
||||
isEmpty={!hasCostData}
|
||||
>
|
||||
<CostTrendChart data={dailyCosts} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Agent Workload"
|
||||
ariaLabel="Agent workload chart showing run distribution across agents"
|
||||
isEmpty={agentWorkload.length === 0}
|
||||
>
|
||||
<AgentWorkloadChart data={agentWorkload} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Budget Utilization section */}
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
|
||||
Budget Utilization
|
||||
</div>
|
||||
<BudgetUtilization companyId={companyId} agents={agents} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (reducedMotion) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,11 @@ import { MetricCard } from "./MetricsGrid";
|
||||
import { ActivityFeed } from "./ActivityFeed";
|
||||
import { RecoveryBanner } from "./RecoveryBanner";
|
||||
import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
||||
import { ChartSection } from "./ChartSection";
|
||||
|
||||
export interface DashboardProps {
|
||||
companyName?: string;
|
||||
companyId?: string;
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
@@ -44,7 +46,7 @@ export interface DashboardProps {
|
||||
|
||||
export function Dashboard(props: DashboardProps) {
|
||||
const t = useT();
|
||||
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
||||
@@ -95,7 +97,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
|
||||
{/* Conditional banners */}
|
||||
<RecoveryBanner />
|
||||
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} />
|
||||
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
|
||||
|
||||
{/* Key Metrics — 4 cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
@@ -131,6 +133,9 @@ export function Dashboard(props: DashboardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Charts */}
|
||||
{companyId ? <ChartSection runs={runs} costs={costs} agents={agents} companyId={companyId} /> : null}
|
||||
|
||||
{/* Needs Attention — moved to top, only shows when items exist */}
|
||||
{hasAttentionItems ? (
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { AgentWorkloadData } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface AgentWorkloadChartProps {
|
||||
data: AgentWorkloadData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
|
||||
<Bar
|
||||
dataKey="runs"
|
||||
fill="var(--accent)"
|
||||
fillOpacity={0.85}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AgentRecord, BudgetStatusSummary } from "@shared/types";
|
||||
|
||||
interface BudgetUtilizationProps {
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
}
|
||||
|
||||
interface BudgetRow {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: string;
|
||||
spent: number;
|
||||
budget: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 100) return "bg-[color:var(--danger)]";
|
||||
if (pct >= 80) return "bg-[color:var(--warn)]";
|
||||
return "bg-[color:var(--success)]";
|
||||
}
|
||||
|
||||
function scopeLabel(scopeType: string): string {
|
||||
if (scopeType === "company") return "Company";
|
||||
if (scopeType === "project") return "Project";
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
export function BudgetUtilization({ companyId, agents }: BudgetUtilizationProps) {
|
||||
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.agentCompany.getBudgetStatus(companyId).then((result) => {
|
||||
if (result.ok) setBudgetStatus(result.data);
|
||||
});
|
||||
}, [companyId]);
|
||||
|
||||
const rows = useMemo<BudgetRow[]>(() => {
|
||||
// Build rows from budget policies (company/agent/project scopes)
|
||||
const policyRows: BudgetRow[] = (budgetStatus?.policies ?? [])
|
||||
.filter((p) => p.amount > 0)
|
||||
.map((p) => {
|
||||
const agent = p.scopeType === "agent" ? agents.find((a) => a.id === p.scopeId) : undefined;
|
||||
const name = agent ? agent.name : `${scopeLabel(p.scopeType)} budget`;
|
||||
return {
|
||||
id: p.policyId,
|
||||
name,
|
||||
scopeType: p.scopeType,
|
||||
spent: p.spent,
|
||||
budget: p.amount,
|
||||
pct: Math.round((p.spent / p.amount) * 100),
|
||||
};
|
||||
});
|
||||
|
||||
// Fallback: if no policies, use agent-level budgets from AgentRecord
|
||||
if (policyRows.length === 0) {
|
||||
return agents
|
||||
.filter((a) => a.budgetMonthlyUsd > 0)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
scopeType: "agent",
|
||||
spent: a.spentMonthlyUsd,
|
||||
budget: a.budgetMonthlyUsd,
|
||||
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Sort: company first, then project, then agent
|
||||
const order: Record<string, number> = { company: 0, project: 1, agent: 2 };
|
||||
return policyRows.sort(
|
||||
(a, b) => (order[a.scopeType] ?? 3) - (order[b.scopeType] ?? 3) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}, [budgetStatus, agents]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="flex h-[120px] flex-col items-center justify-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
|
||||
No budget policies configured
|
||||
</span>
|
||||
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
|
||||
Set budget policies in Settings to track utilization here.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[13px] font-semibold text-[color:var(--text)]">
|
||||
{row.name}
|
||||
<span className="text-[11px] font-normal text-[color:var(--muted)]">
|
||||
{scopeLabel(row.scopeType)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="text-[13px] text-[color:var(--muted)]"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{row.pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 rounded-full bg-[color:var(--panel-soft)]">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${getBarColor(row.pct)}`}
|
||||
style={{ width: `${Math.min(row.pct, 100)}%` }}
|
||||
/>
|
||||
{/* 80% warning marker */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-px bg-[color:var(--warn)] opacity-50"
|
||||
style={{ left: "80%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChartLine } from "@phosphor-icons/react";
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
period?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyHeading?: string;
|
||||
emptyBody?: string;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
period = "14d",
|
||||
isEmpty = false,
|
||||
emptyHeading = "No activity yet",
|
||||
emptyBody = "Charts will appear once your company starts running tasks.",
|
||||
ariaLabel,
|
||||
children,
|
||||
}: ChartCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
{period}
|
||||
</span>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-2">
|
||||
<ChartLine
|
||||
size={32}
|
||||
className="text-[color:var(--muted)] opacity-50"
|
||||
/>
|
||||
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
|
||||
{emptyHeading}
|
||||
</span>
|
||||
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
|
||||
{emptyBody}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: 220 }}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { TooltipContentProps } from "recharts";
|
||||
|
||||
interface ChartTooltipExtraProps {
|
||||
valuePrefix?: string;
|
||||
valueSuffix?: string;
|
||||
}
|
||||
|
||||
type Props = Partial<TooltipContentProps<number, string>> & ChartTooltipExtraProps;
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valuePrefix = "",
|
||||
valueSuffix = "",
|
||||
}: Props) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const dateStr =
|
||||
typeof label === "string"
|
||||
? new Date(label + "T00:00:00").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: String(label);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-[6px] border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 shadow-md"
|
||||
style={{ maxWidth: 200, zIndex: 50 }}
|
||||
>
|
||||
<div className="text-[11px] text-[color:var(--muted)]">{dateStr}</div>
|
||||
{payload.map((entry, i) => (
|
||||
<div key={i} className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-[13px] font-semibold text-[color:var(--text)]"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{valuePrefix}
|
||||
{typeof entry.value === "number"
|
||||
? entry.value.toLocaleString()
|
||||
: entry.value}
|
||||
{valueSuffix}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyCostData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface CostTrendChartProps {
|
||||
data: DailyCostData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function CostTrendChart({ data, reducedMotion }: CostTrendChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(val: number) => "$" + val.toFixed(0)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valuePrefix="$" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="var(--accent)"
|
||||
fill="var(--accent-soft)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--accent)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyRunData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface RunActivityChartProps {
|
||||
data: DailyRunData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function RunActivityChart({ data, reducedMotion }: RunActivityChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="var(--accent)"
|
||||
fill="var(--accent-soft)"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--accent)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyRunData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface SuccessRateChartProps {
|
||||
data: DailyRunData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function SuccessRateChart({ data, reducedMotion }: SuccessRateChartProps) {
|
||||
const rateData = useMemo(
|
||||
() =>
|
||||
data.map((d) => ({
|
||||
date: d.date,
|
||||
rate: d.total > 0 ? Math.round((d.succeeded / d.total) * 100) : 0,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rateData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(val: number) => `${val}%`}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix="%" />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="var(--success)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--success)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
|
||||
|
||||
export interface DailyRunData {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface DailyCostData {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
total: number; // USD amount (NOT cents)
|
||||
}
|
||||
|
||||
export interface AgentWorkloadData {
|
||||
name: string; // agent name, truncated to 10 chars
|
||||
runs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate runs into daily buckets for chart consumption.
|
||||
* Returns `days` entries sorted ascending by date (oldest first).
|
||||
* Days with no runs have {total:0, succeeded:0, failed:0}.
|
||||
*/
|
||||
export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] {
|
||||
const now = new Date();
|
||||
const buckets = new Map<string, { total: number; succeeded: number; failed: number }>();
|
||||
|
||||
// Initialize all day buckets with zero values
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
buckets.set(key, { total: 0, succeeded: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// Fill buckets from actual data
|
||||
for (const run of runs) {
|
||||
const day = run.createdAt.slice(0, 10);
|
||||
const bucket = buckets.get(day);
|
||||
if (!bucket) continue;
|
||||
bucket.total++;
|
||||
if (run.status === "succeeded") bucket.succeeded++;
|
||||
if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") {
|
||||
bucket.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return sorted ascending by date
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, data]) => ({ date, ...data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate costs into daily buckets for chart consumption.
|
||||
* Returns `days` entries sorted ascending by date.
|
||||
* Days with no costs have {total:0}. Null amountUsd is treated as 0.
|
||||
*/
|
||||
export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] {
|
||||
const now = new Date();
|
||||
const buckets = new Map<string, number>();
|
||||
|
||||
// Initialize all day buckets with zero
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
buckets.set(key, 0);
|
||||
}
|
||||
|
||||
// Sum costs per day
|
||||
for (const cost of costs) {
|
||||
const day = cost.createdAt.slice(0, 10);
|
||||
const current = buckets.get(day);
|
||||
if (current === undefined) continue;
|
||||
buckets.set(day, current + (cost.amountUsd ?? 0));
|
||||
}
|
||||
|
||||
// Return sorted ascending by date
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, total]) => ({ date, total }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group runs by agent and return workload data sorted by run count descending.
|
||||
* If more than maxAgents, keep top maxAgents and aggregate rest into "Others".
|
||||
* Agent names longer than 10 chars are truncated with ellipsis.
|
||||
*/
|
||||
export function aggregateAgentWorkload(
|
||||
runs: RunRecord[],
|
||||
agents: AgentRecord[],
|
||||
maxAgents: number = 8,
|
||||
): AgentWorkloadData[] {
|
||||
// Build name lookup from agents array
|
||||
const nameMap = new Map<string, string>();
|
||||
for (const agent of agents) {
|
||||
nameMap.set(agent.id, agent.name);
|
||||
}
|
||||
|
||||
// Count runs per agent
|
||||
const counts = new Map<string, number>();
|
||||
for (const run of runs) {
|
||||
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
const sorted = Array.from(counts.entries())
|
||||
.sort(([, a], [, b]) => b - a);
|
||||
|
||||
const truncateName = (name: string): string =>
|
||||
name.length > 10 ? name.slice(0, 10) + "..." : name;
|
||||
|
||||
// If within limit, return all
|
||||
if (sorted.length <= maxAgents) {
|
||||
return sorted.map(([agentId, runCount]) => ({
|
||||
name: truncateName(nameMap.get(agentId) ?? agentId),
|
||||
runs: runCount,
|
||||
}));
|
||||
}
|
||||
|
||||
// Split into top agents and "Others"
|
||||
const top = sorted.slice(0, maxAgents);
|
||||
const rest = sorted.slice(maxAgents);
|
||||
const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0);
|
||||
|
||||
const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({
|
||||
name: truncateName(nameMap.get(agentId) ?? agentId),
|
||||
runs: runCount,
|
||||
}));
|
||||
|
||||
result.push({ name: "Others", runs: othersRuns });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15").
|
||||
* Uses T00:00:00 suffix to avoid timezone shift.
|
||||
*/
|
||||
export function formatShortDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export { WorkspaceCreator } from "./execution/WorkspaceCreator"
|
||||
export { SecretCreator } from "./execution/SecretCreator"
|
||||
export { SettingsPane } from "./execution/SettingsPane"
|
||||
export { PendingReviewList } from "./execution/PendingReviewList"
|
||||
export { PolicyList } from "./execution/PolicyList"
|
||||
export { PolicyFormModal } from "./execution/PolicyFormModal"
|
||||
export { IncidentList } from "./execution/IncidentList"
|
||||
@@ -1,11 +1,31 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Warning } from "@phosphor-icons/react"
|
||||
import type { ProfileSnapshot } from "@shared/types"
|
||||
import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types"
|
||||
import { formatMoney } from "../../lib/formatters"
|
||||
import { ListFrame, ListRow } from "../ui"
|
||||
|
||||
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
|
||||
interface BudgetSummaryProps {
|
||||
agents: ProfileSnapshot["agents"]
|
||||
costs: ProfileSnapshot["costs"]
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) {
|
||||
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) return
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.getBudgetStatus(companyId)
|
||||
if (result.ok) setBudgetStatus(result.data)
|
||||
})()
|
||||
}, [companyId])
|
||||
|
||||
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0)
|
||||
const unattributedCount = costs.filter((entry) => entry.unattributed).length
|
||||
|
||||
const hasPolicies = budgetStatus && budgetStatus.policies.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-y border-[color:var(--line)]">
|
||||
@@ -18,30 +38,68 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
|
||||
<div className="mono text-[18px] text-[color:var(--text)]">{String(unattributedCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListFrame>
|
||||
{agents.map((agent) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
|
||||
{hasPolicies ? (
|
||||
<ListFrame>
|
||||
{budgetStatus.policies.map((status) => {
|
||||
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100
|
||||
const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
|
||||
const scopeLabel = status.scopeType === "company"
|
||||
? "Company"
|
||||
: status.scopeType === "agent"
|
||||
? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8))
|
||||
: status.scopeId.slice(0, 8)
|
||||
|
||||
return (
|
||||
<ListRow key={status.policyId}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-[color:var(--panel-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[color:var(--muted-strong)]">
|
||||
{status.scopeType}
|
||||
</span>
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
|
||||
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
) : (
|
||||
<ListFrame>
|
||||
{agents.map((agent) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
||||
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
|
||||
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
|
||||
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
||||
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
||||
</ListRow>
|
||||
)
|
||||
})}
|
||||
</ListFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CheckCircle, Warning } from "@phosphor-icons/react";
|
||||
import type { BudgetIncidentRecord } from "@shared/types";
|
||||
import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui";
|
||||
import { formatTime } from "../../lib/formatters";
|
||||
|
||||
interface IncidentListProps {
|
||||
companyId: string;
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
projects: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
open: "text-[color:var(--danger)] bg-[color:var(--danger-soft)]",
|
||||
resolved: "text-[color:var(--success)] bg-[color:var(--success-soft)]",
|
||||
auto_resolved_limit_increase: "text-[color:var(--accent)] bg-[color:var(--accent-soft)]",
|
||||
auto_closed: "text-[color:var(--muted-strong)] bg-[color:var(--panel-soft)]",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: "Open",
|
||||
resolved: "Resolved",
|
||||
auto_resolved_limit_increase: "Auto-resolved",
|
||||
auto_closed: "Auto-closed",
|
||||
};
|
||||
|
||||
export function IncidentList({ companyId, agents, projects }: IncidentListProps) {
|
||||
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
|
||||
const [filter, setFilter] = useState<"all" | "open">("open");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: filter });
|
||||
if (result.ok) setIncidents(result.data);
|
||||
}, [companyId, filter]);
|
||||
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
|
||||
function getScopeName(scopeType: string, scopeId: string): string {
|
||||
if (scopeType === "company") return "Company";
|
||||
if (scopeType === "agent") {
|
||||
const agent = agents.find((a) => a.id === scopeId);
|
||||
return agent ? agent.name : scopeId.slice(0, 8);
|
||||
}
|
||||
if (scopeType === "project") {
|
||||
const project = projects.find((p) => p.id === scopeId);
|
||||
return project ? project.name : scopeId.slice(0, 8);
|
||||
}
|
||||
return scopeId.slice(0, 8);
|
||||
}
|
||||
|
||||
async function handleResolve(incidentId: string) {
|
||||
const result = await window.agentCompany.resolveBudgetIncident({ companyId, incidentId });
|
||||
if (result.ok) void loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="text-[15px] font-semibold text-[color:var(--text)]">Budget Incidents</h3>
|
||||
<div className="flex items-center gap-1 rounded-[6px] bg-[color:var(--panel-soft)] p-0.5">
|
||||
{(["open", "all"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded-[5px] px-3 py-1 text-[12px] font-medium transition ${
|
||||
filter === f
|
||||
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{f === "open" ? "Open" : "All"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{incidents.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No budget incidents"
|
||||
detail={filter === "open" ? "No open incidents. All spending is within limits." : "No incidents recorded yet."}
|
||||
/>
|
||||
) : (
|
||||
<ListFrame>
|
||||
{incidents.map((incident) => {
|
||||
const isWarn = incident.thresholdType === "warn_threshold";
|
||||
return (
|
||||
<ListRow key={incident.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
|
||||
isWarn
|
||||
? "text-[color:var(--warn)] bg-[color:var(--warn-soft)]"
|
||||
: "text-[color:var(--danger)] bg-[color:var(--danger-soft)]"
|
||||
}`}
|
||||
>
|
||||
{isWarn ? <Warning size={10} weight="bold" /> : null}
|
||||
{isWarn ? "Warning" : "Hard Stop"}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${STATUS_STYLES[incident.status] ?? STATUS_STYLES.open}`}>
|
||||
{STATUS_LABELS[incident.status] ?? incident.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[12px] text-[color:var(--muted-strong)]">
|
||||
<span className="capitalize">{incident.scopeType}</span>
|
||||
<span className="text-[color:var(--muted)]">·</span>
|
||||
<span className="font-medium text-[color:var(--text)]">{getScopeName(incident.scopeType, incident.scopeId)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)}
|
||||
</span>
|
||||
<span>{formatTime(incident.windowStart)} - {formatTime(incident.windowEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{incident.status === "open" ? (
|
||||
<ActionButton
|
||||
label="Resolve"
|
||||
icon={CheckCircle}
|
||||
onClick={() => void handleResolve(incident.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BudgetPolicyRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Modal, Select } from "../ui";
|
||||
|
||||
interface PolicyFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
companyId: string;
|
||||
editingPolicy: BudgetPolicyRecord | null;
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
projects: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPolicy, agents, projects }: PolicyFormModalProps) {
|
||||
const [scopeType, setScopeType] = useState<"company" | "agent" | "project">("company");
|
||||
const [scopeId, setScopeId] = useState(companyId);
|
||||
const [amountDollars, setAmountDollars] = useState("100");
|
||||
const [warnPercent, setWarnPercent] = useState("80");
|
||||
const [hardStopEnabled, setHardStopEnabled] = useState(true);
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPolicy) {
|
||||
setScopeType(editingPolicy.scopeType);
|
||||
setScopeId(editingPolicy.scopeId);
|
||||
setAmountDollars((editingPolicy.amount / 100).toFixed(2));
|
||||
setWarnPercent(String(editingPolicy.warnPercent));
|
||||
setHardStopEnabled(editingPolicy.hardStopEnabled);
|
||||
setNotifyEnabled(editingPolicy.notifyEnabled);
|
||||
} else {
|
||||
setScopeType("company");
|
||||
setScopeId(companyId);
|
||||
setAmountDollars("100");
|
||||
setWarnPercent("80");
|
||||
setHardStopEnabled(true);
|
||||
setNotifyEnabled(true);
|
||||
}
|
||||
}, [editingPolicy, companyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeType === "company") {
|
||||
setScopeId(companyId);
|
||||
} else if (scopeType === "agent" && agents.length > 0) {
|
||||
setScopeId(agents[0].id);
|
||||
} else if (scopeType === "project" && projects.length > 0) {
|
||||
setScopeId(projects[0].id);
|
||||
}
|
||||
}, [scopeType, companyId, agents, projects]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const amountCents = Math.round(parseFloat(amountDollars) * 100);
|
||||
if (Number.isNaN(amountCents) || amountCents <= 0) return;
|
||||
|
||||
const wp = parseInt(warnPercent, 10);
|
||||
if (Number.isNaN(wp) || wp < 1 || wp > 100) return;
|
||||
|
||||
const result = await window.agentCompany.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType,
|
||||
scopeId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: amountCents,
|
||||
warnPercent: wp,
|
||||
hardStopEnabled,
|
||||
notifyEnabled,
|
||||
isActive: true,
|
||||
});
|
||||
if (result.ok) {
|
||||
onSaved();
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={editingPolicy ? "Edit Budget Policy" : "Add Budget Policy"}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<Select
|
||||
label="Scope"
|
||||
value={scopeType}
|
||||
onChange={(v) => setScopeType(v as "company" | "agent" | "project")}
|
||||
options={[
|
||||
{ value: "company", label: "Company" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "project", label: "Project" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{scopeType === "agent" ? (
|
||||
<Select
|
||||
label="Agent"
|
||||
value={scopeId}
|
||||
onChange={setScopeId}
|
||||
options={agents.map((a) => ({ value: a.id, label: a.name }))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{scopeType === "project" ? (
|
||||
<Select
|
||||
label="Project"
|
||||
value={scopeId}
|
||||
onChange={setScopeId}
|
||||
options={projects.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Monthly Budget (USD)"
|
||||
value={amountDollars}
|
||||
onChange={setAmountDollars}
|
||||
type="number"
|
||||
placeholder="100.00"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Warning Threshold (%)"
|
||||
value={warnPercent}
|
||||
onChange={setWarnPercent}
|
||||
type="number"
|
||||
placeholder="80"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hardStopEnabled}
|
||||
onChange={(e) => setHardStopEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||
/>
|
||||
<span className="text-[13px] text-[color:var(--text)]">Enable hard stop (block new runs at 100%)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyEnabled}
|
||||
onChange={(e) => setNotifyEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||
/>
|
||||
<span className="text-[13px] text-[color:var(--text)]">Send notifications</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[8px] px-4 py-2 text-[13px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<ActionButton
|
||||
label={saving ? "Saving..." : "Save Policy"}
|
||||
tone="accent"
|
||||
disabled={saving}
|
||||
onClick={() => void handleSave()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, ShieldCheck, Trash, Warning } from "@phosphor-icons/react";
|
||||
import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types";
|
||||
import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui";
|
||||
import { PolicyFormModal } from "./PolicyFormModal";
|
||||
|
||||
interface PolicyListProps {
|
||||
companyId: string;
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
projects: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
|
||||
const [policies, setPolicies] = useState<BudgetPolicyRecord[]>([]);
|
||||
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<BudgetPolicyRecord | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [polRes, statusRes] = await Promise.all([
|
||||
window.agentCompany.listBudgetPolicies(companyId),
|
||||
window.agentCompany.getBudgetStatus(companyId),
|
||||
]);
|
||||
if (polRes.ok) setPolicies(polRes.data);
|
||||
if (statusRes.ok) setBudgetStatus(statusRes.data);
|
||||
}, [companyId]);
|
||||
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
|
||||
function getScopeName(policy: BudgetPolicyRecord): string {
|
||||
if (policy.scopeType === "company") return "Company";
|
||||
if (policy.scopeType === "agent") {
|
||||
const agent = agents.find((a) => a.id === policy.scopeId);
|
||||
return agent ? agent.name : policy.scopeId.slice(0, 8);
|
||||
}
|
||||
if (policy.scopeType === "project") {
|
||||
const project = projects.find((p) => p.id === policy.scopeId);
|
||||
return project ? project.name : policy.scopeId.slice(0, 8);
|
||||
}
|
||||
return policy.scopeId.slice(0, 8);
|
||||
}
|
||||
|
||||
function getUtilization(policyId: string): { spent: number; amount: number; pct: number; hasIncident: boolean } | null {
|
||||
if (!budgetStatus) return null;
|
||||
const entry = budgetStatus.policies.find((p) => p.policyId === policyId);
|
||||
if (!entry) return null;
|
||||
const pct = entry.amount > 0 ? (entry.spent / entry.amount) * 100 : 0;
|
||||
return { spent: entry.spent, amount: entry.amount, pct, hasIncident: entry.hasOpenIncident };
|
||||
}
|
||||
|
||||
async function handleDelete(policyId: string) {
|
||||
const result = await window.agentCompany.deleteBudgetPolicy({ companyId, policyId });
|
||||
if (result.ok) void loadData();
|
||||
}
|
||||
|
||||
function handleEdit(policy: BudgetPolicyRecord) {
|
||||
setEditingPolicy(policy);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
setEditingPolicy(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function handleSaved() {
|
||||
void loadData();
|
||||
}
|
||||
|
||||
if (policies.length === 0 && !showForm) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="No budget policies"
|
||||
detail="Add your first policy to track and enforce spending limits."
|
||||
action={<ActionButton label="Add Policy" icon={Plus} onClick={handleAdd} />}
|
||||
/>
|
||||
<PolicyFormModal
|
||||
open={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={handleSaved}
|
||||
companyId={companyId}
|
||||
editingPolicy={editingPolicy}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="text-[15px] font-semibold text-[color:var(--text)]">Budget Policies</h3>
|
||||
<ActionButton label="Add Policy" icon={Plus} onClick={handleAdd} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<ListFrame>
|
||||
{policies.map((policy) => {
|
||||
const util = getUtilization(policy.id);
|
||||
const pct = util?.pct ?? 0;
|
||||
const barColor = pct > 100 ? "bg-[color:var(--danger)]" : pct > 80 ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]";
|
||||
return (
|
||||
<ListRow key={policy.id} onClick={() => handleEdit(policy)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-[color:var(--panel-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[color:var(--muted-strong)]">
|
||||
{policy.scopeType}
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{getScopeName(policy)}</span>
|
||||
{policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-[color:var(--accent)]" /> : null}
|
||||
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-[color:var(--warn)]" /> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
${(policy.amount / 100).toFixed(2)}/mo
|
||||
</span>
|
||||
<span>Warn at {policy.warnPercent}%</span>
|
||||
</div>
|
||||
{util ? (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-[color:var(--line)]">
|
||||
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-[family-name:var(--font-mono)] text-[color:var(--muted)]" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[color:var(--muted)] transition hover:text-[color:var(--danger)]"
|
||||
title="Delete policy"
|
||||
aria-label="Delete policy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDelete(policy.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
</div>
|
||||
<PolicyFormModal
|
||||
open={showForm}
|
||||
onClose={() => { setShowForm(false); setEditingPolicy(null); }}
|
||||
onSaved={handleSaved}
|
||||
companyId={companyId}
|
||||
editingPolicy={editingPolicy}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { SkillAgentToggles } from "../skills/SkillAgentToggles";
|
||||
import { ConfirmDelete, HeartbeatSection } from "./helpers";
|
||||
|
||||
export function AgentEditor({
|
||||
@@ -74,6 +76,34 @@ export function AgentEditor({
|
||||
}
|
||||
}, [connectorId, agent.connectorId]);
|
||||
|
||||
const skills = useAppStore((s) => s.skills);
|
||||
const setSkills = useAppStore((s) => s.setSkills);
|
||||
|
||||
useEffect(() => {
|
||||
if (skills.length === 0 && companyId) {
|
||||
window.agentCompany.listSkills(companyId).then((result) => {
|
||||
if (result.ok) setSkills(result.data);
|
||||
});
|
||||
}
|
||||
}, [companyId, skills.length, setSkills]);
|
||||
|
||||
// Load agent's budget policy (D-11: read from budget_policies)
|
||||
const [agentPolicy, setAgentPolicy] = useState<BudgetPolicyRecord | null>(null);
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetPolicies(companyId);
|
||||
if (result.ok) {
|
||||
const policy = result.data.find(
|
||||
(p) => p.scopeType === "agent" && p.scopeId === agent.id
|
||||
);
|
||||
if (policy) {
|
||||
setAgentPolicy(policy);
|
||||
setBudget(String((policy.amount / 100).toFixed(2)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [companyId, agent.id]);
|
||||
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
|
||||
return (
|
||||
@@ -162,6 +192,12 @@ export function AgentEditor({
|
||||
|
||||
<HeartbeatSection agent={agent} companyId={companyId} pendingApproval={pendingApproval} persistedConnectorIssue={persistedConnectorIssue} onSaved={onSaved} />
|
||||
|
||||
{/* Skills section (D-04 dual access) */}
|
||||
<div className="mt-6 border-t border-[color:var(--line)] pt-4">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-[color:var(--accent-text)] mb-3">Skills</h3>
|
||||
<SkillAgentToggles mode="agent" companyId={companyId} agentId={agent.id} skills={skills} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionButton
|
||||
label="Save changes"
|
||||
@@ -175,6 +211,7 @@ export function AgentEditor({
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
try {
|
||||
const budgetDollars = Number(budget || 0);
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
@@ -187,9 +224,24 @@ export function AgentEditor({
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities,
|
||||
}));
|
||||
// D-11: Also write to budget_policies for policy-based enforcement
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
await window.agentCompany.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: Math.round(budgetDollars * 100),
|
||||
warnPercent: agentPolicy?.warnPercent ?? 80,
|
||||
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
|
||||
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AgentRecord, SkillRecord } from "@shared/types";
|
||||
import { useAppStore } from "../../state/store";
|
||||
|
||||
type SkillAgentTogglesProps =
|
||||
| { mode: "skill"; companyId: string; skillId: string; agents: AgentRecord[] }
|
||||
| { mode: "agent"; companyId: string; agentId: string; skills: SkillRecord[] };
|
||||
|
||||
export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||
const { companyId, mode } = props;
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
if (mode === "skill") {
|
||||
const result = await window.agentCompany.listSkillAgents(companyId, props.skillId);
|
||||
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||
} else {
|
||||
const result = await window.agentCompany.listAgentSkills(companyId, props.agentId);
|
||||
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [mode, companyId, mode === "skill" ? props.skillId : props.agentId]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (targetId: string) => {
|
||||
setTogglingId(targetId);
|
||||
const isAssigned = assignedIds.has(targetId);
|
||||
const agentId = mode === "skill" ? targetId : props.agentId;
|
||||
const skillId = mode === "skill" ? props.skillId : targetId;
|
||||
|
||||
const result = isAssigned
|
||||
? await window.agentCompany.removeSkillFromAgent(companyId, agentId, skillId)
|
||||
: await window.agentCompany.assignSkillToAgent(companyId, agentId, skillId);
|
||||
|
||||
if (result.ok) {
|
||||
setAssignedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isAssigned) next.delete(targetId);
|
||||
else next.add(targetId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
setTogglingId(null);
|
||||
},
|
||||
[assignedIds, mode, companyId, addToast,
|
||||
mode === "skill" ? props.skillId : "",
|
||||
mode === "agent" ? props.agentId : ""],
|
||||
);
|
||||
|
||||
const items: Array<{ id: string; name: string; detail?: string }> =
|
||||
mode === "skill"
|
||||
? props.agents.map((a) => ({ id: a.id, name: a.name, detail: a.role }))
|
||||
: props.skills.map((s) => ({ id: s.id, name: s.name, detail: s.description ?? undefined }));
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="px-3 py-4 text-center text-[13px] text-[color:var(--muted)]">
|
||||
{mode === "skill" ? "No agents in this company" : "No skills imported yet"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const isAssigned = assignedIds.has(item.id);
|
||||
const isToggling = togglingId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={isToggling}
|
||||
className="flex w-full items-center gap-3 border-b border-[color:var(--line)] px-3 py-2 text-left transition hover:bg-[color:var(--panel-soft)] disabled:opacity-60"
|
||||
onClick={() => void handleToggle(item.id)}
|
||||
>
|
||||
{/* Toggle indicator */}
|
||||
<span
|
||||
className={`flex h-[18px] w-[32px] shrink-0 items-center rounded-full p-[2px] transition-colors ${
|
||||
isAssigned
|
||||
? "bg-[color:var(--accent)]"
|
||||
: "bg-[color:var(--line)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${
|
||||
isAssigned ? "translate-x-[14px]" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.detail ? (
|
||||
<div className="truncate text-[12px] text-[color:var(--muted)]">
|
||||
{item.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowsClockwise,
|
||||
FolderSimple,
|
||||
GithubLogo,
|
||||
Globe,
|
||||
Link,
|
||||
PuzzlePiece,
|
||||
Trash,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, SkillRecord, SkillUpdateStatus } from "@shared/types";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { ActionButton, StatusPill } from "../ui";
|
||||
import { SkillAgentToggles } from "./SkillAgentToggles";
|
||||
import { buildTree, SkillFileTree } from "./SkillFileTree";
|
||||
|
||||
const sourceIconMap: Record<string, typeof GithubLogo> = {
|
||||
github: GithubLogo,
|
||||
local_path: FolderSimple,
|
||||
url: Link,
|
||||
skills_sh: Globe,
|
||||
managed: PuzzlePiece,
|
||||
};
|
||||
|
||||
const sourceLabel: Record<string, string> = {
|
||||
github: "GitHub",
|
||||
local_path: "Local Path",
|
||||
url: "URL",
|
||||
skills_sh: "skills.sh",
|
||||
managed: "Managed",
|
||||
};
|
||||
|
||||
const trustToneMap: Record<string, string> = {
|
||||
markdown_only: "ready",
|
||||
assets: "pending",
|
||||
scripts_executables: "error",
|
||||
};
|
||||
|
||||
const trustLabelMap: Record<string, string> = {
|
||||
markdown_only: "Markdown only",
|
||||
assets: "Has assets",
|
||||
scripts_executables: "Contains scripts",
|
||||
};
|
||||
|
||||
function stripFrontmatter(markdown: string): string {
|
||||
const normalized = markdown.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) return normalized.trim();
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) return normalized.trim();
|
||||
return normalized.slice(closing + 5).trim();
|
||||
}
|
||||
|
||||
function shortRef(ref: string | null | undefined): string {
|
||||
if (!ref) return "untracked";
|
||||
return ref.length > 7 ? ref.slice(0, 7) : ref;
|
||||
}
|
||||
|
||||
export function SkillDetail({
|
||||
companyId,
|
||||
skillId,
|
||||
agents,
|
||||
onDeleted,
|
||||
onRefresh,
|
||||
}: {
|
||||
companyId: string;
|
||||
skillId: string;
|
||||
agents: AgentRecord[];
|
||||
onDeleted: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const selectedSkillFilePath = useAppStore((s) => s.selectedSkillFilePath);
|
||||
const setSelectedSkillFilePath = useAppStore((s) => s.setSelectedSkillFilePath);
|
||||
|
||||
const [detail, setDetail] = useState<SkillRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updateStatus, setUpdateStatus] = useState<SkillUpdateStatus | null>(null);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setDetail(null);
|
||||
setUpdateStatus(null);
|
||||
setSelectedSkillFilePath("SKILL.md");
|
||||
setExpandedDirs(new Set());
|
||||
|
||||
async function load() {
|
||||
const result = await window.agentCompany.getSkillDetail(companyId, skillId);
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
if (result.ok) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [companyId, skillId, addToast, setSelectedSkillFilePath]);
|
||||
|
||||
const handleCheckUpdate = useCallback(async () => {
|
||||
if (!detail) return;
|
||||
setCheckingUpdate(true);
|
||||
const result = await window.agentCompany.checkSkillUpdate(companyId, skillId);
|
||||
setCheckingUpdate(false);
|
||||
if (result.ok) {
|
||||
setUpdateStatus(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
}, [companyId, skillId, detail, addToast]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const result = await window.agentCompany.deleteSkill(companyId, skillId);
|
||||
if (result.ok) {
|
||||
addToast("Skill deleted", "success");
|
||||
onDeleted();
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
}, [companyId, skillId, addToast, onDeleted]);
|
||||
|
||||
const handleToggleDir = useCallback((path: string) => {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
||||
<div className="text-[13px]">Loading skill...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
||||
<div className="text-[13px]">Skill not found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SourceIcon = sourceIconMap[detail.sourceType] ?? PuzzlePiece;
|
||||
const treeNodes = buildTree(detail.fileInventory ?? []);
|
||||
const isGitHubSource = detail.sourceType === "github" || detail.sourceType === "skills_sh";
|
||||
|
||||
// Determine content for the file viewer
|
||||
const isSkillMd = selectedSkillFilePath === "SKILL.md";
|
||||
const fileContent = isSkillMd
|
||||
? stripFrontmatter(detail.markdown ?? "")
|
||||
: null;
|
||||
|
||||
const selectedFileEntry = !isSkillMd
|
||||
? (detail.fileInventory ?? []).find((f) => f.path === selectedSkillFilePath)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{/* Header section */}
|
||||
<div className="border-b border-[color:var(--line)] px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<SourceIcon size={20} className="mt-1 shrink-0 text-[color:var(--muted)]" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[20px] font-semibold leading-tight text-[color:var(--text)]">
|
||||
{detail.name}
|
||||
</h1>
|
||||
{detail.description ? (
|
||||
<p className="mt-1 max-w-3xl text-[13px] text-[color:var(--muted)]">
|
||||
{detail.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="space-y-3 border-b border-[color:var(--line)] px-5 pt-4 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px]">
|
||||
{/* Source */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Source</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SourceIcon size={14} className="text-[color:var(--muted)]" />
|
||||
<span className="text-[color:var(--text)]">{sourceLabel[detail.sourceType] ?? detail.sourceType}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trust level */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Trust</span>
|
||||
<StatusPill status={trustToneMap[detail.trustLevel] ?? "default"} />
|
||||
<span className="text-[color:var(--text)]">{trustLabelMap[detail.trustLevel] ?? detail.trustLevel}</span>
|
||||
</div>
|
||||
|
||||
{/* Compatibility */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Compat</span>
|
||||
<span className="text-[color:var(--text)]">{detail.compatibility || "all"}</span>
|
||||
</div>
|
||||
|
||||
{/* Ref (for GitHub sources) */}
|
||||
{isGitHubSource ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Ref</span>
|
||||
<span className="font-mono text-[12px] text-[color:var(--text)]">{shortRef(detail.sourceRef)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Update check row */}
|
||||
{isGitHubSource ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<ActionButton
|
||||
label="Check for updates"
|
||||
icon={ArrowsClockwise}
|
||||
loading={checkingUpdate}
|
||||
onClick={() => void handleCheckUpdate()}
|
||||
/>
|
||||
{updateStatus?.hasUpdate ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[color:var(--accent-soft)] px-2.5 py-0.5 text-[12px] font-medium text-[color:var(--accent-text)]">
|
||||
Update available: {shortRef(updateStatus.currentRef)} → {shortRef(updateStatus.latestRef)}
|
||||
</span>
|
||||
) : updateStatus && !updateStatus.hasUpdate ? (
|
||||
<span className="text-[12px] text-[color:var(--muted)]">Up to date</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* File viewer section */}
|
||||
<div className="min-h-[560px]">
|
||||
{/* File tree sidebar + content */}
|
||||
<div className="grid grid-cols-[12rem_minmax(0,1fr)]">
|
||||
{/* File tree sidebar */}
|
||||
<div className="border-r border-[color:var(--line)] py-2">
|
||||
{treeNodes.length > 0 ? (
|
||||
<SkillFileTree
|
||||
nodes={treeNodes}
|
||||
selectedPath={selectedSkillFilePath}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={handleToggleDir}
|
||||
onSelectPath={setSelectedSkillFilePath}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-3 py-2 text-[12px] text-[color:var(--muted)]">No files</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="p-4">
|
||||
{/* File path bar */}
|
||||
<div className="mb-3 flex items-center gap-1 text-[12px] text-[color:var(--muted)]">
|
||||
{selectedSkillFilePath.split("/").map((segment, i, arr) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 ? <span className="opacity-40">/</span> : null}
|
||||
<span className={i === arr.length - 1 ? "font-medium text-[color:var(--text)]" : ""}>
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isSkillMd ? (
|
||||
<pre className="rounded-lg bg-[color:var(--panel)] p-4 font-mono text-[13px] text-[color:var(--text)] overflow-auto whitespace-pre-wrap">
|
||||
{fileContent}
|
||||
</pre>
|
||||
) : selectedFileEntry ? (
|
||||
<div className="rounded-lg bg-[color:var(--panel)] p-4 text-[13px] text-[color:var(--muted)]">
|
||||
<p className="font-medium text-[color:var(--text)]">{selectedFileEntry.path}</p>
|
||||
<p className="mt-1">Kind: {selectedFileEntry.kind}</p>
|
||||
<p className="mt-1 text-[12px] italic">File content preview is not available for non-SKILL.md files.</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-[color:var(--muted)]">Select a file to view its details.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent assignments section */}
|
||||
<div className="border-t border-[color:var(--line)] px-5 py-4">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--accent-text)] mb-3">
|
||||
Agent Assignments
|
||||
</h3>
|
||||
<SkillAgentToggles mode="skill" companyId={companyId} skillId={skillId} agents={agents} />
|
||||
</div>
|
||||
|
||||
{/* Delete section */}
|
||||
<div className="border-t border-[color:var(--line)] px-5 py-4">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-[13px] text-[color:var(--danger)]">
|
||||
Delete “{detail.name}”? This cannot be undone.
|
||||
</p>
|
||||
<ActionButton
|
||||
label="Confirm Delete"
|
||||
tone="danger"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Cancel"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Delete Skill"
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FileCode,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
FolderSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { FileInventoryEntry } from "@shared/types";
|
||||
|
||||
export type SkillTreeNode = {
|
||||
name: string;
|
||||
path: string | null;
|
||||
kind: "dir" | "file";
|
||||
fileKind?: FileInventoryEntry["kind"];
|
||||
children: SkillTreeNode[];
|
||||
};
|
||||
|
||||
const BASE_INDENT = 16;
|
||||
const STEP_INDENT = 24;
|
||||
|
||||
export function buildTree(entries: FileInventoryEntry[]): SkillTreeNode[] {
|
||||
const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] };
|
||||
|
||||
for (const entry of entries) {
|
||||
const segments = entry.path.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const isLeaf = index === segments.length - 1;
|
||||
let next = current.children.find((child) => child.name === segment);
|
||||
if (!next) {
|
||||
next = {
|
||||
name: segment,
|
||||
path: isLeaf ? entry.path : currentPath,
|
||||
kind: isLeaf ? "file" : "dir",
|
||||
fileKind: isLeaf ? entry.kind : undefined,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNode(node: SkillTreeNode) {
|
||||
node.children.sort((left, right) => {
|
||||
if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1;
|
||||
if (left.name === "SKILL.md") return -1;
|
||||
if (right.name === "SKILL.md") return 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
}
|
||||
|
||||
sortNode(root);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
function fileIcon(kind: FileInventoryEntry["kind"] | undefined) {
|
||||
if (kind === "script" || kind === "reference") return FileCode;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function SkillFileTree({
|
||||
nodes,
|
||||
selectedPath,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
onSelectPath,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: SkillTreeNode[];
|
||||
selectedPath: string;
|
||||
expandedDirs: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectPath: (path: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => {
|
||||
const expanded = node.kind === "dir" && node.path ? expandedDirs.has(node.path) : false;
|
||||
|
||||
if (node.kind === "dir") {
|
||||
const FolderIcon = expanded ? FolderOpen : FolderSimple;
|
||||
const ChevronIcon = expanded ? CaretDown : CaretRight;
|
||||
return (
|
||||
<div key={node.path ?? node.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-9 w-full items-center gap-2 text-left text-[13px] text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
style={{ paddingLeft: `${BASE_INDENT + depth * STEP_INDENT}px` }}
|
||||
onClick={() => node.path && onToggleDir(node.path)}
|
||||
>
|
||||
<FolderIcon size={14} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{node.name}</span>
|
||||
<ChevronIcon size={12} className="ml-auto mr-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
{expanded && (
|
||||
<SkillFileTree
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectPath={onSelectPath}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FileIcon = fileIcon(node.fileKind);
|
||||
const isSelected = node.path === selectedPath;
|
||||
return (
|
||||
<button
|
||||
key={node.path ?? node.name}
|
||||
type="button"
|
||||
className={`flex min-h-9 w-full items-center gap-2 text-left text-[13px] hover:bg-[color:var(--panel-soft)] ${
|
||||
isSelected
|
||||
? "text-[color:var(--text)] font-medium"
|
||||
: "text-[color:var(--muted)]"
|
||||
}`}
|
||||
style={{ paddingLeft: `${BASE_INDENT + depth * STEP_INDENT}px` }}
|
||||
onClick={() => node.path && onSelectPath(node.path)}
|
||||
>
|
||||
<FileIcon size={14} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{node.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
FolderSimple,
|
||||
GithubLogo,
|
||||
Globe,
|
||||
Link,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Modal, ActionButton } from "../ui";
|
||||
import { useAppStore } from "../../state/store";
|
||||
|
||||
type TabId = "github" | "local" | "url" | "skills_sh";
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: typeof GithubLogo }[] = [
|
||||
{ id: "github", label: "GitHub", icon: GithubLogo },
|
||||
{ id: "local", label: "Local Path", icon: FolderSimple },
|
||||
{ id: "url", label: "HTTP URL", icon: Link },
|
||||
{ id: "skills_sh", label: "skills.sh", icon: Globe },
|
||||
];
|
||||
|
||||
function isValidGithubSource(value: string): boolean {
|
||||
return /^(https:\/\/github\.com\/[\w.-]+\/[\w.-]+|[\w.-]+\/[\w.-]+)/.test(value.trim());
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function SkillImportModal({
|
||||
open,
|
||||
onClose,
|
||||
companyId,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
companyId: string;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("github");
|
||||
const [source, setSource] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setSource("");
|
||||
setActiveTab("github");
|
||||
setIsImporting(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}, [onClose, resetForm]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const result = await window.agentCompany.pickDirectory();
|
||||
if (result.ok && result.data) {
|
||||
setSource(result.data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canImport = (() => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return false;
|
||||
switch (activeTab) {
|
||||
case "github":
|
||||
return isValidGithubSource(trimmed);
|
||||
case "url":
|
||||
return isValidUrl(trimmed);
|
||||
case "local":
|
||||
return trimmed.length > 0;
|
||||
case "skills_sh":
|
||||
return trimmed.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await window.agentCompany.importSkill(companyId, trimmed);
|
||||
if (result.ok) {
|
||||
const count = result.data.length;
|
||||
addToast(`Imported ${count} skill${count !== 1 ? "s" : ""} successfully`);
|
||||
onImported();
|
||||
handleClose();
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Import failed", "danger");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [source, companyId, addToast, onImported, handleClose]);
|
||||
|
||||
const placeholder: Record<TabId, string> = {
|
||||
github: "https://github.com/owner/repo or owner/repo",
|
||||
local: "Enter or browse local path",
|
||||
url: "https://example.com/path/to/skill",
|
||||
skills_sh: "skills.sh install command or URL",
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Import Skill" onClose={handleClose} width="560px">
|
||||
<div className="space-y-5">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-[color:var(--line)]">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-medium transition-colors ${
|
||||
isActive
|
||||
? "border-b-2 border-[color:var(--accent)] text-[color:var(--text)]"
|
||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setSource("");
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="space-y-3">
|
||||
{activeTab === "local" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder={placeholder.local}
|
||||
/>
|
||||
<ActionButton label="Browse" onClick={() => void handleBrowse()} />
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder={placeholder[activeTab]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "skills_sh" && (
|
||||
<p className="text-[12px] text-[color:var(--muted)]">
|
||||
Enter a skills.sh install command (e.g., <code className="rounded bg-[color:var(--panel-soft)] px-1 py-0.5 font-mono text-[11px]">npx skills.sh install skill-name</code>) or a direct skills.sh URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<ActionButton label="Cancel" onClick={handleClose} />
|
||||
<ActionButton
|
||||
label="Import"
|
||||
tone="accent"
|
||||
onClick={() => void handleImport()}
|
||||
loading={isImporting}
|
||||
disabled={!canImport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FolderSimple,
|
||||
GithubLogo,
|
||||
Globe,
|
||||
Link,
|
||||
PuzzlePiece,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { SkillRecord } from "@shared/types";
|
||||
import { SkillFileTree, buildTree } from "./SkillFileTree";
|
||||
|
||||
const sourceIconMap: Record<string, typeof GithubLogo> = {
|
||||
github: GithubLogo,
|
||||
local_path: FolderSimple,
|
||||
url: Link,
|
||||
skills_sh: Globe,
|
||||
managed: PuzzlePiece,
|
||||
};
|
||||
|
||||
export function SkillList({
|
||||
skills,
|
||||
selectedSkillId,
|
||||
skillFilter,
|
||||
onSelectSkill,
|
||||
}: {
|
||||
skills: SkillRecord[];
|
||||
selectedSkillId: string | null;
|
||||
skillFilter: string;
|
||||
onSelectSkill: (id: string) => void;
|
||||
}) {
|
||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Record<string, Set<string>>>({});
|
||||
const [selectedPaths, setSelectedPaths] = useState<Record<string, string>>({});
|
||||
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const haystack = `${skill.name} ${skill.key} ${skill.slug}`.toLowerCase();
|
||||
return haystack.includes(skillFilter.toLowerCase());
|
||||
});
|
||||
|
||||
const handleToggleSkill = useCallback((skillId: string) => {
|
||||
setExpandedSkillId((prev) => (prev === skillId ? null : skillId));
|
||||
}, []);
|
||||
|
||||
const handleToggleDir = useCallback((skillId: string, path: string) => {
|
||||
setExpandedDirs((prev) => {
|
||||
const current = prev[skillId] ?? new Set<string>();
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return { ...prev, [skillId]: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectPath = useCallback((skillId: string, path: string) => {
|
||||
setSelectedPaths((prev) => ({ ...prev, [skillId]: path }));
|
||||
}, []);
|
||||
|
||||
if (filteredSkills.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-[13px] text-[color:var(--muted)]">
|
||||
No skills match this filter.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredSkills.map((skill) => {
|
||||
const isExpanded = expandedSkillId === skill.id;
|
||||
const isSelected = skill.id === selectedSkillId;
|
||||
const tree = buildTree(skill.fileInventory);
|
||||
const SourceIcon = sourceIconMap[skill.sourceType] ?? PuzzlePiece;
|
||||
const dirState = expandedDirs[skill.id] ?? new Set<string>();
|
||||
const filePath = selectedPaths[skill.id] ?? "SKILL.md";
|
||||
|
||||
return (
|
||||
<div key={skill.id} className="border-b border-[color:var(--line)]">
|
||||
<div
|
||||
className={`group flex items-center gap-2 px-3 py-1.5 hover:bg-[color:var(--panel-soft)] ${
|
||||
isSelected ? "text-[color:var(--text)]" : "text-[color:var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
<SourceIcon
|
||||
size={14}
|
||||
className="shrink-0 opacity-75 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onSelectSkill(skill.id)}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-5 [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
|
||||
{skill.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[4px] text-[color:var(--muted)] opacity-60 transition-opacity hover:bg-[color:var(--panel-soft)] hover:opacity-100"
|
||||
onClick={() => handleToggleSkill(skill.id)}
|
||||
aria-label={isExpanded ? "Collapse file tree" : "Expand file tree"}
|
||||
>
|
||||
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden transition-[grid-template-rows] duration-200"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: isExpanded ? "1fr" : "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
{tree.length > 0 && (
|
||||
<SkillFileTree
|
||||
nodes={tree}
|
||||
selectedPath={filePath}
|
||||
expandedDirs={dirState}
|
||||
onToggleDir={(path) => handleToggleDir(skill.id, path)}
|
||||
onSelectPath={(path) => handleSelectPath(skill.id, path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
Plus,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { EmptyState } from "../ui";
|
||||
import { SkillList } from "./SkillList";
|
||||
import { SkillImportModal } from "./SkillImportModal";
|
||||
import { SkillDetail } from "./SkillDetail";
|
||||
|
||||
export function SkillsSection({
|
||||
companyId,
|
||||
agents,
|
||||
onRefresh: _onRefresh,
|
||||
}: {
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const skills = useAppStore((s) => s.skills);
|
||||
const setSkills = useAppStore((s) => s.setSkills);
|
||||
const selectedSkillId = useAppStore((s) => s.selectedSkillId);
|
||||
const setSelectedSkillId = useAppStore((s) => s.setSelectedSkillId);
|
||||
const skillImportModalOpen = useAppStore((s) => s.skillImportModalOpen);
|
||||
const setSkillImportModalOpen = useAppStore((s) => s.setSkillImportModalOpen);
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.agentCompany.listSkills(companyId);
|
||||
if (result.ok) {
|
||||
setSkills(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Failed to load skills", "danger");
|
||||
}
|
||||
}, [companyId, setSkills, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const handleImported = useCallback(() => {
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const handleSelectSkill = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedSkillId(id);
|
||||
},
|
||||
[setSelectedSkillId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
{/* Left column -- skill list sidebar */}
|
||||
<aside className="flex flex-col overflow-y-auto border-r border-[color:var(--line)]">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 py-3">
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-[color:var(--text)]">
|
||||
Skills
|
||||
</h2>
|
||||
{skills.length > 0 && (
|
||||
<span className="inline-flex h-[20px] min-w-[20px] items-center justify-center rounded-full bg-[color:var(--panel-soft)] px-1.5 text-[11px] font-semibold text-[color:var(--muted-strong)]">
|
||||
{skills.length}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring ml-auto flex h-7 w-7 items-center justify-center rounded-[6px] text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
onClick={() => setSkillImportModalOpen(true)}
|
||||
aria-label="Import skill"
|
||||
>
|
||||
<Plus size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
<div className="shrink-0 px-3 pb-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[color:var(--muted)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] py-2 pl-8 pr-3 text-[13px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={skillFilter}
|
||||
onChange={(e) => setSkillFilter(e.target.value)}
|
||||
placeholder="Filter skills..."
|
||||
aria-label="Filter skills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No skills imported"
|
||||
detail="Import skills from GitHub, a local path, or skills.sh to get started."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-2 rounded-[8px] border border-transparent bg-[color:var(--accent)] px-4 py-2.5 text-[12px] font-bold text-[color:var(--text-on-accent)] shadow-[0_1px_3px_rgba(74,93,122,0.35)] transition hover:bg-[color:var(--accent-hover)]"
|
||||
onClick={() => setSkillImportModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} weight="bold" />
|
||||
Import Skill
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SkillList
|
||||
skills={skills}
|
||||
selectedSkillId={selectedSkillId}
|
||||
skillFilter={skillFilter}
|
||||
onSelectSkill={handleSelectSkill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right column -- skill detail */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{selectedSkillId ? (
|
||||
<SkillDetail
|
||||
companyId={companyId}
|
||||
skillId={selectedSkillId}
|
||||
agents={agents}
|
||||
onDeleted={() => { setSelectedSkillId(null); void loadSkills(); }}
|
||||
onRefresh={() => void loadSkills()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState
|
||||
title="Select a skill to view details"
|
||||
detail="Choose a skill from the list to see its content, file tree, and configuration."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import modal */}
|
||||
<SkillImportModal
|
||||
open={skillImportModalOpen}
|
||||
onClose={() => setSkillImportModalOpen(false)}
|
||||
companyId={companyId}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SkillsSection } from "./SkillsSection";
|
||||
export { SkillAgentToggles } from "./SkillAgentToggles";
|
||||
@@ -22,6 +22,7 @@ export const en: Record<string, string> = {
|
||||
"nav.costs": "Costs",
|
||||
"nav.activity": "Activity",
|
||||
"nav.social": "Social Media",
|
||||
"nav.skills": "Skills",
|
||||
"nav.connectors": "Connectors",
|
||||
"nav.workspaces": "Workspaces",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zh: Record<string, string> = {
|
||||
"nav.costs": "成本",
|
||||
"nav.activity": "活动",
|
||||
"nav.social": "社交媒体",
|
||||
"nav.skills": "技能",
|
||||
"nav.connectors": "连接器",
|
||||
"nav.workspaces": "工作区",
|
||||
"nav.settings": "设置",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
// --- Legacy simple batcher -- kept for backward compatibility ---
|
||||
|
||||
type BatchCallback = () => void;
|
||||
|
||||
export function createEventBatcher(callback: BatchCallback, windowMs = 50) {
|
||||
@@ -28,3 +32,94 @@ export function createEventBatcher(callback: BatchCallback, windowMs = 50) {
|
||||
|
||||
return { schedule, flush, dispose };
|
||||
}
|
||||
|
||||
// --- Enhanced batcher for granular events ---
|
||||
|
||||
export interface GranularEventEntry {
|
||||
type: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
const MAX_GRANULAR_EVENTS = 10;
|
||||
|
||||
export function entityKeyFromEvent(event: DesktopEvent): GranularEventEntry | null {
|
||||
switch (event.type) {
|
||||
case "task-updated":
|
||||
return { type: "task-updated", entityId: event.taskId, action: event.action };
|
||||
case "run-updated":
|
||||
return { type: "run-updated", entityId: event.runId, action: event.action };
|
||||
case "approval-updated":
|
||||
return { type: "approval-updated", entityId: event.approvalId, action: event.action };
|
||||
case "agent-updated":
|
||||
return { type: "agent-updated", entityId: event.agentId, action: event.action };
|
||||
case "budget-changed":
|
||||
return { type: "budget-changed", entityId: event.companyId, action: "changed" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDomainEventBatcher(
|
||||
onFullRefresh: () => void,
|
||||
onGranularBatch: (events: GranularEventEntry[]) => void,
|
||||
windowMs = 50,
|
||||
maxGranular = MAX_GRANULAR_EVENTS,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasDomainChanged = false;
|
||||
const pending = new Map<string, GranularEventEntry>();
|
||||
|
||||
function push(event: DesktopEvent) {
|
||||
if (event.type === "domain-changed") {
|
||||
hasDomainChanged = true;
|
||||
} else {
|
||||
const entry = entityKeyFromEvent(event);
|
||||
if (entry) {
|
||||
const key = `${entry.type}:${entry.entityId}`;
|
||||
const existing = pending.get(key);
|
||||
// If any event in window has action "deleted", preserve that action
|
||||
if (existing && existing.action === "deleted" && entry.action !== "deleted") {
|
||||
// Keep the delete -- it takes priority
|
||||
} else {
|
||||
pending.set(key, entry);
|
||||
}
|
||||
} else {
|
||||
// Unknown event type -- treat as domain-changed for safety (per D-05)
|
||||
hasDomainChanged = true;
|
||||
}
|
||||
}
|
||||
if (timer === null) {
|
||||
timer = setTimeout(flushInternal, windowMs);
|
||||
}
|
||||
}
|
||||
|
||||
function flushInternal() {
|
||||
timer = null;
|
||||
if (hasDomainChanged || pending.size > maxGranular) {
|
||||
onFullRefresh();
|
||||
} else if (pending.size > 0) {
|
||||
onGranularBatch([...pending.values()]);
|
||||
}
|
||||
hasDomainChanged = false;
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
flushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
hasDomainChanged = false;
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
return { push, flush, dispose };
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ApprovalRecord,
|
||||
CompanyMetrics,
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
RunRecord,
|
||||
StandupReport,
|
||||
TaskRecord,
|
||||
} from "@shared/types";
|
||||
import type { SidebarBadges } from "@shared/contracts";
|
||||
|
||||
@@ -24,6 +28,15 @@ export interface CompanySlice {
|
||||
setBadges: (badges: SidebarBadges | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
refreshSnapshot: () => Promise<void>;
|
||||
patchTask: (task: TaskRecord) => void;
|
||||
removeTask: (taskId: string) => void;
|
||||
patchRun: (run: RunRecord) => void;
|
||||
removeRun: (runId: string) => void;
|
||||
patchAgent: (agent: AgentRecord) => void;
|
||||
removeAgent: (agentId: string) => void;
|
||||
patchApproval: (approval: ApprovalRecord) => void;
|
||||
removeApproval: (approvalId: string) => void;
|
||||
refreshBadges: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const createCompanySlice: StateCreator<
|
||||
@@ -31,7 +44,7 @@ export const createCompanySlice: StateCreator<
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
CompanySlice
|
||||
> = (set) => ({
|
||||
> = (set, get) => ({
|
||||
snapshot: null,
|
||||
currentCompanyId: null,
|
||||
companyMetrics: null,
|
||||
@@ -85,4 +98,85 @@ export const createCompanySlice: StateCreator<
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// --- Granular patch methods for selective IPC updates ---
|
||||
|
||||
patchTask: (task) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.tasks[idx] = task;
|
||||
} else {
|
||||
state.snapshot.tasks.unshift(task);
|
||||
}
|
||||
}),
|
||||
removeTask: (taskId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.tasks = state.snapshot.tasks.filter((t) => t.id !== taskId);
|
||||
}),
|
||||
|
||||
patchRun: (run) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.runs.findIndex((r) => r.id === run.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.runs[idx] = run;
|
||||
} else {
|
||||
state.snapshot.runs.unshift(run);
|
||||
}
|
||||
}),
|
||||
removeRun: (runId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.runs = state.snapshot.runs.filter((r) => r.id !== runId);
|
||||
}),
|
||||
|
||||
patchAgent: (agent) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.agents.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.agents[idx] = agent;
|
||||
} else {
|
||||
state.snapshot.agents.unshift(agent);
|
||||
}
|
||||
}),
|
||||
removeAgent: (agentId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.agents = state.snapshot.agents.filter((a) => a.id !== agentId);
|
||||
}),
|
||||
|
||||
patchApproval: (approval) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.approvals.findIndex((a) => a.id === approval.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.approvals[idx] = approval;
|
||||
} else {
|
||||
state.snapshot.approvals.unshift(approval);
|
||||
}
|
||||
}),
|
||||
removeApproval: (approvalId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId);
|
||||
}),
|
||||
|
||||
refreshBadges: async () => {
|
||||
const companyId = get().currentCompanyId;
|
||||
if (!companyId) return;
|
||||
try {
|
||||
const result = await window.agentCompany.getSidebarBadges(companyId);
|
||||
if (result.ok) {
|
||||
set((state) => {
|
||||
state.badges = result.data;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore -- badges are non-critical */
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -105,6 +105,7 @@ export const createNavigationSlice: StateCreator<
|
||||
else if (section === "connectors") state.selectedConnectorId = entityId;
|
||||
else if (section === "communication")
|
||||
state.selectedCommunicationEntityId = entityId;
|
||||
else if (section === "skills") state.selectedSkillId = entityId;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type { SkillRecord } from "@shared/types";
|
||||
|
||||
export interface SkillSlice {
|
||||
skills: SkillRecord[];
|
||||
setSkills: (skills: SkillRecord[]) => void;
|
||||
selectedSkillId: string | null;
|
||||
setSelectedSkillId: (id: string | null) => void;
|
||||
selectedSkillFilePath: string;
|
||||
setSelectedSkillFilePath: (path: string) => void;
|
||||
skillImportModalOpen: boolean;
|
||||
setSkillImportModalOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const createSkillSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
SkillSlice
|
||||
> = (set) => ({
|
||||
skills: [],
|
||||
setSkills: (skills) =>
|
||||
set((state) => {
|
||||
state.skills = skills;
|
||||
}),
|
||||
selectedSkillId: null,
|
||||
setSelectedSkillId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedSkillId = id;
|
||||
}),
|
||||
selectedSkillFilePath: "SKILL.md",
|
||||
setSelectedSkillFilePath: (path) =>
|
||||
set((state) => {
|
||||
state.selectedSkillFilePath = path;
|
||||
}),
|
||||
skillImportModalOpen: false,
|
||||
setSkillImportModalOpen: (open) =>
|
||||
set((state) => {
|
||||
state.skillImportModalOpen = open;
|
||||
}),
|
||||
});
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
type CommunicationSlice,
|
||||
} from "./slices/communication";
|
||||
import { createSocialSlice, type SocialSlice } from "./slices/social";
|
||||
import { createSkillSlice, type SkillSlice } from "./slices/skill";
|
||||
|
||||
export type AppState = NavigationSlice &
|
||||
CompanySlice &
|
||||
SelectionSlice &
|
||||
UiSlice &
|
||||
CommunicationSlice &
|
||||
SocialSlice;
|
||||
SocialSlice &
|
||||
SkillSlice;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
@@ -33,6 +35,7 @@ export const useAppStore = create<AppState>()(
|
||||
...createUiSlice(...a),
|
||||
...createCommunicationSlice(...a),
|
||||
...createSocialSlice(...a),
|
||||
...createSkillSlice(...a),
|
||||
})),
|
||||
{
|
||||
name: "AgentCompany",
|
||||
|
||||
+158
-2
@@ -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, AgentRecord, ApprovalRecord, BrowserActionRecord, BudgetIncidentRecord, BudgetPolicyRecord, BudgetStatusSummary, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, RunRecord, SkillImportResult, SkillRecord, SkillUpdateStatus, StandupReport, TaskRecord } from "./types";
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
loadSnapshot: "agent-company:load-snapshot",
|
||||
@@ -73,6 +73,26 @@ 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",
|
||||
listSkillAgents: "agent-company:list-skill-agents",
|
||||
listAgentSkills: "agent-company:list-agent-skills",
|
||||
listWakeupRequests: "agent-company:list-wakeup-requests",
|
||||
saveBudgetPolicy: "agent-company:save-budget-policy",
|
||||
deleteBudgetPolicy: "agent-company:delete-budget-policy",
|
||||
listBudgetPolicies: "agent-company:list-budget-policies",
|
||||
listBudgetIncidents: "agent-company:list-budget-incidents",
|
||||
resolveBudgetIncident: "agent-company:resolve-budget-incident",
|
||||
getBudgetStatus: "agent-company:get-budget-status",
|
||||
getTask: "agent-company:get-task",
|
||||
getAgent: "agent-company:get-agent",
|
||||
getRun: "agent-company:get-run",
|
||||
getApproval: "agent-company:get-approval",
|
||||
} as const;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
@@ -514,10 +534,126 @@ 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 const listSkillAgentsSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
skillId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const listAgentSkillsSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const budgetPolicyInputSchema = z.object({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
scopeType: z.enum(["company", "agent", "project"]),
|
||||
scopeId: z.string().uuid(),
|
||||
metric: z.literal("billed_cents").default("billed_cents"),
|
||||
windowKind: z.literal("calendar_month_utc").default("calendar_month_utc"),
|
||||
amount: z.number().int().min(1),
|
||||
warnPercent: z.number().int().min(1).max(100).default(80),
|
||||
hardStopEnabled: z.boolean().default(true),
|
||||
notifyEnabled: z.boolean().default(true),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const listBudgetPoliciesSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const deleteBudgetPolicySchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
policyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const listBudgetIncidentsSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
status: z.enum(["open", "resolved", "auto_resolved_limit_increase", "auto_closed", "all"]).default("all"),
|
||||
});
|
||||
|
||||
export const resolveBudgetIncidentSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
incidentId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const getBudgetStatusSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const wakeupQuerySchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
status: z.enum(["queued", "claimed", "coalesced", "deferred", "completed"]).optional(),
|
||||
agentId: z.string().uuid().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export interface WakeupRequestRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
source: string;
|
||||
triggerDetail: string | null;
|
||||
status: string;
|
||||
coalescedCount: number;
|
||||
runId: string | null;
|
||||
requestedAt: string;
|
||||
claimedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const entityGetterSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type DesktopResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: { code: string; message: string } };
|
||||
|
||||
export interface SaveTaskResult {
|
||||
id: string;
|
||||
capabilityWarning?: string;
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
loadSnapshot: () => Promise<DesktopResult<ProfileSnapshot>>;
|
||||
pickDirectory: () => Promise<DesktopResult<string | null>>;
|
||||
@@ -531,7 +667,7 @@ export type DesktopApi = {
|
||||
requestHire: (input: z.infer<typeof hireRequestSchema>) => Promise<DesktopResult<{ agentId: string; approvalId: string; autoApproved: boolean }>>;
|
||||
saveGoal: (input: z.infer<typeof goalInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveProject: (input: z.infer<typeof projectInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveTask: (input: z.infer<typeof taskInputSchema>) => Promise<DesktopResult<string>>;
|
||||
saveTask: (input: z.infer<typeof taskInputSchema>) => Promise<DesktopResult<SaveTaskResult>>;
|
||||
requestApproval: (input: z.infer<typeof approvalInputSchema>) => Promise<DesktopResult<string>>;
|
||||
decideApproval: (input: z.infer<typeof approvalDecisionSchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveSecret: (input: z.infer<typeof secretInputSchema>) => Promise<DesktopResult<string>>;
|
||||
@@ -589,8 +725,28 @@ export type DesktopApi = {
|
||||
runWorkflow: (input: z.infer<typeof runWorkflowSchema>) => Promise<DesktopResult<boolean>>;
|
||||
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
|
||||
searchMessages: (input: z.infer<typeof searchMessagesSchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
|
||||
importSkill: (companyId: string, source: string) => Promise<DesktopResult<SkillImportResult[]>>;
|
||||
listSkills: (companyId: string) => Promise<DesktopResult<SkillRecord[]>>;
|
||||
getSkillDetail: (companyId: string, skillId: string) => Promise<DesktopResult<SkillRecord>>;
|
||||
deleteSkill: (companyId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
assignSkillToAgent: (companyId: string, agentId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
checkSkillUpdate: (companyId: string, skillId: string) => Promise<DesktopResult<SkillUpdateStatus>>;
|
||||
listSkillAgents: (companyId: string, skillId: string) => Promise<DesktopResult<string[]>>;
|
||||
listAgentSkills: (companyId: string, agentId: string) => Promise<DesktopResult<string[]>>;
|
||||
listWakeupRequests: (input: z.infer<typeof wakeupQuerySchema>) => Promise<DesktopResult<WakeupRequestRecord[]>>;
|
||||
saveBudgetPolicy: (input: z.infer<typeof budgetPolicyInputSchema>) => Promise<DesktopResult<string>>;
|
||||
deleteBudgetPolicy: (input: z.infer<typeof deleteBudgetPolicySchema>) => Promise<DesktopResult<boolean>>;
|
||||
listBudgetPolicies: (companyId: string) => Promise<DesktopResult<BudgetPolicyRecord[]>>;
|
||||
listBudgetIncidents: (input: z.infer<typeof listBudgetIncidentsSchema>) => Promise<DesktopResult<BudgetIncidentRecord[]>>;
|
||||
resolveBudgetIncident: (input: z.infer<typeof resolveBudgetIncidentSchema>) => Promise<DesktopResult<boolean>>;
|
||||
getBudgetStatus: (companyId: string) => Promise<DesktopResult<BudgetStatusSummary>>;
|
||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryDismiss: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryFail: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
getTask: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<TaskRecord>>;
|
||||
getAgent: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<AgentRecord>>;
|
||||
getRun: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<RunRecord>>;
|
||||
getApproval: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<ApprovalRecord>>;
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||
};
|
||||
+150
-1
@@ -16,6 +16,7 @@ export type SectionId =
|
||||
| "activity"
|
||||
| "workspaces"
|
||||
| "social"
|
||||
| "skills"
|
||||
| "automation"
|
||||
| "communication"
|
||||
| "documents"
|
||||
@@ -268,6 +269,74 @@ export interface CostRecord {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetPolicyRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
scopeType: "company" | "agent" | "project";
|
||||
scopeId: string;
|
||||
metric: string;
|
||||
windowKind: string;
|
||||
amount: number; // INTEGER cents
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
notifyEnabled: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetIncidentRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
policyId: string;
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
metric: string;
|
||||
windowKind: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
thresholdType: "warn_threshold" | "hard_stop";
|
||||
amountLimit: number;
|
||||
amountObserved: number;
|
||||
status: "open" | "resolved" | "auto_resolved_limit_increase" | "auto_closed";
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetStatusSummary {
|
||||
policies: Array<{
|
||||
policyId: string;
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
amount: number;
|
||||
spent: number;
|
||||
warnPercent: number;
|
||||
hardStopEnabled: boolean;
|
||||
hasOpenIncident: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TaskSessionRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
sessionParamsJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
lastRunId: string | null;
|
||||
lastError: string | null;
|
||||
runCount: number;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
rotatedAt: string | null;
|
||||
rotationReason: string | null;
|
||||
handoffMarkdown: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ActivityRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -385,6 +454,35 @@ export interface NotificationClickEvent {
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
export interface TaskUpdatedEvent {
|
||||
type: "task-updated";
|
||||
taskId: string;
|
||||
action: "created" | "updated" | "deleted";
|
||||
}
|
||||
|
||||
export interface RunUpdatedEvent {
|
||||
type: "run-updated";
|
||||
runId: string;
|
||||
action: "created" | "status_changed" | "completed";
|
||||
}
|
||||
|
||||
export interface ApprovalUpdatedEvent {
|
||||
type: "approval-updated";
|
||||
approvalId: string;
|
||||
action: "created" | "decided" | "deleted";
|
||||
}
|
||||
|
||||
export interface AgentUpdatedEvent {
|
||||
type: "agent-updated";
|
||||
agentId: string;
|
||||
action: "updated" | "paused";
|
||||
}
|
||||
|
||||
export interface BudgetChangedEvent {
|
||||
type: "budget-changed";
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export type DesktopEvent =
|
||||
| LogChunkEvent
|
||||
| DomainChangedEvent
|
||||
@@ -394,7 +492,12 @@ export type DesktopEvent =
|
||||
| AgentMessageEvent
|
||||
| AutomationFiredEvent
|
||||
| NewMessageEvent
|
||||
| NotificationClickEvent;
|
||||
| NotificationClickEvent
|
||||
| TaskUpdatedEvent
|
||||
| RunUpdatedEvent
|
||||
| ApprovalUpdatedEvent
|
||||
| AgentUpdatedEvent
|
||||
| BudgetChangedEvent;
|
||||
|
||||
export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message";
|
||||
|
||||
@@ -453,6 +556,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<string, unknown> | 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";
|
||||
|
||||
Reference in new issue
Block a user