1 parent
16d8426119
commit
66158e222e
398 files changed
+85259
-270637
No files matched your search
@@ -10,6 +10,7 @@ import type {
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
import { validateTaskCreation } from "./task-quality-gates";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import { notifyHireApproved } from "./hire-hook";
|
||||
|
||||
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
|
||||
const IMPORTANT_DOCUMENT_TYPES = new Set([
|
||||
@@ -98,6 +99,10 @@ function parseDocumentIdsFromPayload(payloadJson: string | null): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
|
||||
return locale === "zh" ? zh : en;
|
||||
}
|
||||
|
||||
function scoreSkillAgentMatch(
|
||||
skill: { name: string; description: string | null; markdown: string },
|
||||
agent: { role: string; department: string | null; title: string | null; capabilities: string },
|
||||
@@ -159,6 +164,21 @@ export function autoAssignSkillsToAgent(
|
||||
|
||||
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
|
||||
|
||||
// Always assign basic-for-all builtin skills regardless of score
|
||||
let assigned = 0;
|
||||
for (const skill of allSkills) {
|
||||
if (existingAssignments.has(skill.id)) continue;
|
||||
try {
|
||||
const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {};
|
||||
if (meta.builtinCategory === "basic-for-all") {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
existingAssignments.add(skill.id);
|
||||
assigned++;
|
||||
logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`);
|
||||
}
|
||||
} catch { /* skip malformed metadata */ }
|
||||
}
|
||||
|
||||
const scored = allSkills
|
||||
.filter((skill) => !existingAssignments.has(skill.id))
|
||||
.map((skill) => ({
|
||||
@@ -168,7 +188,6 @@ export function autoAssignSkillsToAgent(
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const MIN_SCORE = 3;
|
||||
let assigned = 0;
|
||||
for (const { skill, score } of scored) {
|
||||
if (score < MIN_SCORE) continue;
|
||||
try {
|
||||
@@ -1138,9 +1157,10 @@ export function createAutomationService({
|
||||
});
|
||||
// Fire OS notification for budget threshold warning
|
||||
const pct = Math.round(utilization * 100);
|
||||
const budgetLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: "Budget Warning",
|
||||
body: `${agent.name} has used ${pct}% of monthly budget`,
|
||||
title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"),
|
||||
body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "costs" },
|
||||
batchKey: "budget_alerts",
|
||||
@@ -1148,8 +1168,8 @@ export function createAutomationService({
|
||||
if (utilization >= 1.0) {
|
||||
// Fire OS notification for budget exhaustion (agent auto-paused by database)
|
||||
notify({
|
||||
title: "Budget Exhausted",
|
||||
body: `${agent.name} has reached 100% of monthly budget and is paused`,
|
||||
title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"),
|
||||
body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`),
|
||||
urgency: "critical",
|
||||
navigation: { section: "costs" },
|
||||
});
|
||||
@@ -1211,13 +1231,23 @@ export function createAutomationService({
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
const hireLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: "Agent Hired",
|
||||
body: `${agent.name} joined as ${agent.role}`,
|
||||
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
|
||||
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "orgchart", entityId: agent.id },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
// Notify adapter that hire was approved (Paperclip hire-hook parity)
|
||||
void notifyHireApproved(db, {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
source: "approval",
|
||||
sourceId: approvalId,
|
||||
approvedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Hire follow-up is best-effort; approval state is already committed in storage.
|
||||
@@ -1279,6 +1309,32 @@ export function createAutomationService({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle budget override approvals
|
||||
if (approval.type === "budget_override" && decision === "approved") {
|
||||
try {
|
||||
const payload = JSON.parse(approval.payloadJson ?? "{}") as {
|
||||
policyId?: string;
|
||||
scopeType?: string;
|
||||
scopeId?: string;
|
||||
newAmount?: number;
|
||||
};
|
||||
if (payload.policyId) {
|
||||
// Resolve the budget incident that triggered this approval
|
||||
db.resolveBudgetIncident(payload.policyId, companyId);
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "budget.override_approved",
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
detail: `Budget override approved for ${payload.scopeType} scope`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[automation] Failed to process budget_override approval: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
|
||||
if (browserAction) {
|
||||
if (decision === "approved") {
|
||||
|
||||
@@ -67,6 +67,21 @@ export async function bootstrapApplication({
|
||||
}: BootstrapApplicationDependencies) {
|
||||
await app.whenReady();
|
||||
log.initialize();
|
||||
|
||||
// Configure log format for cleaner output
|
||||
log.transports.console.format = "[{h}:{i}:{s}] [{level}] {text}";
|
||||
log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}";
|
||||
|
||||
// In production, only show warnings and errors in console
|
||||
// File logging always captures info+
|
||||
if (app.isPackaged) {
|
||||
log.transports.console.level = "warn";
|
||||
log.transports.file.level = "info";
|
||||
} else {
|
||||
log.transports.console.level = "info";
|
||||
log.transports.file.level = "debug";
|
||||
}
|
||||
|
||||
await prepareHostEnvironment({ logger: log });
|
||||
|
||||
const db = appDatabaseFactory(profileDir);
|
||||
|
||||
@@ -814,6 +814,10 @@ export class AppDatabase {
|
||||
}
|
||||
const existing = this.db.prepare("select id from agents where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
// Snapshot before-config for revision tracking
|
||||
const beforeAgent = this.db.prepare(
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?"
|
||||
).get(id, input.companyId) as Record<string, any> | undefined;
|
||||
this.db
|
||||
.prepare(
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
@@ -835,6 +839,17 @@ export class AppDatabase {
|
||||
id,
|
||||
input.companyId,
|
||||
);
|
||||
// Record config revision if fields changed
|
||||
if (beforeAgent) {
|
||||
const afterConfig = { name: input.name, role: input.role, title: input.title, department, capabilities: input.capabilities, connectorId: input.connectorId, model: normalizeOptionalId(input.model), budgetMonthlyUsd: input.budgetMonthlyUsd, metadataJson };
|
||||
const beforeConfig = { name: beforeAgent.name, role: beforeAgent.role, title: beforeAgent.title, department: beforeAgent.department, capabilities: beforeAgent.capabilities, connectorId: beforeAgent.connector_id, model: beforeAgent.model, budgetMonthlyUsd: beforeAgent.budget_monthly_usd, metadataJson: beforeAgent.metadata_json };
|
||||
const changedKeys = Object.keys(afterConfig).filter(k => JSON.stringify((afterConfig as any)[k]) !== JSON.stringify((beforeConfig as any)[k]));
|
||||
if (changedKeys.length > 0) {
|
||||
try {
|
||||
this.recordConfigRevision({ companyId: input.companyId, agentId: id, changedKeys, configBefore: beforeConfig, configAfter: afterConfig });
|
||||
} catch { /* Non-fatal: revision recording should never block agent save */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -4346,6 +4361,55 @@ export class AppDatabase {
|
||||
try { this.db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ }
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 22,
|
||||
name: "add_config_revisions_run_events_agent_permissions",
|
||||
fn: () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_config_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
changed_keys TEXT NOT NULL DEFAULT '[]',
|
||||
config_before TEXT NOT NULL,
|
||||
config_after TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'patch',
|
||||
rolled_back_from_revision_id TEXT,
|
||||
created_by_agent_id TEXT,
|
||||
created_by_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
stream TEXT,
|
||||
level TEXT,
|
||||
message TEXT,
|
||||
payload TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id);
|
||||
`);
|
||||
const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } };
|
||||
addCol("ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'");
|
||||
addCol("ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT");
|
||||
addCol("ALTER TABLE agents ADD COLUMN runtime_state_json TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN actor_type TEXT");
|
||||
addCol("ALTER TABLE runs ADD COLUMN actor_id TEXT");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
@@ -5133,6 +5197,249 @@ export class AppDatabase {
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
recordConfigRevision(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
changedKeys: string[];
|
||||
configBefore: Record<string, unknown>;
|
||||
configAfter: Record<string, unknown>;
|
||||
source?: string;
|
||||
rolledBackFromRevisionId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const lastRev = this.db.prepare(
|
||||
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
|
||||
).get(input.agentId) as { maxRev: number | null } | undefined;
|
||||
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
|
||||
this.db.prepare(
|
||||
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
id, input.companyId, input.agentId, revisionNumber,
|
||||
JSON.stringify(input.changedKeys),
|
||||
JSON.stringify(input.configBefore),
|
||||
JSON.stringify(input.configAfter),
|
||||
input.source ?? "patch",
|
||||
input.rolledBackFromRevisionId ?? null,
|
||||
input.createdByAgentId ?? null,
|
||||
input.createdByUserId ?? null,
|
||||
ts
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
listConfigRevisions(agentId: string, companyId: string): Array<{
|
||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||
source: string; rolledBackFromRevisionId: string | null;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||
}> {
|
||||
return this.readAll<any>(
|
||||
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
|
||||
agentId, companyId
|
||||
).map(row => ({
|
||||
id: row.id,
|
||||
agentId: row.agent_id,
|
||||
revisionNumber: row.revision_number,
|
||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||
source: row.source,
|
||||
rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||
createdByAgentId: row.created_by_agent_id ?? null,
|
||||
createdByUserId: row.created_by_user_id ?? null,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
getConfigRevision(revisionId: string, companyId: string): {
|
||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||
source: string; rolledBackFromRevisionId: string | null;
|
||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||
} {
|
||||
const row = this.db.prepare(
|
||||
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
|
||||
).get(revisionId, companyId) as any;
|
||||
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
|
||||
return {
|
||||
id: row.id,
|
||||
agentId: row.agent_id,
|
||||
revisionNumber: row.revision_number,
|
||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
|
||||
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
|
||||
source: row.source,
|
||||
rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||
createdByAgentId: row.created_by_agent_id ?? null,
|
||||
createdByUserId: row.created_by_user_id ?? null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string): string {
|
||||
return this.transaction(() => {
|
||||
const revision = this.getConfigRevision(revisionId, companyId);
|
||||
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
|
||||
const before = revision.configBefore as Record<string, any>;
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(
|
||||
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
|
||||
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
|
||||
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
|
||||
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
|
||||
);
|
||||
const agent = this.getAgent(agentId);
|
||||
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: (agent as any).department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
|
||||
return this.recordConfigRevision({
|
||||
companyId, agentId,
|
||||
changedKeys: revision.changedKeys,
|
||||
configBefore: currentConfig,
|
||||
configAfter: before,
|
||||
source: "rollback",
|
||||
rolledBackFromRevisionId: revisionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
appendRunEvent(input: {
|
||||
companyId: string; runId: string; agentId: string;
|
||||
eventType: string; stream?: string | null; level?: string | null;
|
||||
message?: string | null; payload?: Record<string, unknown> | null;
|
||||
}): void {
|
||||
const ts = nowIso();
|
||||
const lastSeq = this.db.prepare(
|
||||
"SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?"
|
||||
).get(input.runId) as { maxSeq: number | null } | undefined;
|
||||
const seq = (lastSeq?.maxSeq ?? 0) + 1;
|
||||
this.db.prepare(
|
||||
"INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
input.companyId, input.runId, input.agentId, seq,
|
||||
input.eventType, input.stream ?? null, input.level ?? null,
|
||||
input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts
|
||||
);
|
||||
}
|
||||
|
||||
listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{
|
||||
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
||||
stream: string | null; level: string | null; message: string | null;
|
||||
payload: Record<string, unknown> | null; createdAt: string;
|
||||
}> {
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? 500;
|
||||
return this.readAll<any>(
|
||||
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?",
|
||||
runId, companyId, limit, offset
|
||||
).map(row => ({
|
||||
id: row.id,
|
||||
runId: row.run_id,
|
||||
agentId: row.agent_id,
|
||||
seq: row.seq,
|
||||
eventType: row.event_type,
|
||||
stream: row.stream,
|
||||
level: row.level,
|
||||
message: row.message,
|
||||
payload: parseJson<Record<string, unknown> | null>(row.payload, null),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
getAgentPermissions(agentId: string): Record<string, boolean> {
|
||||
const row = this.db.prepare(
|
||||
"SELECT permissions_json FROM agents WHERE id = ?"
|
||||
).get(agentId) as { permissions_json: string } | undefined;
|
||||
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||
}
|
||||
|
||||
setAgentPermission(agentId: string, companyId: string, permissionKey: string, enabled: boolean): void {
|
||||
const current = this.getAgentPermissions(agentId);
|
||||
if (enabled) {
|
||||
current[permissionKey] = true;
|
||||
} else {
|
||||
delete current[permissionKey];
|
||||
}
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(JSON.stringify(current), ts, agentId, companyId);
|
||||
}
|
||||
|
||||
hasAgentPermission(agentId: string, permissionKey: string): boolean {
|
||||
const perms = this.getAgentPermissions(agentId);
|
||||
return perms[permissionKey] === true;
|
||||
}
|
||||
|
||||
getRuntimeState(agentId: string): Record<string, unknown> {
|
||||
const row = this.db.prepare(
|
||||
"SELECT runtime_state_json FROM agents WHERE id = ?"
|
||||
).get(agentId) as { runtime_state_json: string | null } | undefined;
|
||||
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
||||
}
|
||||
|
||||
setRuntimeState(agentId: string, companyId: string, state: Record<string, unknown>): void {
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(JSON.stringify(state), ts, agentId, companyId);
|
||||
}
|
||||
|
||||
getHeartbeatPolicy(agentId: string): {
|
||||
enabled: boolean; intervalSec: number; cooldownSec: number;
|
||||
maxConcurrentRuns: number; wakeOnDemand: boolean;
|
||||
} {
|
||||
const row = this.db.prepare(
|
||||
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?"
|
||||
).get(agentId) as { heartbeat_enabled: number; heartbeat_interval_sec: number; heartbeat_policy_json: string | null } | undefined;
|
||||
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
||||
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
||||
return {
|
||||
enabled: toBoolean(row.heartbeat_enabled),
|
||||
intervalSec: row.heartbeat_interval_sec ?? 120,
|
||||
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
|
||||
maxConcurrentRuns: typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
|
||||
wakeOnDemand: policy.wakeOnDemand !== false,
|
||||
};
|
||||
}
|
||||
|
||||
setHeartbeatPolicy(agentId: string, companyId: string, policy: {
|
||||
enabled?: boolean; intervalSec?: number; cooldownSec?: number;
|
||||
maxConcurrentRuns?: number; wakeOnDemand?: boolean;
|
||||
}): void {
|
||||
const ts = nowIso();
|
||||
const current = this.getHeartbeatPolicy(agentId);
|
||||
const merged = { ...current, ...policy };
|
||||
const policyJson = JSON.stringify({
|
||||
cooldownSec: merged.cooldownSec,
|
||||
maxConcurrentRuns: merged.maxConcurrentRuns,
|
||||
wakeOnDemand: merged.wakeOnDemand,
|
||||
});
|
||||
this.db.prepare(
|
||||
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
||||
}
|
||||
|
||||
countActiveRunsForAgent(agentId: string, companyId: string): number {
|
||||
const row = this.db.prepare(
|
||||
"SELECT COUNT(*) as cnt FROM runs WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'running')"
|
||||
).get(agentId, companyId) as { cnt: number } | undefined;
|
||||
return row?.cnt ?? 0;
|
||||
}
|
||||
|
||||
updateRunActor(runId: string, actorType: string, actorId: string | null): void {
|
||||
this.db.prepare(
|
||||
"UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?"
|
||||
).run(actorType, actorId, nowIso(), runId);
|
||||
}
|
||||
|
||||
updateCompanySkillMetadata(companyId: string, skillId: string, metadataJson: string): void {
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"UPDATE company_skills SET metadata = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||
).run(metadataJson, ts, skillId, companyId);
|
||||
}
|
||||
|
||||
private openConnection(): SqliteDatabase {
|
||||
return new DatabaseSync(this.dbPath, { timeout: 5000 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { HireApprovedPayload, HireApprovedHookResult } from "@agentcompany/adapter-utils";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export type { HireApprovedPayload, HireApprovedHookResult };
|
||||
|
||||
/**
|
||||
* Notify the adapter that an agent hire was approved.
|
||||
* Non-fatal: failures are logged but never thrown.
|
||||
* Follows the Paperclip hire-hook.ts pattern.
|
||||
*/
|
||||
export async function notifyHireApproved(
|
||||
db: AppDatabase,
|
||||
input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
let agent;
|
||||
try {
|
||||
agent = db.getAgent(input.agentId);
|
||||
} catch {
|
||||
log.warn(`[hire-hook] Agent ${input.agentId} not found, skipping hire notification.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let connectorDef;
|
||||
try {
|
||||
connectorDef = getConnectorDefinition(agent.connectorId);
|
||||
} catch {
|
||||
log.warn(`[hire-hook] No connector definition for ${agent.connectorId}, skipping hire notification.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = connectorDef.module;
|
||||
if (!adapter.onHireApproved) {
|
||||
// Adapter doesn't implement the hook — this is normal, not an error
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HireApprovedPayload = {
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
agentName: agent.name,
|
||||
adapterType: connectorDef.id,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
approvedAt: input.approvedAt ?? new Date().toISOString(),
|
||||
message: db.listSnapshot().locale === "zh"
|
||||
? `你已被批准为"${agent.title}"。你现在已激活,可以接收任务。`
|
||||
: `Your hire as "${agent.title}" in the company has been approved. You are now active and ready to receive tasks.`,
|
||||
};
|
||||
|
||||
const result = await adapter.onHireApproved(payload, {});
|
||||
if (result.ok) {
|
||||
log.info(`[hire-hook] Successfully notified adapter for agent ${agent.name} (${input.agentId})`);
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.success",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: `Hire notification sent to ${connectorDef.id} adapter`,
|
||||
});
|
||||
} else {
|
||||
log.warn(`[hire-hook] Adapter returned error for agent ${agent.name}: ${result.error}`);
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.failed",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: `Adapter error: ${result.error ?? "unknown"}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: log and continue
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[hire-hook] Exception notifying hire for agent ${input.agentId}: ${message}`);
|
||||
try {
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.exception",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: message,
|
||||
});
|
||||
} catch { /* activity logging itself failed — nothing more we can do */ }
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,20 @@ import {
|
||||
approvalInputSchema,
|
||||
cancelRunSchema,
|
||||
commentInputSchema,
|
||||
getAgentPermissionsSchema,
|
||||
getConfigRevisionSchema,
|
||||
heartbeatPolicySchema,
|
||||
heartbeatSettingsSchema,
|
||||
listCommentsSchema,
|
||||
listConfigRevisionsSchema,
|
||||
listRunEventsSchema,
|
||||
rollbackConfigRevisionSchema,
|
||||
runtimeStateSchema,
|
||||
runLogChunkSchema,
|
||||
runLogSchema,
|
||||
setAgentPermissionSchema,
|
||||
setHeartbeatPolicySchema,
|
||||
setRuntimeStateSchema,
|
||||
startRunSchema,
|
||||
triggerHeartbeatSchema,
|
||||
} from "@shared/contracts";
|
||||
@@ -240,4 +250,62 @@ export function registerCoreOperationHandlers({
|
||||
}
|
||||
return ok(runId);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listConfigRevisions, async (_event, payload) => {
|
||||
const parsed = listConfigRevisionsSchema.parse(payload);
|
||||
return ok(db.listConfigRevisions(parsed.agentId, parsed.companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getConfigRevision, async (_event, payload) => {
|
||||
const parsed = getConfigRevisionSchema.parse(payload);
|
||||
return ok(db.getConfigRevision(parsed.revisionId, parsed.companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.rollbackConfigRevision, async (_event, payload) => {
|
||||
const parsed = rollbackConfigRevisionSchema.parse(payload);
|
||||
const newRevisionId = db.rollbackConfigRevision(parsed.agentId, parsed.revisionId, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok({ revisionId: newRevisionId });
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listRunEvents, async (_event, payload) => {
|
||||
const parsed = listRunEventsSchema.parse(payload);
|
||||
return ok(db.listRunEvents(parsed.runId, parsed.companyId, { offset: parsed.offset, limit: parsed.limit }));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAgentPermissions, async (_event, payload) => {
|
||||
const parsed = getAgentPermissionsSchema.parse(payload);
|
||||
return ok(db.getAgentPermissions(parsed.agentId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setAgentPermission, async (_event, payload) => {
|
||||
const parsed = setAgentPermissionSchema.parse(payload);
|
||||
db.setAgentPermission(parsed.agentId, parsed.companyId, parsed.permissionKey, parsed.enabled);
|
||||
publishDomainChanged();
|
||||
return ok(null);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getHeartbeatPolicy, async (_event, payload) => {
|
||||
const parsed = heartbeatPolicySchema.parse(payload);
|
||||
return ok(db.getHeartbeatPolicy(parsed.agentId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setHeartbeatPolicy, async (_event, payload) => {
|
||||
const parsed = setHeartbeatPolicySchema.parse(payload);
|
||||
const { companyId, agentId, ...policy } = parsed;
|
||||
db.setHeartbeatPolicy(agentId, companyId, policy);
|
||||
publishDomainChanged();
|
||||
return ok(null);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getRuntimeState, async (_event, payload) => {
|
||||
const parsed = runtimeStateSchema.parse(payload);
|
||||
return ok(db.getRuntimeState(parsed.agentId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setRuntimeState, async (_event, payload) => {
|
||||
const parsed = setRuntimeStateSchema.parse(payload);
|
||||
db.setRuntimeState(parsed.agentId, parsed.companyId, parsed.state);
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
@@ -99,9 +99,10 @@ export function createOnboardingService({
|
||||
function createDefaultWorkspace(companyId: string, workspacePath: string) {
|
||||
const trimmedPath = workspacePath.trim();
|
||||
if (!trimmedPath) return null;
|
||||
const wsLocale = db.listSnapshot().locale;
|
||||
return db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default Workspace",
|
||||
name: wsLocale === "zh" ? "默认工作区" : "Default Workspace",
|
||||
localPath: trimmedPath,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
@@ -143,7 +144,10 @@ export function createOnboardingService({
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const kickoffTaskTitle = activeGoal ? `Operationalize goal: ${activeGoal.title}` : "Establish the first operating cadence";
|
||||
const kickoffLocale = db.listSnapshot().locale;
|
||||
const kickoffTaskTitle = activeGoal
|
||||
? (kickoffLocale === "zh" ? `落地目标:${activeGoal.title}` : `Operationalize goal: ${activeGoal.title}`)
|
||||
: (kickoffLocale === "zh" ? "建立首个运营节奏" : "Establish the first operating cadence");
|
||||
const kickoffTaskDescription = activeGoal
|
||||
? [
|
||||
`Read the founding goal "${activeGoal.title}" and convert it into a real operating plan.`,
|
||||
@@ -213,17 +217,19 @@ export function createOnboardingService({
|
||||
});
|
||||
}
|
||||
|
||||
const ceoLocale = db.listSnapshot().locale;
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
|
||||
reportsTo: null,
|
||||
connectorId: selectedConnectorId,
|
||||
workspaceId,
|
||||
model: input.connectorModel?.trim() || null,
|
||||
capabilities:
|
||||
"Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||
capabilities: ceoLocale === "zh"
|
||||
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
|
||||
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||
budgetMonthlyUsd: 250,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ interface EnqueueRunPayload {
|
||||
interface OrchestrationDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
@@ -362,7 +363,7 @@ export function createOrchestrationService({
|
||||
if (!hasWorkerReady()) return null;
|
||||
const companyStatus = db.getCompanyStatus(companyId);
|
||||
if (companyStatus !== "active") {
|
||||
logger.info(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
|
||||
logger.debug(`[heartbeat] Skipping ${agentId} because company ${companyId} is not active.`);
|
||||
return null;
|
||||
}
|
||||
const agent = db.getAgent(agentId);
|
||||
@@ -374,26 +375,52 @@ export function createOrchestrationService({
|
||||
if (getRunEligibilityFailure(agent, connector, null)) {
|
||||
return null;
|
||||
}
|
||||
if (hasActiveRun({ agentId: agent.id, companyId })) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because it already has an active run.`);
|
||||
// Extended heartbeat policy checks (Paperclip parity)
|
||||
let heartbeatPolicy = { cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true };
|
||||
try {
|
||||
heartbeatPolicy = db.getHeartbeatPolicy(agent.id);
|
||||
} catch {
|
||||
// Non-fatal: use defaults
|
||||
}
|
||||
|
||||
// maxConcurrentRuns check: count active runs for this agent
|
||||
const activeRunCount = db.countActiveRunsForAgent(agent.id, companyId);
|
||||
if (activeRunCount >= heartbeatPolicy.maxConcurrentRuns) {
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — active runs (${activeRunCount}) >= maxConcurrentRuns (${heartbeatPolicy.maxConcurrentRuns}).`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cooldown check: ensure enough time has passed since last heartbeat
|
||||
if (agent.lastHeartbeatAt) {
|
||||
const elapsed = Date.now() - new Date(agent.lastHeartbeatAt).getTime();
|
||||
const cooldownMs = heartbeatPolicy.cooldownSec * 1000;
|
||||
if (elapsed < cooldownMs) {
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — cooldown (${heartbeatPolicy.cooldownSec}s) not elapsed.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// wakeOnDemand gate: if disabled, only timer/bootstrap triggers allowed
|
||||
if (!heartbeatPolicy.wakeOnDemand && trigger !== "timer" && trigger !== "bootstrap") {
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — wakeOnDemand is disabled and trigger is "${trigger}".`);
|
||||
return null;
|
||||
}
|
||||
if (db.isAgentPendingApproval(agentId)) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} because agent is pending hire approval.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block heartbeat when agent has tasks awaiting user deliverable review
|
||||
// Check both: pending approval (gate created) and requires_user_review flag (gap before approval)
|
||||
if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block CEO heartbeat when company has any pending deliverable reviews
|
||||
// CEO should not create new work while the user hasn't reviewed existing deliverables
|
||||
if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
|
||||
logger.info(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
|
||||
logger.debug(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -418,7 +445,7 @@ export function createOrchestrationService({
|
||||
const companyNeedsBootstrap = goalCount > 0 && taskCount < goalCount;
|
||||
|
||||
if (isTimer && !isCeo && !isManager && !companyNeedsBootstrap) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
|
||||
logger.debug(`[heartbeat] Skipping ${agent.name} — no actionable tasks and trigger is timer.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -507,6 +534,14 @@ export function createOrchestrationService({
|
||||
});
|
||||
|
||||
db.markHeartbeatAt(agentId);
|
||||
|
||||
// Actor audit: record what triggered this run
|
||||
try {
|
||||
const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent";
|
||||
const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger;
|
||||
db.updateRunActor(run.id, actorType, actorId);
|
||||
} catch { /* Non-fatal */ }
|
||||
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
|
||||
@@ -1573,6 +1573,11 @@ Always verify API responses before reporting — only report successful actions.
|
||||
- CRITICAL: Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward.
|
||||
`);
|
||||
|
||||
// Reinforce locale at the end of prompt (recency bias helps LLMs follow this)
|
||||
if (localeInstruction) {
|
||||
sections.push(`# 语言要求提醒\n${localeInstruction}\n请记住:你的所有输出必须使用中文。\n`);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,8 @@ export function createServiceGraph({
|
||||
publishDomainChanged,
|
||||
scanAndAssignSkills: async (companyId: string, agentId: string) => {
|
||||
const svc = getOrCreateSkillService();
|
||||
await svc.seedBuiltinSkills(companyId);
|
||||
svc.assignRoleSkillsToAgent(companyId, agentId, "CEO");
|
||||
await svc.scanWorkspaceSkills(companyId);
|
||||
autoAssignSkillsToAgent(db, logger, companyId, agentId);
|
||||
},
|
||||
|
||||
+171
-24
@@ -677,53 +677,197 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
): Promise<SkillImportResult[]> {
|
||||
const allResults: SkillImportResult[] = [];
|
||||
|
||||
// 1. Scan user home directories: ~/.agents/skills/ and ~/.claude/skills/
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
||||
if (homeDir) {
|
||||
const homeScanDirs = [
|
||||
join(homeDir, ".agents", "skills"),
|
||||
join(homeDir, ".claude", "skills"),
|
||||
];
|
||||
for (const scanDir of homeScanDirs) {
|
||||
if (!existsSync(scanDir)) continue;
|
||||
try {
|
||||
const stat = statSync(scanDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
const results = await importFromLocal(companyId, scanDir);
|
||||
// 1. Scan profile skills directory: {profileDir}/skills/{companyId}/
|
||||
// This is where builtin and user-installed skills live
|
||||
const profileSkillsDir = skillsRoot(companyId);
|
||||
if (existsSync(profileSkillsDir)) {
|
||||
try {
|
||||
const stat = statSync(profileSkillsDir);
|
||||
if (stat.isDirectory()) {
|
||||
const results = await importFromLocal(companyId, profileSkillsDir);
|
||||
allResults.push(...results);
|
||||
logger.info(`Scanned home skills: ${scanDir} — imported ${results.length} skill(s)`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`Failed to scan home skills at ${scanDir}: ${message}`);
|
||||
logger.info(`Scanned profile skills: ${profileSkillsDir} — imported ${results.length} skill(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`Failed to scan profile skills at ${profileSkillsDir}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan workspace .agents/skills/ directories
|
||||
// 2. Scan workspace .agentcompany/skills/ directories
|
||||
const workspacePaths = db.listWorkspaceLocalPaths(companyId);
|
||||
for (const wsPath of workspacePaths) {
|
||||
const agentsSkillsDir = join(wsPath, ".agents", "skills");
|
||||
if (!existsSync(agentsSkillsDir)) continue;
|
||||
const agentcompanySkillsDir = join(wsPath, ".agentcompany", "skills");
|
||||
if (!existsSync(agentcompanySkillsDir)) continue;
|
||||
|
||||
try {
|
||||
const stat = statSync(agentsSkillsDir);
|
||||
const stat = statSync(agentcompanySkillsDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
const results = await importFromLocal(companyId, agentsSkillsDir);
|
||||
const results = await importFromLocal(companyId, agentcompanySkillsDir);
|
||||
allResults.push(...results);
|
||||
logger.info(
|
||||
`Scanned workspace skills: ${agentsSkillsDir} — imported ${results.length} skill(s)`,
|
||||
`Scanned workspace skills: ${agentcompanySkillsDir} — imported ${results.length} skill(s)`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Log but don't fail the entire scan if one workspace has issues
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`Failed to scan workspace skills at ${agentsSkillsDir}: ${message}`);
|
||||
logger.warn(`Failed to scan workspace skills at ${agentcompanySkillsDir}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the path to bundled builtin skills.
|
||||
* Cross-platform: works on macOS, Windows, and Linux.
|
||||
*
|
||||
* Production (packaged app):
|
||||
* macOS: AppName.app/Contents/Resources/builtin-skills/
|
||||
* Windows: {install dir}/resources/builtin-skills/
|
||||
* Linux: {install dir}/resources/builtin-skills/
|
||||
* Resolved via `process.resourcesPath` (set by Electron on all platforms).
|
||||
*
|
||||
* Development (electron-vite dev):
|
||||
* CWD is the project root → {project}/builtin-skills/
|
||||
* No __dirname dependency (ESM polyfill varies across bundlers).
|
||||
*/
|
||||
function getBuiltinSkillsDir(): string | null {
|
||||
// 1. Production: Electron packages extraResources into process.resourcesPath
|
||||
// process.resourcesPath is always defined in Electron (all platforms)
|
||||
if (process.resourcesPath) {
|
||||
const resourcesPath = join(process.resourcesPath, "builtin-skills");
|
||||
if (existsSync(resourcesPath)) return resourcesPath;
|
||||
}
|
||||
// 2. Development: electron-vite dev sets CWD to project root
|
||||
const cwdPath = join(process.cwd(), "builtin-skills");
|
||||
if (existsSync(cwdPath)) return cwdPath;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed all builtin skills for a company from the bundled builtin-skills directory.
|
||||
* Idempotent: skips skills that already exist (by key).
|
||||
* Called once during company bootstrap.
|
||||
*/
|
||||
async function seedBuiltinSkills(companyId: string): Promise<SkillImportResult[]> {
|
||||
const builtinDir = getBuiltinSkillsDir();
|
||||
if (!builtinDir) {
|
||||
logger.warn("[skills] Builtin skills directory not found — skipping seed.");
|
||||
return [];
|
||||
}
|
||||
|
||||
const allResults: SkillImportResult[] = [];
|
||||
|
||||
// Walk each category directory (basic-for-all, software-engineer, etc.)
|
||||
let categories: string[];
|
||||
try {
|
||||
categories = readdirSync(builtinDir).filter(name => {
|
||||
if (name.startsWith(".")) return false;
|
||||
try { return statSync(join(builtinDir, name)).isDirectory(); } catch { return false; }
|
||||
});
|
||||
} catch {
|
||||
logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryDir = join(builtinDir, category);
|
||||
try {
|
||||
const results = await importFromLocal(companyId, categoryDir);
|
||||
// Tag each imported skill with builtin metadata so we know its category
|
||||
for (const result of results) {
|
||||
try {
|
||||
const skill = db.getCompanySkill(companyId, result.skillId);
|
||||
if (skill) {
|
||||
const existingMeta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
|
||||
existingMeta.builtinCategory = category;
|
||||
existingMeta.sourceKind = "builtin";
|
||||
db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta));
|
||||
}
|
||||
} catch { /* non-fatal metadata update */ }
|
||||
}
|
||||
allResults.push(...results);
|
||||
logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`[skills] Failed to seed builtin category ${category}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-assign all skills from the "basic-for-all" builtin category to an agent.
|
||||
*/
|
||||
function assignBasicSkillsToAgent(companyId: string, agentId: string): number {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
let assigned = 0;
|
||||
for (const skill of allSkills) {
|
||||
try {
|
||||
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
|
||||
if (meta.builtinCategory === "basic-for-all") {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
}
|
||||
} catch { /* skip malformed metadata */ }
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-assign role-appropriate builtin skills to an agent based on its role/department.
|
||||
* CEO gets all categories. Other agents get basic-for-all + role-matched categories.
|
||||
*/
|
||||
function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
let assigned = 0;
|
||||
|
||||
// Map roles/departments to skill categories
|
||||
const roleCategories: Record<string, string[]> = {
|
||||
ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"],
|
||||
engineer: ["basic-for-all", "software-engineer"],
|
||||
designer: ["basic-for-all", "frontend-design"],
|
||||
marketing: ["basic-for-all", "marketing", "my-writing-style"],
|
||||
writer: ["basic-for-all", "document-process", "my-writing-style"],
|
||||
ios: ["basic-for-all", "software-engineer", "iOS-Dev"],
|
||||
mobile: ["basic-for-all", "software-engineer", "iOS-Dev"],
|
||||
video: ["basic-for-all", "Video-maker"],
|
||||
frontend: ["basic-for-all", "software-engineer", "frontend-design"],
|
||||
};
|
||||
|
||||
const normalizedRole = role.toLowerCase();
|
||||
const normalizedDept = department?.toLowerCase() ?? "";
|
||||
|
||||
// Find matching categories
|
||||
let categories = roleCategories[normalizedRole] ?? ["basic-for-all"];
|
||||
|
||||
// Also check department
|
||||
for (const [key, cats] of Object.entries(roleCategories)) {
|
||||
if (normalizedDept.includes(key) || normalizedRole.includes(key)) {
|
||||
categories = [...new Set([...categories, ...cats])];
|
||||
}
|
||||
}
|
||||
|
||||
// If CEO role, assign all
|
||||
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
|
||||
categories = roleCategories.ceo!;
|
||||
}
|
||||
|
||||
for (const skill of allSkills) {
|
||||
try {
|
||||
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
|
||||
if (meta.builtinCategory && categories.includes(meta.builtinCategory)) {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
return {
|
||||
importFromSource,
|
||||
importFromGitHub,
|
||||
@@ -737,5 +881,8 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
checkUpdateStatus,
|
||||
listRuntimeSkillEntries,
|
||||
scanWorkspaceSkills,
|
||||
seedBuiltinSkills,
|
||||
assignBasicSkillsToAgent,
|
||||
assignRoleSkillsToAgent,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type WorkerUiEvent = Extract<z.infer<typeof workerMessageSchema>, { type: "run-l
|
||||
interface WorkerServiceDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
@@ -105,8 +106,14 @@ export function createWorkerService({
|
||||
serviceName: "AgentCompany Orchestrator",
|
||||
});
|
||||
|
||||
worker.stdout?.on("data", (chunk) => logger.info(`[worker] ${String(chunk).trim()}`));
|
||||
worker.stderr?.on("data", (chunk) => logger.error(`[worker] ${String(chunk).trim()}`));
|
||||
worker.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk).trim();
|
||||
if (text) logger.debug(`[worker:stdout] ${text}`);
|
||||
});
|
||||
worker.stderr?.on("data", (chunk) => {
|
||||
const text = String(chunk).trim();
|
||||
if (text) logger.warn(`[worker:stderr] ${text}`);
|
||||
});
|
||||
|
||||
worker.on("exit", (code) => {
|
||||
if (isQuitting() || destroyed) return;
|
||||
|
||||
@@ -115,6 +115,26 @@ const api: DesktopApi = {
|
||||
getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input),
|
||||
getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input),
|
||||
getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input),
|
||||
listConfigRevisions: (companyId: string, agentId: string) =>
|
||||
ipcRenderer.invoke("agent-company:list-config-revisions", { companyId, agentId }),
|
||||
getConfigRevision: (companyId: string, revisionId: string) =>
|
||||
ipcRenderer.invoke("agent-company:get-config-revision", { companyId, revisionId }),
|
||||
rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) =>
|
||||
ipcRenderer.invoke("agent-company:rollback-config-revision", { companyId, agentId, revisionId }),
|
||||
listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) =>
|
||||
ipcRenderer.invoke("agent-company:list-run-events", { companyId, runId, ...opts }),
|
||||
getAgentPermissions: (agentId: string) =>
|
||||
ipcRenderer.invoke("agent-company:get-agent-permissions", { agentId }),
|
||||
setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke("agent-company:set-agent-permission", { companyId, agentId, permissionKey, enabled }),
|
||||
getHeartbeatPolicy: (agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke("agent-company:get-heartbeat-policy", { companyId, agentId }),
|
||||
setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("agent-company:set-heartbeat-policy", { companyId, agentId, ...policy }),
|
||||
getRuntimeState: (agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke("agent-company:get-runtime-state", { companyId, agentId }),
|
||||
setRuntimeState: (companyId: string, agentId: string, state: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("agent-company:set-runtime-state", { companyId, agentId, state }),
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||
|
||||
@@ -97,6 +97,21 @@ export const IPC_CHANNELS = {
|
||||
getApproval: "agent-company:get-approval",
|
||||
getConnectorDisabledModels: "agent-company:get-connector-disabled-models",
|
||||
setConnectorDisabledModels: "agent-company:set-connector-disabled-models",
|
||||
// Agent config revisions
|
||||
listConfigRevisions: "agent-company:list-config-revisions",
|
||||
getConfigRevision: "agent-company:get-config-revision",
|
||||
rollbackConfigRevision: "agent-company:rollback-config-revision",
|
||||
// Run events
|
||||
listRunEvents: "agent-company:list-run-events",
|
||||
// Agent permissions
|
||||
getAgentPermissions: "agent-company:get-agent-permissions",
|
||||
setAgentPermission: "agent-company:set-agent-permission",
|
||||
// Heartbeat policy
|
||||
getHeartbeatPolicy: "agent-company:get-heartbeat-policy",
|
||||
setHeartbeatPolicy: "agent-company:set-heartbeat-policy",
|
||||
// Runtime state
|
||||
getRuntimeState: "agent-company:get-runtime-state",
|
||||
setRuntimeState: "agent-company:set-runtime-state",
|
||||
} as const;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
@@ -660,6 +675,66 @@ export const entityGetterSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const listConfigRevisionsSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const getConfigRevisionSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
revisionId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const rollbackConfigRevisionSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
revisionId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const listRunEventsSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
runId: z.string().min(1),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export const getAgentPermissionsSchema = z.object({
|
||||
agentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const setAgentPermissionSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
permissionKey: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const heartbeatPolicySchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const setHeartbeatPolicySchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
enabled: z.boolean().optional(),
|
||||
intervalSec: z.number().optional(),
|
||||
cooldownSec: z.number().optional(),
|
||||
maxConcurrentRuns: z.number().optional(),
|
||||
wakeOnDemand: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const runtimeStateSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const setRuntimeStateSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
state: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export type DesktopResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: { code: string; message: string } };
|
||||
@@ -767,6 +842,16 @@ export type DesktopApi = {
|
||||
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>>;
|
||||
listConfigRevisions: (companyId: string, agentId: string) => Promise<DesktopResult<unknown[]>>;
|
||||
getConfigRevision: (companyId: string, revisionId: string) => Promise<DesktopResult<unknown>>;
|
||||
rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) => Promise<DesktopResult<boolean>>;
|
||||
listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) => Promise<DesktopResult<unknown[]>>;
|
||||
getAgentPermissions: (agentId: string) => Promise<DesktopResult<unknown>>;
|
||||
setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) => Promise<DesktopResult<boolean>>;
|
||||
getHeartbeatPolicy: (agentId: string, companyId: string) => Promise<DesktopResult<unknown>>;
|
||||
setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record<string, unknown>) => Promise<DesktopResult<boolean>>;
|
||||
getRuntimeState: (agentId: string, companyId: string) => Promise<DesktopResult<unknown>>;
|
||||
setRuntimeState: (companyId: string, agentId: string, state: Record<string, unknown>) => Promise<DesktopResult<boolean>>;
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
windowMinimize: () => Promise<void>;
|
||||
|
||||
@@ -7,7 +7,12 @@ import type { AppLocale } from "./locale";
|
||||
*/
|
||||
export function getLocalePromptInstruction(locale: AppLocale): string {
|
||||
if (locale === "zh") {
|
||||
return "重要:你必须全程使用中文进行所有沟通、回复、任务描述、评论和消息。所有输出内容必须是中文。";
|
||||
return [
|
||||
"重要语言要求:你必须全程使用中文进行所有沟通。",
|
||||
"这包括:任务描述、评论、消息、报告、文档、审批、通知 — 所有输出必须是中文。",
|
||||
"即使系统提示和 API 文档是英文的,你的所有回复和创建的内容必须是中文。",
|
||||
"不要使用英文回复,除非是代码、变量名、API 端点等技术术语。",
|
||||
].join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
+2
-1
@@ -77,7 +77,8 @@ export type ApprovalType =
|
||||
| "production_deploy"
|
||||
| "incident_escalation"
|
||||
| "social_post"
|
||||
| "deliverable_review";
|
||||
| "deliverable_review"
|
||||
| "budget_override";
|
||||
export type RunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
|
||||
Reference in new issue
Block a user