large update
This commit is contained in:
1 parent
2ed952f19c
commit
65e85135c1
111 files changed
+3936
-2933
No files matched your search
+306
-3
@@ -432,12 +432,20 @@ export class AgentApiServer {
|
||||
} catch { /* deliverable review interception is best-effort; fall through to normal flow */ }
|
||||
}
|
||||
|
||||
// Validate status transition
|
||||
const effectiveStatus = update.status ?? task.status;
|
||||
const VALID_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
|
||||
if (update.status && !VALID_STATUSES.has(update.status)) {
|
||||
this.json(res, 400, { error: `Invalid task status: ${update.status}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
title: update.title ?? task.title,
|
||||
description: update.description ?? task.description,
|
||||
status: update.status ?? task.status,
|
||||
status: effectiveStatus,
|
||||
priority: update.priority ?? task.priority,
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
@@ -447,6 +455,11 @@ export class AgentApiServer {
|
||||
};
|
||||
this.db.saveTask(input as Parameters<typeof this.db.saveTask>[0]);
|
||||
|
||||
// Apply status side-effect timestamps via advanceTaskStatus if status changed
|
||||
if (update.status && update.status !== task.status) {
|
||||
this.db.advanceTaskStatus(task.id, update.status);
|
||||
}
|
||||
|
||||
if (update.comment) {
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
this.db.addComment({
|
||||
@@ -861,6 +874,31 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
|
||||
const existingAgents = this.db.listSnapshot().agents.filter(
|
||||
(a) => a.companyId === companyId && a.status !== "terminated",
|
||||
);
|
||||
const duplicateByName = existingAgents.find(
|
||||
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
|
||||
);
|
||||
if (duplicateByName) {
|
||||
this.json(res, 409, { error: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.` });
|
||||
return;
|
||||
}
|
||||
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
|
||||
const pendingHires = this.db.listSnapshot().approvals.filter(
|
||||
(a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending",
|
||||
);
|
||||
for (const pending of pendingHires) {
|
||||
try {
|
||||
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
|
||||
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
|
||||
this.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
|
||||
return;
|
||||
}
|
||||
} catch { /* invalid payloadJson, skip */ }
|
||||
}
|
||||
|
||||
const managerLabel = input.reportsTo
|
||||
? (() => {
|
||||
try {
|
||||
@@ -975,6 +1013,16 @@ export class AgentApiServer {
|
||||
const chain = this.db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
|
||||
this.onWakeAgent(chain[0].id, task.companyId, "comment");
|
||||
wokenForComment.add(chain[0].id);
|
||||
}
|
||||
|
||||
// @mention detection: wake any mentioned agents not already woken
|
||||
const mentionedIds = this.db.findMentionedAgentIds(task.companyId, input.body);
|
||||
for (const mentionedId of mentionedIds) {
|
||||
if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) {
|
||||
this.onWakeAgent(mentionedId, task.companyId, "comment_mentioned");
|
||||
wokenForComment.add(mentionedId);
|
||||
}
|
||||
}
|
||||
|
||||
this.json(res, 201, comment);
|
||||
@@ -1115,6 +1163,88 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
|
||||
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
|
||||
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
|
||||
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
const comments = this.db.listApprovalComments(params.approvalId!, approval.companyId);
|
||||
this.json(res, 200, comments);
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => {
|
||||
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
|
||||
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
|
||||
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
const data = body as Record<string, unknown>;
|
||||
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
|
||||
if (!commentBody) { this.json(res, 400, { error: "Comment body is required" }); return; }
|
||||
const commentId = this.db.addApprovalComment({
|
||||
companyId: approval.companyId,
|
||||
approvalId: approval.id,
|
||||
authorAgentId: token.agentId ?? null,
|
||||
body: commentBody,
|
||||
});
|
||||
// Wake mentioned agents in comment
|
||||
const mentionedIds = this.db.findMentionedAgentIds(approval.companyId, commentBody);
|
||||
for (const mentionedId of mentionedIds) {
|
||||
if (mentionedId !== token.agentId) {
|
||||
this.onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned");
|
||||
}
|
||||
}
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { id: commentId });
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
|
||||
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
|
||||
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
|
||||
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
if (approval.state !== "revision_requested") {
|
||||
this.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" });
|
||||
return;
|
||||
}
|
||||
if (token.agentId !== approval.requestedByAgentId) {
|
||||
this.json(res, 403, { error: "Only the requesting agent can resubmit this approval" });
|
||||
return;
|
||||
}
|
||||
this.db.resubmitApproval(approval.id, approval.companyId);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { resubmitted: true });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
|
||||
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
|
||||
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
|
||||
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
const taskIds = this.db.listTasksForApproval(params.approvalId!);
|
||||
this.json(res, 200, taskIds);
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => {
|
||||
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
|
||||
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
|
||||
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
const data = body as Record<string, unknown>;
|
||||
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
|
||||
if (taskIds.length === 0) { this.json(res, 400, { error: "taskIds array is required" }); return; }
|
||||
for (const taskId of taskIds) {
|
||||
if (!this.db.belongsToCompany("tasks", taskId, token.companyId)) {
|
||||
this.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
|
||||
}
|
||||
}
|
||||
this.db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { linked: taskIds.length });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
|
||||
const documentId = params.documentId;
|
||||
if (!documentId) { this.json(res, 400, { error: "Missing documentId" }); return; }
|
||||
const revisions = this.db.listDocumentRevisions(documentId, token.companyId);
|
||||
this.json(res, 200, revisions);
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
@@ -1506,6 +1636,180 @@ export class AgentApiServer {
|
||||
this.json(res, 200, docs);
|
||||
});
|
||||
|
||||
this.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const documentId = params.documentId;
|
||||
if (!documentId) {
|
||||
this.json(res, 400, { error: "Missing documentId" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId);
|
||||
if (!doc) {
|
||||
this.json(res, 404, { error: "Document not found in this company" });
|
||||
return;
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
|
||||
|
||||
// Conflict detection: if baseRevisionId provided, verify it matches current
|
||||
if (typeof data.baseRevisionId === "string") {
|
||||
const currentRevId = this.db.getDocumentLatestRevisionId(documentId);
|
||||
if (currentRevId && data.baseRevisionId !== currentRevId) {
|
||||
this.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
const newStatus = String(data.status);
|
||||
if (!VALID_STATUSES.has(newStatus)) {
|
||||
this.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
|
||||
return;
|
||||
}
|
||||
this.db.updateDocumentStatus(documentId, token.companyId, newStatus);
|
||||
}
|
||||
|
||||
// Content update → create revision for audit trail
|
||||
if (data.content !== undefined) {
|
||||
const newContent = String(data.content);
|
||||
this.db.createDocumentRevision({
|
||||
documentId,
|
||||
companyId: token.companyId,
|
||||
body: newContent,
|
||||
changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null,
|
||||
createdByAgentId: token.agentId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Field updates (title, reviewer, tags, etc.)
|
||||
const reviewerAgentId = data.reviewerAgentId !== undefined
|
||||
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
|
||||
: doc.reviewerAgentId;
|
||||
if (data.reviewerAgentId !== undefined && reviewerAgentId && !this.db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
|
||||
this.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
|
||||
return;
|
||||
}
|
||||
this.db.saveDocument({
|
||||
id: documentId,
|
||||
companyId: token.companyId,
|
||||
type: data.type ? String(data.type) : doc.type,
|
||||
title: data.title ? String(data.title) : doc.title,
|
||||
content: data.content !== undefined ? String(data.content) : doc.content,
|
||||
authorAgentId: doc.authorAgentId,
|
||||
reviewerAgentId,
|
||||
projectId: doc.projectId,
|
||||
goalId: doc.goalId,
|
||||
status: data.status ? String(data.status) : doc.status,
|
||||
tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { updated: true, latestRevisionId: this.db.getDocumentLatestRevisionId(documentId) });
|
||||
});
|
||||
|
||||
// ── Work Products ──
|
||||
|
||||
this.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
|
||||
const products = this.db.listWorkProductsForTask(task.id, task.companyId);
|
||||
this.json(res, 200, products);
|
||||
} catch { this.json(res, 404, { error: "Task not found" }); }
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
|
||||
const data = body as Record<string, unknown>;
|
||||
if (!data.type || !data.provider || !data.title) {
|
||||
this.json(res, 400, { error: "type, provider, and title are required" }); return;
|
||||
}
|
||||
const id = this.db.createWorkProduct({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
projectId: typeof data.projectId === "string" ? data.projectId : task.projectId,
|
||||
type: String(data.type),
|
||||
provider: String(data.provider),
|
||||
externalId: typeof data.externalId === "string" ? data.externalId : null,
|
||||
title: String(data.title),
|
||||
url: typeof data.url === "string" ? data.url : null,
|
||||
status: typeof data.status === "string" ? data.status : "active",
|
||||
reviewState: typeof data.reviewState === "string" ? data.reviewState : "none",
|
||||
isPrimary: data.isPrimary === true,
|
||||
summary: typeof data.summary === "string" ? data.summary : null,
|
||||
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
|
||||
createdByRunId: token.runId ?? null,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { id });
|
||||
} catch { this.json(res, 404, { error: "Task not found" }); }
|
||||
});
|
||||
|
||||
this.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
|
||||
const wpId = params.workProductId;
|
||||
if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; }
|
||||
const data = body as Record<string, unknown>;
|
||||
this.db.updateWorkProduct(wpId, token.companyId, data);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { updated: true });
|
||||
});
|
||||
|
||||
this.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
|
||||
const wpId = params.workProductId;
|
||||
if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; }
|
||||
this.db.deleteWorkProduct(wpId, token.companyId);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { deleted: true });
|
||||
});
|
||||
|
||||
// ── Task-Document Links ──
|
||||
|
||||
this.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
|
||||
const links = this.db.listDocumentsForTask(task.id, task.companyId);
|
||||
this.json(res, 200, links);
|
||||
} catch { this.json(res, 404, { error: "Task not found" }); }
|
||||
});
|
||||
|
||||
this.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
|
||||
const key = String(params.key ?? "").trim().toLowerCase();
|
||||
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
|
||||
this.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
|
||||
}
|
||||
const data = body as Record<string, unknown>;
|
||||
const documentId = typeof data.documentId === "string" ? data.documentId : null;
|
||||
if (!documentId) { this.json(res, 400, { error: "documentId is required" }); return; }
|
||||
if (!this.db.belongsToCompany("documents", documentId, token.companyId)) {
|
||||
this.json(res, 400, { error: "Document does not belong to this company" }); return;
|
||||
}
|
||||
const linkId = this.db.linkDocumentToTask(task.id, documentId, task.companyId, key);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { id: linkId, key });
|
||||
} catch { this.json(res, 404, { error: "Task not found" }); }
|
||||
});
|
||||
|
||||
this.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
|
||||
const key = String(params.key ?? "").trim().toLowerCase();
|
||||
this.db.unlinkDocumentFromTask(task.id, key, task.companyId);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { unlinked: true });
|
||||
} catch { this.json(res, 404, { error: "Task not found" }); }
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
@@ -1597,7 +1901,7 @@ export class AgentApiServer {
|
||||
// Log but skip invalid meeting action items rather than failing the whole request
|
||||
continue;
|
||||
}
|
||||
const taskId = this.db.saveTask({
|
||||
this.db.saveTask({
|
||||
companyId: token.companyId,
|
||||
title: item.title,
|
||||
description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`,
|
||||
@@ -1608,7 +1912,6 @@ export class AgentApiServer {
|
||||
if (item.assigneeAgentId) {
|
||||
this.onWakeAgent(item.assigneeAgentId, token.companyId, "assignment");
|
||||
}
|
||||
void taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+184
-27
@@ -73,6 +73,126 @@ interface AutomationDependencies {
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
|
||||
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
|
||||
product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"],
|
||||
marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"],
|
||||
sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"],
|
||||
hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"],
|
||||
finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"],
|
||||
design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"],
|
||||
research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"],
|
||||
operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"],
|
||||
customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"],
|
||||
legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"],
|
||||
executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"],
|
||||
};
|
||||
|
||||
function parseDocumentIdsFromPayload(payloadJson: string | null): string[] {
|
||||
if (!payloadJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
const ids = parsed.documentIds;
|
||||
if (Array.isArray(ids)) return ids.filter((id): id is string => typeof id === "string");
|
||||
} catch { /* invalid JSON */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function scoreSkillAgentMatch(
|
||||
skill: { name: string; description: string | null; markdown: string },
|
||||
agent: { role: string; department: string | null; title: string | null; capabilities: string },
|
||||
): number {
|
||||
const agentText = [
|
||||
agent.role,
|
||||
agent.department ?? "",
|
||||
agent.title ?? "",
|
||||
agent.capabilities,
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
const skillText = [
|
||||
skill.name,
|
||||
skill.description ?? "",
|
||||
skill.markdown.slice(0, 500),
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
const dept = agent.department ?? "";
|
||||
const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? [];
|
||||
for (const keyword of deptKeywords) {
|
||||
if (skillText.includes(keyword)) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
const agentWords = agentText
|
||||
.replace(/[^a-z0-9\s-]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 3);
|
||||
for (const word of agentWords) {
|
||||
if (skillText.includes(word)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Executives need awareness of all domains
|
||||
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"];
|
||||
const roleLower = agent.role.toLowerCase();
|
||||
if (executiveRoles.some((r) => roleLower.includes(r))) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function autoAssignSkillsToAgent(
|
||||
db: AppDatabase,
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): number {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent || agent.companyId !== companyId) return 0;
|
||||
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
if (allSkills.length === 0) return 0;
|
||||
|
||||
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
|
||||
|
||||
const scored = allSkills
|
||||
.filter((skill) => !existingAssignments.has(skill.id))
|
||||
.map((skill) => ({
|
||||
skill,
|
||||
score: scoreSkillAgentMatch(skill, agent),
|
||||
}))
|
||||
.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 {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
logger.info(
|
||||
`[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (assigned === 0) {
|
||||
logger.info(
|
||||
`[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`,
|
||||
);
|
||||
}
|
||||
|
||||
return assigned;
|
||||
}
|
||||
|
||||
interface RunFinishedEvent {
|
||||
status: RunStatus;
|
||||
summary: string | null;
|
||||
@@ -202,10 +322,9 @@ export function createAutomationService({
|
||||
}
|
||||
|
||||
function matchesConditions(
|
||||
rule: { conditionsJson: string; sourceDepartment: string | null; targetDepartment: string | null },
|
||||
rule: { conditionsJson: string; sourceDepartment: string | null },
|
||||
ctx: AutomationEventContext,
|
||||
) {
|
||||
void rule.targetDepartment;
|
||||
return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx);
|
||||
}
|
||||
|
||||
@@ -1067,11 +1186,30 @@ export function createAutomationService({
|
||||
entityId: agent.id,
|
||||
detail: `${agent.name} hire approved and activated.`,
|
||||
});
|
||||
|
||||
const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id);
|
||||
if (skillsAssigned > 0) {
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "skills.auto_assigned",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`,
|
||||
});
|
||||
}
|
||||
|
||||
dispatchAutomation("hire_approved", {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
wakeAgentIfPossible(agent.id, companyId, "assignment");
|
||||
|
||||
const ceo = db.getCeoForCompany(companyId);
|
||||
if (ceo && ceo.id !== agent.id) {
|
||||
wakeAgentIfPossible(ceo.id, companyId, "assignment");
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
notify({
|
||||
title: "Agent Hired",
|
||||
@@ -1086,39 +1224,58 @@ export function createAutomationService({
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "deliverable_review" && approval.relatedTaskId) {
|
||||
if (approval.type === "deliverable_review") {
|
||||
try {
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
// Re-enable heartbeat for the producing agent now that the user has decided
|
||||
if (task.assigneeAgentId) {
|
||||
// Update linked document statuses from payloadJson
|
||||
const documentIds = parseDocumentIdsFromPayload(approval.payloadJson);
|
||||
const docStatus = decision === "approved" ? "approved" : decision === "rejected" ? "draft" : "in_review";
|
||||
for (const docId of documentIds) {
|
||||
db.updateDocumentStatus(docId, companyId, docStatus);
|
||||
}
|
||||
|
||||
if (approval.relatedTaskId) {
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
if (task.assigneeAgentId) {
|
||||
try {
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* agent lookup non-fatal */ }
|
||||
}
|
||||
if (decision === "approved") {
|
||||
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "done");
|
||||
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
|
||||
} else if (decision === "rejected") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
} else if (decision === "revision_requested") {
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
} else if (approval.requestedByAgentId) {
|
||||
// Document-only review (no task) — resume the producing agent
|
||||
try {
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
const agent = db.getAgent(approval.requestedByAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
if (decision === "approved") {
|
||||
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "done");
|
||||
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
|
||||
publishDomainChanged();
|
||||
} else if (decision === "rejected") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
} catch { /* agent lookup non-fatal */ }
|
||||
if (decision !== "approved") {
|
||||
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
|
||||
}
|
||||
publishDomainChanged();
|
||||
} else if (decision === "revision_requested") {
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
} catch {
|
||||
// Deliverable review follow-up is best-effort.
|
||||
// Deliverable review follow-up is non-fatal — approval state is already committed.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function bootstrapApplication({
|
||||
}: BootstrapApplicationDependencies) {
|
||||
await app.whenReady();
|
||||
log.initialize();
|
||||
await prepareHostEnvironment({ iconPath, logger: log });
|
||||
await prepareHostEnvironment({ logger: log });
|
||||
|
||||
const db = appDatabaseFactory(profileDir);
|
||||
db.init();
|
||||
@@ -280,6 +280,43 @@ export async function bootstrapApplication({
|
||||
|
||||
serviceGraph.getOrchestrationService()?.resumeQueuedRuns();
|
||||
|
||||
// Resume tasks that were interrupted by a clean shutdown.
|
||||
// The worker-service destroy() marks running/queued runs as "interrupted" with
|
||||
// summary "Application shut down." but does NOT reset the tasks, so agents
|
||||
// never re-wake to continue them. Fix: reset these tasks back to "todo" and
|
||||
// wake the assigned agents via heartbeat.
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
const shutdownRuns = snapshot.runs.filter(
|
||||
(r) => r.status === "interrupted" &&
|
||||
(r.summary === "Application shut down." || r.summary === "Worker process crashed."),
|
||||
);
|
||||
if (shutdownRuns.length > 0) {
|
||||
const resumedAgents = new Set<string>();
|
||||
for (const run of shutdownRuns) {
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
if (task.status === "in_progress" || task.status === "blocked") {
|
||||
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
}
|
||||
if (!resumedAgents.has(run.agentId)) {
|
||||
resumedAgents.add(run.agentId);
|
||||
wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
|
||||
}
|
||||
} catch {
|
||||
// Task or agent may have been deleted — skip
|
||||
}
|
||||
}
|
||||
if (resumedAgents.size > 0) {
|
||||
log.info(`[bootstrap] Resumed ${shutdownRuns.length} interrupted task(s) across ${resumedAgents.size} agent(s) after clean shutdown recovery.`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`[bootstrap] Shutdown recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
apiServer,
|
||||
|
||||
@@ -29,13 +29,12 @@ export function createBrowserActionService({
|
||||
throw new Error(`Browser action ${actionId} is ${action.status} and cannot be dispatched.`);
|
||||
}
|
||||
if (action.approvalId) {
|
||||
const approval = (() => {
|
||||
try {
|
||||
return db.getApprovalRecord(action.approvalId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
let approval: ReturnType<typeof db.getApprovalRecord> | null = null;
|
||||
try {
|
||||
approval = db.getApprovalRecord(action.approvalId);
|
||||
} catch {
|
||||
// Record not found
|
||||
}
|
||||
if (!approval || approval.companyId !== companyId) {
|
||||
throw new Error(`Browser action approval ${action.approvalId} does not belong to company ${companyId}.`);
|
||||
}
|
||||
|
||||
@@ -10,49 +10,11 @@ function escapeAppleScript(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
|
||||
switch (connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex Login",
|
||||
shellCommand: `${quoteShell(command)} login`,
|
||||
intro: "AgentCompany is opening the Codex login flow.",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the Claude Code login flow.",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini CLI Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildTerminalCommand(connectorId: ConnectorId, command: string, cwd: string) {
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(spec.intro)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(cwd)}`,
|
||||
spec.shellCommand,
|
||||
"echo",
|
||||
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return { title: spec.title, script };
|
||||
}
|
||||
|
||||
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
|
||||
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
|
||||
const { script } = buildTerminalCommand(connectorId, command, targetCwd);
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared terminal spawning — used by both auth and generic commands */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function spawnTerminalWithScript(script: string, title: string, cwd: string): boolean {
|
||||
if (process.platform === "darwin") {
|
||||
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
|
||||
const child = spawn("osascript", ["-e", appleScript], {
|
||||
@@ -64,18 +26,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
const winScript = [
|
||||
`@echo off`,
|
||||
`echo ${spec.intro}`,
|
||||
`echo.`,
|
||||
`cd /d "${targetCwd}"`,
|
||||
spec.shellCommand,
|
||||
`echo.`,
|
||||
`echo You can close this terminal after the login flow completes.`,
|
||||
`pause`,
|
||||
].join(" & ");
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", winScript], {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
shell: false,
|
||||
@@ -84,6 +35,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux — try multiple terminal emulators
|
||||
const linuxCandidates: Array<[string, string[]]> = [
|
||||
["x-terminal-emulator", ["-e", "sh", "-lc", script]],
|
||||
["gnome-terminal", ["--", "sh", "-lc", script]],
|
||||
@@ -110,5 +62,101 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No supported terminal application was found for connector login.");
|
||||
throw new Error("No supported terminal application was found.");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Connector auth terminal (existing API — unchanged behavior) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
|
||||
switch (connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex Login",
|
||||
shellCommand: `${quoteShell(command)} login`,
|
||||
intro: "AgentCompany is opening the Codex login flow.",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code Login",
|
||||
shellCommand: `${quoteShell(command)} auth login`,
|
||||
intro: "AgentCompany is opening the Claude Code login flow.",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini CLI Login",
|
||||
shellCommand: `${quoteShell(command)}`,
|
||||
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
|
||||
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows: build a CMD-compatible script directly
|
||||
const winScript = [
|
||||
`@echo off`,
|
||||
`echo ${spec.intro}`,
|
||||
`echo.`,
|
||||
`cd /d "${targetCwd}"`,
|
||||
spec.shellCommand.replace(/'/g, ""), // Remove shell quotes for CMD
|
||||
`echo.`,
|
||||
`echo You can close this terminal after the login flow completes.`,
|
||||
`pause`,
|
||||
].join(" & ");
|
||||
return spawnTerminalWithScript(winScript, spec.title, targetCwd);
|
||||
}
|
||||
|
||||
// Unix: build a shell script
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(spec.intro)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(targetCwd)}`,
|
||||
spec.shellCommand,
|
||||
"echo",
|
||||
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return spawnTerminalWithScript(script, spec.title, targetCwd);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Generic terminal command (for install flows, etc.) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openGenericTerminal(input: { title: string; command: string }) {
|
||||
const cwd = process.env.HOME || homedir() || process.cwd();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const winScript = [
|
||||
`@echo off`,
|
||||
`echo AgentCompany - ${input.title}`,
|
||||
`echo.`,
|
||||
`cd /d "${cwd}"`,
|
||||
input.command,
|
||||
`echo.`,
|
||||
`echo Done. You can close this terminal.`,
|
||||
`pause`,
|
||||
].join(" & ");
|
||||
return spawnTerminalWithScript(winScript, input.title, cwd);
|
||||
}
|
||||
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(`AgentCompany — ${input.title}`)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(cwd)}`,
|
||||
input.command,
|
||||
"echo",
|
||||
`echo ${quoteShell("Done. You can close this terminal.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return spawnTerminalWithScript(script, input.title, cwd);
|
||||
}
|
||||
@@ -103,27 +103,51 @@ export function createConnectorHealthService({
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
|
||||
]);
|
||||
|
||||
const authRequired = result.checks.some((check) =>
|
||||
/auth_required|login_required/i.test(check.code) ||
|
||||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
|
||||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? ""),
|
||||
// Check if the CLI command itself is missing before inspecting auth status.
|
||||
// Without this, info-level hints like "run `gemini auth login`" would wrongly
|
||||
// trigger auth_required even when the CLI binary doesn't exist.
|
||||
const commandMissing = result.checks.some((check) =>
|
||||
/command_unresolvable|command_not_found/i.test(check.code) && check.level === "error",
|
||||
);
|
||||
|
||||
// Only consider warn/error-level checks for auth detection.
|
||||
// Info-level checks (like gemini_api_key_missing) carry advisory hints
|
||||
// that match auth patterns but do NOT indicate actual auth failure.
|
||||
const authRequired = !commandMissing && result.checks.some((check) =>
|
||||
check.level !== "info" && (
|
||||
/auth_required|login_required/i.test(check.code) ||
|
||||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
|
||||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? "")
|
||||
),
|
||||
);
|
||||
let authState: "ok" | "required" | "error" = "ok";
|
||||
if (authRequired) {
|
||||
if (commandMissing) {
|
||||
authState = "error";
|
||||
} else if (authRequired) {
|
||||
authState = "required";
|
||||
} else if (result.status === "fail") {
|
||||
authState = "error";
|
||||
}
|
||||
const version = await parseVersion(connector.command);
|
||||
const version = commandMissing ? null : await parseVersion(connector.command);
|
||||
|
||||
// claude_compatible requires explicit env bindings (API key + base URL) to be
|
||||
// considered ready. Without them, it just uses the official claude CLI auth
|
||||
// which is not what the user intends for a compatible provider.
|
||||
const needsCompatConfig = id.startsWith("claude_compat")
|
||||
&& !connector.envBindingText.includes("ANTHROPIC_BASE_URL");
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status:
|
||||
authRequired
|
||||
? "auth_required"
|
||||
: result.status === "pass" || result.status === "warn"
|
||||
? "ready"
|
||||
: "error",
|
||||
commandMissing
|
||||
? "not_installed"
|
||||
: needsCompatConfig
|
||||
? "auth_required"
|
||||
: authRequired
|
||||
? "auth_required"
|
||||
: result.status === "pass" || result.status === "warn"
|
||||
? "ready"
|
||||
: "error",
|
||||
authState,
|
||||
version,
|
||||
lastError:
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* Deterministic connector-matching heuristic.
|
||||
*
|
||||
* Maps task types to the best available connector based on a static
|
||||
* preference table. Provides fallback logic when preferred connectors
|
||||
* are offline or unavailable.
|
||||
*/
|
||||
import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConnectorMatchInput {
|
||||
interface ConnectorMatchInput {
|
||||
taskType: TaskType;
|
||||
agentRole: string;
|
||||
agentDepartment: string | null;
|
||||
availableConnectors: Array<{
|
||||
id: ConnectorId;
|
||||
status: ConnectorStatus;
|
||||
@@ -22,30 +9,20 @@ export interface ConnectorMatchInput {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ConnectorMatchResult {
|
||||
interface ConnectorMatchResult {
|
||||
recommendedConnectorId: ConnectorId;
|
||||
fallbackConnectorId: ConnectorId | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static preference tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For each task type, connectors ordered from most preferred to least.
|
||||
*/
|
||||
export const TASK_TYPE_PREFERENCE: Record<TaskType, ConnectorId[]> = {
|
||||
code: ["claude_local", "codex_local", "gemini_local"],
|
||||
research: ["gemini_local", "claude_local", "codex_local"],
|
||||
content: ["claude_local", "gemini_local", "codex_local"],
|
||||
management: ["claude_local", "gemini_local", "codex_local"],
|
||||
general: ["claude_local", "codex_local", "gemini_local"],
|
||||
code: ["claude_local", "codex_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local"],
|
||||
research: ["gemini_local", "claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "codex_local"],
|
||||
content: ["claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local", "codex_local"],
|
||||
management: ["claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local", "codex_local"],
|
||||
general: ["claude_local", "codex_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps department strings to the most likely task type for that department.
|
||||
*/
|
||||
export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
|
||||
engineering: "code",
|
||||
research: "research",
|
||||
@@ -61,28 +38,37 @@ export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
|
||||
legal: "management",
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable capability descriptions per connector.
|
||||
*/
|
||||
export const CONNECTOR_STRENGTHS: Record<ConnectorId, string> = {
|
||||
claude_local:
|
||||
"Best for code generation, content writing, complex reasoning. Supports session resume, cost tracking.",
|
||||
claude_compatible:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_2:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_3:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_4:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_5:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_6:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_7:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_8:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_9:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
claude_compat_10:
|
||||
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
|
||||
codex_local:
|
||||
"Optimized for code editing and generation. Supports session resume, structured output.",
|
||||
gemini_local:
|
||||
"Strong at research, analysis, and large-context tasks. Good for data processing.",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const READY_STATUSES: Set<ConnectorStatus> = new Set(["ready"]);
|
||||
|
||||
/**
|
||||
* Infer the most likely task type from a department name.
|
||||
* Returns 'general' for null, undefined, or unrecognised departments.
|
||||
*/
|
||||
export function inferTaskType(
|
||||
department: string | null | undefined,
|
||||
): TaskType {
|
||||
@@ -91,25 +77,10 @@ export function inferTaskType(
|
||||
return ROLE_DEPARTMENT_HINTS[lower] ?? "general";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of a connector's strengths.
|
||||
*/
|
||||
export function getConnectorStrengths(connectorId: ConnectorId): string {
|
||||
return CONNECTOR_STRENGTHS[connectorId] ?? "General-purpose AI connector.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best connector for a given task type and set of available
|
||||
* connectors. Falls back gracefully when preferred connectors are offline.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Filter connectors to those with "ready" status.
|
||||
* 2. Walk the preference list for the given task type.
|
||||
* 3. Return the first ready match as recommended.
|
||||
* 4. Set fallback to the next *different* ready connector in the list.
|
||||
* 5. If no connectors are ready, use the first available connector.
|
||||
* 6. If no connectors at all, default to 'claude_local'.
|
||||
*/
|
||||
export function matchConnector(input: ConnectorMatchInput): ConnectorMatchResult {
|
||||
const { taskType, availableConnectors } = input;
|
||||
const prefList = TASK_TYPE_PREFERENCE[taskType] ?? TASK_TYPE_PREFERENCE.general;
|
||||
|
||||
@@ -87,6 +87,256 @@ const CONNECTORS: ConnectorDefinition[] = [
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compatible",
|
||||
label: "Claude Code Compatible API",
|
||||
description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_2",
|
||||
label: "Claude Code Compatible API (2)",
|
||||
description: "Second Claude Code CLI compatible API slot for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_3",
|
||||
label: "Claude Code Compatible API (3)",
|
||||
description: "Third Claude Code CLI compatible API slot for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_4",
|
||||
label: "Claude Code Compatible API (4)",
|
||||
description: "Claude Code CLI compatible API slot 4 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_5",
|
||||
label: "Claude Code Compatible API (5)",
|
||||
description: "Claude Code CLI compatible API slot 5 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_6",
|
||||
label: "Claude Code Compatible API (6)",
|
||||
description: "Claude Code CLI compatible API slot 6 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_7",
|
||||
label: "Claude Code Compatible API (7)",
|
||||
description: "Claude Code CLI compatible API slot 7 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_8",
|
||||
label: "Claude Code Compatible API (8)",
|
||||
description: "Claude Code CLI compatible API slot 8 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_9",
|
||||
label: "Claude Code Compatible API (9)",
|
||||
description: "Claude Code CLI compatible API slot 9 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "claude_compat_10",
|
||||
label: "Claude Code Compatible API (10)",
|
||||
description: "Claude Code CLI compatible API slot 10 for a third-party provider.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: null,
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
nativeContextManagement: true,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "gemini_local",
|
||||
label: "Gemini CLI",
|
||||
|
||||
+428
-88
@@ -580,9 +580,9 @@ export class AppDatabase {
|
||||
...companyArgs,
|
||||
),
|
||||
tasks: this.readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview) })) as TaskRecord[],
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
|
||||
approvals: this.readAll<ApprovalRecord>(
|
||||
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
@@ -635,13 +635,13 @@ export class AppDatabase {
|
||||
actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
documents: this.readAll<SqliteRow>(
|
||||
`select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at from documents${companyWhere} order by updated_at desc`,
|
||||
`select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content,
|
||||
authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id,
|
||||
goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status,
|
||||
tagsJson: r.tags_json, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
knowledgeBase: this.readAll<SqliteRow>(
|
||||
`select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`,
|
||||
@@ -1131,11 +1131,14 @@ export class AppDatabase {
|
||||
getTask(id: string): TaskRecord {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
)
|
||||
.get(id) as TaskRecord | undefined;
|
||||
if (!row) throw new Error(`Task not found: ${id}`);
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
row.startedAt = row.startedAt ?? null;
|
||||
row.completedAt = row.completedAt ?? null;
|
||||
row.cancelledAt = row.cancelledAt ?? null;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -2124,90 +2127,62 @@ export class AppDatabase {
|
||||
).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[];
|
||||
}
|
||||
|
||||
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string): void {
|
||||
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record<string, unknown>): void {
|
||||
const ts = nowIso();
|
||||
this.transaction(() => {
|
||||
// D-07: Per-agent scope -- check for existing queued or deferred wake for this agent
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail, coalesced_count
|
||||
`SELECT id, trigger_detail, coalesced_count, context_snapshot
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as
|
||||
| { id: string; trigger_detail: string | null; coalesced_count: number }
|
||||
| { id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
// D-06: Coalesce -- append trigger to JSON array
|
||||
const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail
|
||||
? JSON.parse(existing.trigger_detail)
|
||||
: [];
|
||||
triggers.push({ trigger, at: ts, detail });
|
||||
// Merge context snapshots (Paperclip pattern: incoming overwrites existing, preserve commentId)
|
||||
let mergedContext: string | null = existing.context_snapshot;
|
||||
if (context) {
|
||||
const prev: Record<string, unknown> = existing.context_snapshot ? JSON.parse(existing.context_snapshot) : {};
|
||||
const merged = { ...prev, ...context };
|
||||
mergedContext = JSON.stringify(merged);
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests
|
||||
SET trigger_detail = ?,
|
||||
coalesced_count = coalesced_count + 1,
|
||||
context_snapshot = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(JSON.stringify(triggers), ts, existing.id);
|
||||
.run(JSON.stringify(triggers), mergedContext, ts, existing.id);
|
||||
} else {
|
||||
// New wakeup request
|
||||
const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]);
|
||||
const contextJson = context ? JSON.stringify(context) : null;
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO wakeup_requests
|
||||
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
|
||||
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, context_snapshot, requested_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, ts, ts, ts);
|
||||
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, contextJson, ts, ts, ts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
claimWakeupRequest(agentId: string, companyId: string, runId: string): { id: string; triggerDetail: string | null } | null {
|
||||
let result: { id: string; triggerDetail: string | null } | null = null;
|
||||
this.transaction(() => {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail AS triggerDetail
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'queued'
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as { id: string; triggerDetail: string | null } | undefined;
|
||||
if (!row) return;
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'claimed', claimed_at = ?, run_id = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.run(ts, runId, ts, row.id);
|
||||
result = row;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
completeWakeupRequest(wakeupId: string): void {
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'completed', finished_at = ?, updated_at = ? WHERE id = ? AND status = 'claimed'`,
|
||||
)
|
||||
.run(ts, ts, wakeupId);
|
||||
}
|
||||
|
||||
deferWakeupRequest(agentId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'deferred', updated_at = ? WHERE agent_id = ? AND company_id = ? AND status = 'queued'`,
|
||||
)
|
||||
.run(ts, agentId, companyId);
|
||||
private mergeContextSnapshots(existingJson: string | null, incomingJson: string | null): string | null {
|
||||
if (!incomingJson) return existingJson;
|
||||
if (!existingJson) return incomingJson;
|
||||
const existing: Record<string, unknown> = JSON.parse(existingJson);
|
||||
const incoming: Record<string, unknown> = JSON.parse(incomingJson);
|
||||
return JSON.stringify({ ...existing, ...incoming });
|
||||
}
|
||||
|
||||
promoteWakeupRequests(agentId: string, companyId: string): number {
|
||||
@@ -2215,36 +2190,35 @@ export class AppDatabase {
|
||||
this.transaction(() => {
|
||||
const queued = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail FROM wakeup_requests
|
||||
`SELECT id, trigger_detail, context_snapshot FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as { id: string; trigger_detail: string | null } | undefined;
|
||||
const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number }>(
|
||||
`SELECT id, trigger_detail, coalesced_count FROM wakeup_requests
|
||||
.get(agentId, companyId) as { id: string; trigger_detail: string | null; context_snapshot: string | null } | undefined;
|
||||
const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }>(
|
||||
`SELECT id, trigger_detail, coalesced_count, context_snapshot FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`,
|
||||
agentId, companyId,
|
||||
);
|
||||
if (deferred.length === 0) return;
|
||||
const ts = nowIso();
|
||||
if (queued) {
|
||||
// Merge deferred triggers into existing queued wake
|
||||
const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
let mergedCtx = queued.context_snapshot;
|
||||
for (const d of deferred) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
queuedTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot);
|
||||
}
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(queuedTriggers), addedCount, ts, queued.id);
|
||||
// Delete the merged deferred rows
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(queuedTriggers), addedCount, mergedCtx, ts, queued.id);
|
||||
for (const d of deferred) {
|
||||
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
promoted = deferred.length;
|
||||
} else {
|
||||
// No queued wake -- promote first deferred to queued, merge rest
|
||||
const [first, ...rest] = deferred;
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
|
||||
@@ -2252,14 +2226,16 @@ export class AppDatabase {
|
||||
if (rest.length > 0) {
|
||||
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
let mergedCtx = first!.context_snapshot;
|
||||
for (const d of rest) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
firstTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot);
|
||||
}
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(firstTriggers), addedCount, ts, first!.id);
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id);
|
||||
for (const d of rest) {
|
||||
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
@@ -2597,14 +2573,6 @@ export class AppDatabase {
|
||||
return row ? this.mapSkillRow(row) : null;
|
||||
}
|
||||
|
||||
/** Get a single skill by canonical key within a company. */
|
||||
getCompanySkillByKey(companyId: string, key: string): SkillRecord | null {
|
||||
const row = this.db
|
||||
.prepare("SELECT * FROM company_skills WHERE company_id = ? AND key = ?")
|
||||
.get(companyId, key) as SqliteRow | undefined;
|
||||
return row ? this.mapSkillRow(row) : null;
|
||||
}
|
||||
|
||||
/** Delete a skill record. Cascade deletes agent_skill_assignments via FK. */
|
||||
deleteCompanySkill(companyId: string, skillId: string): boolean {
|
||||
const result = this.db
|
||||
@@ -2982,10 +2950,246 @@ export class AppDatabase {
|
||||
return id;
|
||||
}
|
||||
|
||||
updateDocumentStatus(id: string, companyId: string, status: string): void {
|
||||
this.db.prepare(
|
||||
"UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||
).run(status, nowIso(), id, companyId);
|
||||
}
|
||||
|
||||
deleteDocument(id: string, companyId: string) {
|
||||
this.db.prepare("delete from documents where id = ? and company_id = ?").run(id, companyId);
|
||||
}
|
||||
|
||||
createDocumentRevision(input: {
|
||||
documentId: string; companyId: string; body: string;
|
||||
changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null;
|
||||
}): { id: string; revisionNumber: number } {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const current = this.db.prepare(
|
||||
"SELECT version FROM documents WHERE id = ? AND company_id = ?",
|
||||
).get(input.documentId, input.companyId) as { version: number } | undefined;
|
||||
if (!current) throw new Error(`Document ${input.documentId} not found`);
|
||||
const revisionNumber = current.version + 1;
|
||||
this.db.prepare(
|
||||
"INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts);
|
||||
this.db.prepare(
|
||||
"UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?",
|
||||
).run(revisionNumber, id, ts, input.documentId);
|
||||
return { id, revisionNumber };
|
||||
}
|
||||
|
||||
getDocumentLatestRevisionId(documentId: string): string | null {
|
||||
const row = this.db.prepare(
|
||||
"SELECT latest_revision_id FROM documents WHERE id = ?",
|
||||
).get(documentId) as { latest_revision_id: string | null } | undefined;
|
||||
return row?.latest_revision_id ?? null;
|
||||
}
|
||||
|
||||
listDocumentRevisions(documentId: string, companyId: string) {
|
||||
return this.readAll<{
|
||||
id: string; company_id: string; document_id: string; revision_number: number;
|
||||
body: string; change_summary: string | null; created_by_agent_id: string | null;
|
||||
created_by_user_id: string | null; created_at: string;
|
||||
}>(
|
||||
"SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC",
|
||||
documentId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, documentId: r.document_id,
|
||||
revisionNumber: r.revision_number, body: r.body,
|
||||
changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id,
|
||||
createdByUserId: r.created_by_user_id, createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
addApprovalComment(input: {
|
||||
companyId: string; approvalId: string; authorAgentId?: string | null;
|
||||
authorUserId?: string | null; body: string;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"INSERT INTO approval_comments (id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, input.companyId, input.approvalId, input.authorAgentId ?? null, input.authorUserId ?? null, input.body, ts, ts);
|
||||
return id;
|
||||
}
|
||||
|
||||
listApprovalComments(approvalId: string, companyId: string) {
|
||||
return this.readAll<{
|
||||
id: string; company_id: string; approval_id: string; author_agent_id: string | null;
|
||||
author_user_id: string | null; body: string; created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at FROM approval_comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC",
|
||||
approvalId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, approvalId: r.approval_id,
|
||||
authorAgentId: r.author_agent_id, authorUserId: r.author_user_id,
|
||||
body: r.body, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
findMentionedAgentIds(companyId: string, text: string): string[] {
|
||||
const mentionRe = /\B@([^\s@,!?.]+)/g;
|
||||
const tokens = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = mentionRe.exec(text)) !== null) {
|
||||
tokens.add(m[1]!.toLowerCase());
|
||||
}
|
||||
if (tokens.size === 0) return [];
|
||||
const agents = this.readAll<{ id: string; name: string }>(
|
||||
"SELECT id, name FROM agents WHERE company_id = ?", companyId,
|
||||
);
|
||||
const matched: string[] = [];
|
||||
for (const agent of agents) {
|
||||
if (tokens.has(agent.name.toLowerCase())) {
|
||||
matched.push(agent.id);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
resubmitApproval(approvalId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"UPDATE approvals SET state = 'pending', decision_note = '', updated_at = ? WHERE id = ? AND company_id = ? AND state = 'revision_requested'",
|
||||
).run(ts, approvalId, companyId);
|
||||
}
|
||||
|
||||
linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null): void {
|
||||
this.db.prepare(
|
||||
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
).run(taskId, approvalId, companyId, linkedByAgentId ?? null, nowIso());
|
||||
}
|
||||
|
||||
unlinkTaskFromApproval(taskId: string, approvalId: string): void {
|
||||
this.db.prepare(
|
||||
"DELETE FROM task_approval_links WHERE task_id = ? AND approval_id = ?",
|
||||
).run(taskId, approvalId);
|
||||
}
|
||||
|
||||
listApprovalsForTask(taskId: string): string[] {
|
||||
return this.readAll<{ approval_id: string }>(
|
||||
"SELECT approval_id FROM task_approval_links WHERE task_id = ?", taskId,
|
||||
).map((r) => r.approval_id);
|
||||
}
|
||||
|
||||
listTasksForApproval(approvalId: string): string[] {
|
||||
return this.readAll<{ task_id: string }>(
|
||||
"SELECT task_id FROM task_approval_links WHERE approval_id = ?", approvalId,
|
||||
).map((r) => r.task_id);
|
||||
}
|
||||
|
||||
linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null): void {
|
||||
const ts = nowIso();
|
||||
for (const taskId of taskIds) {
|
||||
this.db.prepare(
|
||||
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
).run(taskId, approvalId, companyId, linkedByAgentId ?? null, ts);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Work Products ──
|
||||
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string; projectId?: string | null;
|
||||
type: string; provider: string; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: string; reviewState?: string;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary) {
|
||||
this.db.prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
}
|
||||
this.db.prepare(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, input.status ?? "active",
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>): void {
|
||||
const ts = nowIso();
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: unknown[] = [ts];
|
||||
const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
if (ALLOWED.has(col)) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null);
|
||||
}
|
||||
}
|
||||
values.push(id, companyId);
|
||||
this.db.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...values);
|
||||
}
|
||||
|
||||
listWorkProductsForTask(taskId: string, companyId: string) {
|
||||
return this.readAll<{
|
||||
id: string; company_id: string; project_id: string | null; task_id: string;
|
||||
type: string; provider: string; external_id: string | null; title: string;
|
||||
url: string | null; status: string; review_state: string; is_primary: number;
|
||||
summary: string | null; metadata_json: string | null; created_by_run_id: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC",
|
||||
taskId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
deleteWorkProduct(id: string, companyId: string): void {
|
||||
this.db.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId);
|
||||
}
|
||||
|
||||
// ── Task-Document Links ──
|
||||
|
||||
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts);
|
||||
return id;
|
||||
}
|
||||
|
||||
unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void {
|
||||
this.db.prepare(
|
||||
"DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?",
|
||||
).run(taskId, key.toLowerCase(), companyId);
|
||||
}
|
||||
|
||||
listDocumentsForTask(taskId: string, companyId: string) {
|
||||
return this.readAll<{
|
||||
id: string; company_id: string; task_id: string; document_id: string;
|
||||
key: string; created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC",
|
||||
taskId, companyId,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, taskId: r.task_id,
|
||||
documentId: r.document_id, key: r.key,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
saveKnowledgeEntry(input: {
|
||||
id?: string | null; companyId: string; category: string; topic: string; content: string;
|
||||
authorAgentId?: string | null; importance?: string; tagsJson?: string;
|
||||
@@ -3319,13 +3523,45 @@ export class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
private static readonly VALID_TASK_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
|
||||
|
||||
advanceTaskStatus(taskId: string, newStatus: string, comment?: string): { previousStatus: string } {
|
||||
if (!AppDatabase.VALID_TASK_STATUSES.has(newStatus)) {
|
||||
throw new Error(`Invalid task status: ${newStatus}`);
|
||||
}
|
||||
const task = this.db.prepare("select status, company_id from tasks where id = ?").get(taskId) as { status: string; company_id: string } | undefined;
|
||||
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||
const previousStatus = task.status;
|
||||
if (previousStatus === newStatus) return { previousStatus };
|
||||
const ts = nowIso();
|
||||
this.db.prepare("update tasks set status = ?, updated_at = ? where id = ?").run(newStatus, ts, taskId);
|
||||
|
||||
// Status side-effect timestamps (Paperclip pattern)
|
||||
const timestampSets: string[] = ["status = ?", "updated_at = ?"];
|
||||
const timestampValues: (string | null)[] = [newStatus, ts];
|
||||
|
||||
if (newStatus === "in_progress") {
|
||||
// Set startedAt only once (first time entering in_progress)
|
||||
timestampSets.push("started_at = COALESCE(started_at, ?)");
|
||||
timestampValues.push(ts);
|
||||
}
|
||||
if (newStatus === "done") {
|
||||
timestampSets.push("completed_at = ?");
|
||||
timestampValues.push(ts);
|
||||
} else {
|
||||
timestampSets.push("completed_at = NULL");
|
||||
}
|
||||
if (newStatus === "cancelled") {
|
||||
timestampSets.push("cancelled_at = ?");
|
||||
timestampValues.push(ts);
|
||||
} else {
|
||||
timestampSets.push("cancelled_at = NULL");
|
||||
}
|
||||
if (newStatus !== "in_progress") {
|
||||
timestampSets.push("checkout_run_id = NULL");
|
||||
}
|
||||
|
||||
this.db.prepare(`UPDATE tasks SET ${timestampSets.join(", ")} WHERE id = ?`).run(...timestampValues, taskId);
|
||||
|
||||
if (comment) {
|
||||
this.addComment({
|
||||
companyId: task.company_id,
|
||||
@@ -3335,11 +3571,9 @@ export class AppDatabase {
|
||||
body: comment,
|
||||
});
|
||||
}
|
||||
// Track sprint velocity: increment completed_points when task moves to "done"
|
||||
if (newStatus === "done" && previousStatus !== "done") {
|
||||
this.incrementActiveSprintVelocity(task.company_id, 1);
|
||||
}
|
||||
// Decrement if task moves back from done
|
||||
if (previousStatus === "done" && newStatus !== "done") {
|
||||
this.incrementActiveSprintVelocity(task.company_id, -1);
|
||||
}
|
||||
@@ -3405,17 +3639,6 @@ export class AppDatabase {
|
||||
return manager ? { ...manager, companyId: agent.company_id } : null;
|
||||
}
|
||||
|
||||
activateWorkflow(id: string, companyId: string) {
|
||||
const row = this.db.prepare("select name, status from workflow_pipelines where id = ? and company_id = ?").get(id, companyId) as { name: string; status: string } | undefined;
|
||||
if (!row) throw new Error(`Workflow not found: ${id}`);
|
||||
if (row.status === "active") throw new Error("Workflow is already running.");
|
||||
const ts = nowIso();
|
||||
this.db.prepare(
|
||||
"update workflow_pipelines set status = 'active', current_step_index = 0, run_count = run_count + 1, last_run_at = ?, updated_at = ? where id = ?",
|
||||
).run(ts, ts, id);
|
||||
this.addActivity({ companyId, actor: "system", action: "started", entityType: "workflow", entityId: id, detail: `Started workflow: ${row.name}` });
|
||||
}
|
||||
|
||||
listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] {
|
||||
return this.readAll<SqliteRow>(
|
||||
"select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc",
|
||||
@@ -3987,6 +4210,123 @@ export class AppDatabase {
|
||||
log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 19,
|
||||
name: "add_document_revisions_and_approval_comments",
|
||||
fn: () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS document_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
change_summary TEXT,
|
||||
created_by_agent_id TEXT,
|
||||
created_by_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
approval_id TEXT NOT NULL,
|
||||
author_agent_id TEXT,
|
||||
author_user_id TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at);
|
||||
`);
|
||||
// Add latest_revision_id to documents (may already exist via ensureOptionalColumns)
|
||||
try { this.db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); } catch { /* column already exists */ }
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 20,
|
||||
name: "add_task_timestamps_and_task_approval_links",
|
||||
fn: () => {
|
||||
const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } };
|
||||
addCol("ALTER TABLE tasks ADD COLUMN started_at TEXT");
|
||||
addCol("ALTER TABLE tasks ADD COLUMN completed_at TEXT");
|
||||
addCol("ALTER TABLE tasks ADD COLUMN cancelled_at TEXT");
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_approval_links (
|
||||
task_id TEXT NOT NULL,
|
||||
approval_id TEXT NOT NULL,
|
||||
company_id TEXT NOT NULL,
|
||||
linked_by_agent_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, approval_id),
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id);
|
||||
`);
|
||||
// Backfill: set started_at for tasks already in_progress/in_review/done
|
||||
this.db.prepare(
|
||||
"UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')",
|
||||
).run();
|
||||
this.db.prepare(
|
||||
"UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'",
|
||||
).run();
|
||||
this.db.prepare(
|
||||
"UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'",
|
||||
).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 21,
|
||||
name: "add_work_products_task_documents_wakeup_context",
|
||||
fn: () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS work_products (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
task_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
review_state TEXT NOT NULL DEFAULT 'none',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT,
|
||||
metadata_json TEXT,
|
||||
created_by_run_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id);
|
||||
`);
|
||||
// Add context_snapshot column to wakeup_requests for rich context merging
|
||||
try { this.db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ }
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
@@ -153,7 +154,13 @@ export function createDesktopShellService({
|
||||
callback(false);
|
||||
});
|
||||
app.on("web-contents-created", (_event, contents) => {
|
||||
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Allow external https URLs to open in the user's default browser
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
@@ -198,6 +205,8 @@ export function createDesktopShellService({
|
||||
async function createWindow() {
|
||||
const windowIcon = nativeImage.createFromPath(iconPath);
|
||||
const savedWindowState = loadWindowState(windowStatePath);
|
||||
const isWindows = process.platform === "win32";
|
||||
const isLinux = process.platform === "linux";
|
||||
mainWindow = new BrowserWindow({
|
||||
title: "AgentCompany",
|
||||
icon: windowIcon.isEmpty() ? undefined : windowIcon,
|
||||
@@ -208,7 +217,9 @@ export function createDesktopShellService({
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
backgroundColor: "#ffffff",
|
||||
frame: process.platform === "darwin",
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
...(isWindows || isLinux ? { titleBarOverlay: false } : {}),
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
@@ -367,7 +378,15 @@ export function createDesktopShellService({
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
if (process.platform === "darwin") {
|
||||
Menu.setApplicationMenu(menu);
|
||||
} else {
|
||||
// Frameless windows: hide native menu bar but keep keyboard accelerators active
|
||||
Menu.setApplicationMenu(menu);
|
||||
mainWindow?.setMenuBarVisibility(false);
|
||||
mainWindow?.setAutoHideMenuBar(true);
|
||||
}
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
@@ -456,6 +475,18 @@ export function createDesktopShellService({
|
||||
registerProtocol();
|
||||
buildMenu();
|
||||
createTray();
|
||||
|
||||
// Window control IPC for custom title bar (Windows/Linux frameless windows)
|
||||
ipcMain.handle("window:minimize", () => { mainWindow?.minimize(); });
|
||||
ipcMain.handle("window:maximize", () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("window:close", () => { mainWindow?.close(); });
|
||||
ipcMain.handle("window:isMaximized", () => mainWindow?.isMaximized() ?? false);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { delimiter as pathDelimiter, join } from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
interface HostEnvironmentDependencies {
|
||||
iconPath: string;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
};
|
||||
@@ -53,7 +52,6 @@ function enrichWindowsPath(logger: HostEnvironmentDependencies["logger"]) {
|
||||
}
|
||||
|
||||
export async function prepareHostEnvironment({
|
||||
iconPath,
|
||||
logger,
|
||||
}: HostEnvironmentDependencies) {
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
companyInputSchema,
|
||||
connectorInputSchema,
|
||||
deleteEntitySchema,
|
||||
openTerminalCommandSchema,
|
||||
secretInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
@@ -14,9 +16,10 @@ export function registerCoreAdminHandlers({
|
||||
publishDomainChanged,
|
||||
checkConnector,
|
||||
openConnectorAuthTerminal,
|
||||
openGenericTerminal,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal"
|
||||
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
|
||||
const parsed = companyInputSchema.parse(payload);
|
||||
@@ -80,6 +83,21 @@ export function registerCoreAdminHandlers({
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.checkNodeInstalled, async () => {
|
||||
try {
|
||||
const raw = execSync("node --version", { encoding: "utf8", timeout: 10000 }).trim();
|
||||
return ok({ installed: true, version: raw || null });
|
||||
} catch {
|
||||
return ok({ installed: false, version: null });
|
||||
}
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openTerminalCommand, async (_event, payload) => {
|
||||
const parsed = openTerminalCommandSchema.parse(payload);
|
||||
await openGenericTerminal(parsed);
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => {
|
||||
const parsed = secretInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { SectionId } from "@shared/types";
|
||||
import type { DesktopEvent, SectionId } from "@shared/types";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
|
||||
type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
|
||||
|
||||
export interface RegisterCoreHandlersDependencies {
|
||||
registerHandle: (
|
||||
@@ -17,7 +17,7 @@ export interface RegisterCoreHandlersDependencies {
|
||||
refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>,
|
||||
) => { id: string | null | undefined; table: CompanyScopedTable; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
handleTaskCreated: (taskId: string, companyId: string) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
@@ -26,6 +26,7 @@ export interface RegisterCoreHandlersDependencies {
|
||||
queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult<string>;
|
||||
checkConnector: (id: any) => Promise<void>;
|
||||
openConnectorAuthTerminal: (id: any) => Promise<boolean>;
|
||||
openGenericTerminal: (input: { title: string; command: string }) => Promise<boolean>;
|
||||
checkForUpdates: () => Promise<boolean>;
|
||||
getWorkerProcess: () => { postMessage: (message: unknown) => void } | null;
|
||||
resetAfterRestore: () => void;
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { IpcMainInvokeEvent } from "electron";
|
||||
import { z } from "zod";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { bootstrapOnboardingSchema } from "@shared/contracts";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { createSkillService } from "./skill-service";
|
||||
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
|
||||
export interface BrowserActionControl {
|
||||
interface BrowserActionControl {
|
||||
clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise<void>;
|
||||
cancelAction: (actionId: string) => boolean;
|
||||
launchLoginBrowser: (socialAccountId: string, platform: string, loginUrl?: string) => Promise<{
|
||||
@@ -33,7 +34,7 @@ export interface RegisterDomainHandlersDependencies {
|
||||
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
|
||||
) => { id: string | null | undefined; table: string; label: string } | null;
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: any) => void;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import { initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl } from "./connector-auth-service";
|
||||
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl, openGenericTerminal as openGenericTerminalImpl } from "./connector-auth-service";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules";
|
||||
@@ -138,6 +138,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
const connector = requireDb().getConnector(id);
|
||||
return openConnectorAuthTerminalImpl(id, connector.command);
|
||||
},
|
||||
openGenericTerminal: (input) => openGenericTerminalImpl(input),
|
||||
checkForUpdates: () => requireUpdaterService().checkForUpdates(),
|
||||
getWorkerProcess: () => requireServiceGraph().getWorkerService()?.getWorker() ?? null,
|
||||
resetAfterRestore: () => {
|
||||
@@ -147,6 +148,13 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
getApiPort: () => state.apiServer?.getPort() ?? 0,
|
||||
notify: (options) => requireIpcSupport().notify(options),
|
||||
listConnectorModels: async (connectorId) => {
|
||||
// For claude_compatible, return user-configured models from the DB
|
||||
// (stored as comma-separated values in the connector's model field)
|
||||
if (connectorId.startsWith("claude_compat")) {
|
||||
const connector = requireDb().getConnector(connectorId);
|
||||
const raw = connector.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
|
||||
}
|
||||
const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]);
|
||||
return definition.listModels ? definition.listModels() : [];
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ interface BootstrapInput {
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
connectorId?: ConnectorId;
|
||||
connectorModel?: string;
|
||||
}
|
||||
|
||||
interface OnboardingDependencies {
|
||||
@@ -32,6 +33,8 @@ interface OnboardingDependencies {
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
/** Scan workspace directories for skills and import them. Best-effort — may be undefined during early init. */
|
||||
scanAndAssignSkills?: (companyId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFERRABLE_RUN_ERRORS = new Set([
|
||||
@@ -69,6 +72,7 @@ export function createOnboardingService({
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
scanAndAssignSkills,
|
||||
}: OnboardingDependencies) {
|
||||
function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) {
|
||||
if (!workspaceId) return;
|
||||
@@ -217,7 +221,7 @@ export function createOnboardingService({
|
||||
reportsTo: null,
|
||||
connectorId: selectedConnectorId,
|
||||
workspaceId,
|
||||
model: null,
|
||||
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.",
|
||||
budgetMonthlyUsd: 250,
|
||||
@@ -227,6 +231,10 @@ export function createOnboardingService({
|
||||
db.setCurrentCompany(companyId);
|
||||
seedDefaultAutomationRules(companyId);
|
||||
|
||||
if (scanAndAssignSkills) {
|
||||
void scanAndAssignSkills(companyId, ceoId).catch(() => {});
|
||||
}
|
||||
|
||||
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
|
||||
|
||||
db.addActivity({
|
||||
|
||||
@@ -68,9 +68,13 @@ interface OrchestrationDependencies {
|
||||
hasWorkerReady: () => boolean;
|
||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||
isQuitting: () => boolean;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string }>;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string; name?: string }>;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
getSessionHandoff?: (taskId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
|
||||
rotated: boolean;
|
||||
handoff: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrchestrationService({
|
||||
@@ -88,6 +92,7 @@ export function createOrchestrationService({
|
||||
listRuntimeSkillEntries,
|
||||
budgetService,
|
||||
getSessionHandoff,
|
||||
evaluatePreRunSessionRotation,
|
||||
}: OrchestrationDependencies) {
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const scheduleLastFiredMap = new Map<string, number>();
|
||||
@@ -132,31 +137,6 @@ 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;
|
||||
}
|
||||
db.updateAgentStatus(agent.id, "paused");
|
||||
db.addActivity({
|
||||
companyId: agent.companyId,
|
||||
actor: "system",
|
||||
action: "agent.auto_paused",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`,
|
||||
});
|
||||
notify({
|
||||
title: "Agent Paused",
|
||||
body: `${agent.name} paused -- budget limit reached`,
|
||||
urgency: "informational",
|
||||
navigation: { section: "agents", entityId: agent.id },
|
||||
batchKey: "agent_paused",
|
||||
});
|
||||
emitEvent({ type: "agent-updated", agentId: agent.id, action: "paused" });
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureAgentHeartbeatEnabled(agentId: string) {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
@@ -246,6 +226,16 @@ export function createOrchestrationService({
|
||||
}
|
||||
}
|
||||
|
||||
let managedSkills: Array<{ runtimeName: string; sourcePath: string; name?: 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)}`);
|
||||
}
|
||||
const skillNames = managedSkills
|
||||
.map((s) => s.name ?? s.runtimeName)
|
||||
.filter(Boolean);
|
||||
|
||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||
agent,
|
||||
apiUrl,
|
||||
@@ -298,6 +288,7 @@ export function createOrchestrationService({
|
||||
.slice(0, 5),
|
||||
allConnectors: snapshot.connectors.map((c) => ({ id: c.id, label: c.label, status: c.status })),
|
||||
sessionHandoff,
|
||||
skillNames,
|
||||
}) : null;
|
||||
|
||||
if (apiUrl) {
|
||||
@@ -312,12 +303,27 @@ export function createOrchestrationService({
|
||||
|
||||
// SESS-01: Prefer task_sessions table, fall back to legacy tasks.session_json
|
||||
const activeSession = db.getActiveTaskSession(task.id);
|
||||
const taskSession = activeSession
|
||||
let taskSession = activeSession
|
||||
? {
|
||||
sessionParams: activeSession.sessionParamsJson ? JSON.parse(activeSession.sessionParamsJson) as Record<string, unknown> : null,
|
||||
sessionDisplayId: activeSession.sessionDisplayId,
|
||||
}
|
||||
: db.getTaskSession(task.id);
|
||||
|
||||
// Pre-execution session compaction (Paperclip pattern): evaluate BEFORE run starts
|
||||
if (evaluatePreRunSessionRotation && activeSession) {
|
||||
const rotationResult = evaluatePreRunSessionRotation(task.id, connector.id);
|
||||
if (rotationResult.rotated) {
|
||||
// Session was rotated: clear session params so adapter starts fresh
|
||||
taskSession = { sessionParams: null, sessionDisplayId: null };
|
||||
// Handoff will be picked up by getSessionHandoff() above (already loaded from rotated session)
|
||||
if (!sessionHandoff && rotationResult.handoff) {
|
||||
sessionHandoff = rotationResult.handoff;
|
||||
}
|
||||
logger.info(`[session] Pre-run compaction for task ${task.id}: session rotated before execution`);
|
||||
}
|
||||
}
|
||||
|
||||
let thinkingEffort: string | null = null;
|
||||
try {
|
||||
const meta = JSON.parse(agent.metadataJson || "{}");
|
||||
@@ -328,14 +334,6 @@ 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,
|
||||
@@ -521,7 +519,7 @@ export function createOrchestrationService({
|
||||
return run.id;
|
||||
}
|
||||
|
||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
|
||||
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string, context?: Record<string, unknown>) {
|
||||
if (!agentId || isQuitting()) return null;
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
@@ -540,15 +538,13 @@ 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}`);
|
||||
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`, context);
|
||||
return null;
|
||||
}
|
||||
|
||||
const runId = startAgentHeartbeatRun(agentId, companyId, 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}`);
|
||||
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`, context);
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
@@ -877,7 +873,6 @@ export function createOrchestrationService({
|
||||
|
||||
return {
|
||||
resolveWorkspace,
|
||||
autoPauseBudgetExceededAgent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
queueTaskRun,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runningProcesses } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { runningProcesses, shutdownAllRunning } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { buildSanitizedRuntimeEnv } from "@main/runtime-env";
|
||||
import type { ConnectorId, RunStatus } from "@shared/types";
|
||||
@@ -47,7 +47,8 @@ interface RunJob {
|
||||
|
||||
type ParentMessage =
|
||||
| { type: "enqueue-run"; payload: RunJob }
|
||||
| { type: "cancel-run"; runId: string };
|
||||
| { type: "cancel-run"; runId: string }
|
||||
| { type: "shutdown" };
|
||||
|
||||
type ChildMessage =
|
||||
| { type: "run-log"; runId: string; stream: "stdout" | "stderr"; chunk: string }
|
||||
@@ -114,6 +115,16 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
|
||||
],
|
||||
};
|
||||
case "claude_local":
|
||||
case "claude_compatible":
|
||||
case "claude_compat_2":
|
||||
case "claude_compat_3":
|
||||
case "claude_compat_4":
|
||||
case "claude_compat_5":
|
||||
case "claude_compat_6":
|
||||
case "claude_compat_7":
|
||||
case "claude_compat_8":
|
||||
case "claude_compat_9":
|
||||
case "claude_compat_10":
|
||||
return {
|
||||
...baseConfig,
|
||||
effort: job.thinkingEffort ?? "",
|
||||
@@ -306,5 +317,13 @@ parentPort.on("message", (event) => {
|
||||
}
|
||||
if (message.type === "cancel-run") {
|
||||
cancelRun(message.runId);
|
||||
return;
|
||||
}
|
||||
if (message.type === "shutdown") {
|
||||
// Kill every running CLI process (claude, codex, gemini, etc.) and exit.
|
||||
// This is the only reliable way to ensure child processes don't survive app quit,
|
||||
// because the main process cannot kill grandchildren directly.
|
||||
shutdownAllRunning();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
@@ -33,6 +33,8 @@ interface PromptContext {
|
||||
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;
|
||||
/** Names of skills injected into this agent's runtime environment */
|
||||
skillNames?: string[];
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
@@ -59,6 +61,7 @@ Today is ${dayOfWeek}, ${dateStr} at ${timeStr} UTC.
|
||||
You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}.
|
||||
${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""}
|
||||
Your monthly budget: $${ctx.agent.budgetMonthlyUsd.toFixed(2)} | Spent this month: $${ctx.agent.spentMonthlyUsd.toFixed(2)}${ctx.agent.budgetMonthlyUsd > 0 ? ` (${Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)}% used)` : ""}
|
||||
${ctx.skillNames && ctx.skillNames.length > 0 ? `Your domain skills: ${ctx.skillNames.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.` : ""}
|
||||
`);
|
||||
|
||||
if (ctx.companyDescription) {
|
||||
@@ -711,6 +714,54 @@ Body: {
|
||||
### List Company Documents
|
||||
GET /api/companies/${ctx.agent.companyId}/documents
|
||||
|
||||
### Update Document (with conflict detection)
|
||||
PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId}
|
||||
Body: {
|
||||
"status": "draft|in_review|approved|archived|superseded",
|
||||
"content": "Updated content (creates a revision)",
|
||||
"changeSummary": "Brief description of what changed",
|
||||
"baseRevisionId": "{latestRevisionId for conflict detection — omit on status-only updates}"
|
||||
}
|
||||
Use after reviewing a document: set to "approved" if quality meets standards, "in_review" when under review, or back to "draft" if revisions are needed.
|
||||
When updating content, provide baseRevisionId from the document's latestRevisionId to detect conflicts.
|
||||
|
||||
### List Document Revisions
|
||||
GET /api/companies/${ctx.agent.companyId}/documents/{documentId}/revisions
|
||||
|
||||
### Comment on Approvals
|
||||
POST /api/approvals/{approvalId}/comments
|
||||
Body: { "body": "Your comment text. Use @AgentName to notify specific agents." }
|
||||
|
||||
### Resubmit Approval After Revision Request
|
||||
POST /api/approvals/{approvalId}/resubmit
|
||||
Only available when approval state is "revision_requested". Resets to "pending" for re-review.
|
||||
|
||||
### Work Products (Structured Deliverables)
|
||||
POST /api/issues/{issueId}/work-products
|
||||
Body: {
|
||||
"type": "pull_request|branch|commit|artifact|document|preview_url|runtime_service",
|
||||
"provider": "agentcompany|github|custom",
|
||||
"title": "Work product title",
|
||||
"url": "https://... (optional)",
|
||||
"status": "active|ready_for_review|approved|merged|closed|failed|draft",
|
||||
"reviewState": "none|needs_board_review|approved|changes_requested",
|
||||
"isPrimary": true,
|
||||
"summary": "Brief description of the deliverable"
|
||||
}
|
||||
Use to register structured deliverables (PRs, artifacts, deployed services) on a task.
|
||||
|
||||
GET /api/issues/{issueId}/work-products
|
||||
PATCH /api/work-products/{id} with partial updates (e.g. {"reviewState": "approved"})
|
||||
DELETE /api/work-products/{id}
|
||||
|
||||
### Link Document to Task (Semantic Classification)
|
||||
PUT /api/issues/{issueId}/documents/{key}
|
||||
Body: { "documentId": "{existingDocumentId}" }
|
||||
Key must be lowercase alphanumeric with _ or - (e.g. "plan", "spec", "design", "test-plan").
|
||||
Links a document to a task under a semantic key. One document per key per task.
|
||||
|
||||
GET /api/issues/{issueId}/documents — list all linked documents for a task
|
||||
|
||||
### Add Knowledge Entry (lessons learned, best practices)
|
||||
POST /api/companies/${ctx.agent.companyId}/knowledge
|
||||
Body: {
|
||||
@@ -989,15 +1040,29 @@ If there are NO active goals, this is your top priority — create strategic goa
|
||||
-d '{"title":"Goal title","description":"Measurable success criteria","ownerAgentId":"${ctx.agent.id}","status":"active"}'
|
||||
Then decompose each goal into projects, and each project into tasks assigned to department leads.
|
||||
|
||||
## Priority 2: Review Completed Work (SOP-004)
|
||||
## Priority 2: Ensure All Agents Have Skills (SOP-002b)
|
||||
Every agent needs domain skills to produce quality work. Check and fix immediately:
|
||||
1. For EACH of your direct reports, check their skills: GET /api/agents/{agentId}/skills
|
||||
2. If an agent has ZERO skills assigned, this is urgent — assign skills NOW:
|
||||
a. GET /api/companies/${ctx.agent.companyId}/skills to list available skills
|
||||
b. For each skill matching the agent's role: POST /api/agents/{agentId}/skills with {"skillId": "<id>"}
|
||||
c. Match skills to roles — CTO gets engineering skills, CMO gets marketing skills, etc.
|
||||
3. This MUST happen before delegating any work — agents without skills produce poor output
|
||||
|
||||
## Priority 3: Review Completed Work (SOP-004)
|
||||
When tasks from your reports have status "in_review":
|
||||
- Read ALL comments on the task to understand what was done
|
||||
- If satisfactory: PATCH /api/issues/{issueId} with status "done" + approving comment
|
||||
- If changes needed: PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback
|
||||
- Review any documents the agent created: GET /api/companies/${ctx.agent.companyId}/documents
|
||||
- If satisfactory:
|
||||
- PATCH /api/issues/{issueId} with status "done" + approving comment
|
||||
- Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "approved"}
|
||||
- If changes needed:
|
||||
- PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback
|
||||
- Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "draft"}
|
||||
- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your entire team
|
||||
- NEVER let review requests sit — they cascade-block everyone downstream
|
||||
|
||||
## Priority 3: Goal Decomposition & Delegation (SOP-003)
|
||||
## Priority 4: Goal Decomposition & Delegation (SOP-003)
|
||||
When you have active goals without projects or tasks:
|
||||
1. Create a project for each major workstream:
|
||||
POST /api/companies/${ctx.agent.companyId}/projects with goalId, leadAgentId, description
|
||||
@@ -1018,14 +1083,14 @@ When you have active goals without projects or tasks:
|
||||
6. Wake the assigned agents so they start immediately:
|
||||
POST /api/agents/{agentId}/wake with reason explaining the new assignment
|
||||
|
||||
## Priority 4: Unblock & Coordinate
|
||||
## Priority 5: Unblock & Coordinate
|
||||
- Review blocked tasks: read the blocker comments and take action
|
||||
- For credential needs: create secret_access approvals via POST /api/companies/${ctx.agent.companyId}/approvals
|
||||
- For cross-department dependencies: send direct messages to the relevant department lead
|
||||
- For Board-level decisions: create an approval with clear context and options
|
||||
- Review failed runs from your reports: check what went wrong and decide whether to retry or reassign
|
||||
|
||||
## Priority 5: Grow the Team
|
||||
## Priority 6: Grow the Team
|
||||
When workload exceeds capacity or new capabilities are needed:
|
||||
- Assess: count open tasks vs active agents per department
|
||||
- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents
|
||||
@@ -1033,7 +1098,7 @@ When workload exceeds capacity or new capabilities are needed:
|
||||
- Each hire needs: clear role, specific capabilities, budget justification, department assignment
|
||||
- After hire approval, create onboarding tasks for the new agent
|
||||
|
||||
## Priority 6: Strategic Communication & Reporting
|
||||
## Priority 7: Strategic Communication & Reporting
|
||||
- Schedule all-hands meetings when major milestones are reached:
|
||||
POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands"
|
||||
- Create status_report documents summarizing company progress:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from "electron";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
import { createAutomationService } from "./automation-service";
|
||||
import { createAutomationService, autoAssignSkillsToAgent } from "./automation-service";
|
||||
import { createBrowserActionService } from "./browser-action-service";
|
||||
import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
@@ -241,6 +241,22 @@ export function createServiceGraph({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
evaluatePreRunSessionRotation: (taskId: string, connectorId: string) => {
|
||||
try {
|
||||
const sessionSvc = getOrCreateSessionManagementService();
|
||||
const activeSession = db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionSvc.rotateSession(activeSession);
|
||||
const handoff = sessionSvc.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
} catch {
|
||||
return { rotated: false, handoff: null };
|
||||
}
|
||||
},
|
||||
});
|
||||
workerService.createWorker();
|
||||
|
||||
@@ -260,6 +276,11 @@ export function createServiceGraph({
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
scanAndAssignSkills: async (companyId: string, agentId: string) => {
|
||||
const svc = getOrCreateSkillService();
|
||||
await svc.scanWorkspaceSkills(companyId);
|
||||
autoAssignSkillsToAgent(db, logger, companyId, agentId);
|
||||
},
|
||||
});
|
||||
|
||||
browserActionService = createBrowserActionService({
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
@@ -137,10 +128,6 @@ function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
||||
return isPlainRecord(parsed.value) ? parsed.value : {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a SKILL.md file with optional YAML frontmatter.
|
||||
* Normalizes CRLF to LF before parsing.
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
*/
|
||||
@@ -21,10 +15,6 @@ interface Logger {
|
||||
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.
|
||||
@@ -61,10 +51,6 @@ export async function resolveGitHubCommitSha(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub tree + SKILL.md fetcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch all SKILL.md files from a GitHub repository via the REST API.
|
||||
*
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
@@ -30,10 +22,6 @@ export function normalizeSkillSlug(
|
||||
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.
|
||||
@@ -42,10 +30,6 @@ 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;
|
||||
}
|
||||
@@ -113,10 +97,6 @@ export function deriveCanonicalSkillKey(
|
||||
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_.-]+$/;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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";
|
||||
@@ -35,10 +33,7 @@ import {
|
||||
} from "./skill-github.js";
|
||||
import { TOKEN_BUDGETS } from "./skill-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkillServiceDependencies {
|
||||
db: AppDatabase;
|
||||
profileDir: string;
|
||||
@@ -49,10 +44,7 @@ interface SkillServiceDependencies {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) {
|
||||
// ---- Result helpers ----
|
||||
function ok<T>(data: T): DesktopResult<T> {
|
||||
@@ -75,8 +67,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return join(skillsRoot(companyId), "__runtime__");
|
||||
}
|
||||
|
||||
// ---- File materialization helpers ----
|
||||
|
||||
/**
|
||||
* Write SKILL.md (and tracked inventory files from GitHub downloads)
|
||||
* to the local skill directory.
|
||||
@@ -101,8 +91,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GitHub URL parser ----
|
||||
|
||||
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -118,8 +106,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Import routing ----
|
||||
|
||||
/**
|
||||
* Import skills from any supported source.
|
||||
* Routes to the correct handler based on parseSkillImportSource.
|
||||
@@ -178,8 +164,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GitHub import (SKILL-01) ----
|
||||
|
||||
async function importFromGitHub(
|
||||
companyId: string,
|
||||
url: string,
|
||||
@@ -296,8 +280,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Local path import (SKILL-02, D-04) ----
|
||||
|
||||
async function importFromLocal(
|
||||
companyId: string,
|
||||
localPath: string,
|
||||
@@ -416,8 +398,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- URL import (SKILL-03, D-05) ----
|
||||
|
||||
async function importFromUrl(
|
||||
companyId: string,
|
||||
url: string,
|
||||
@@ -487,8 +467,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
function listForCompany(companyId: string): SkillRecord[] {
|
||||
return db.listCompanySkills(companyId);
|
||||
}
|
||||
@@ -522,8 +500,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Agent assignment (SKILL-07) ----
|
||||
|
||||
function assignToAgent(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
@@ -544,8 +520,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// ---- Update detection (SKILL-11, D-11) ----
|
||||
|
||||
async function checkUpdateStatus(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
@@ -603,8 +577,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Runtime skill entries (SKILL-09 token budget) ----
|
||||
|
||||
/**
|
||||
* Build the list of RuntimeSkillEntry for adapter injection.
|
||||
* Applies token budget enforcement: skills sorted by creation date,
|
||||
@@ -617,12 +589,7 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
): 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;
|
||||
const skills = allSkills.filter((s) => agentAssignments.includes(s.id));
|
||||
|
||||
// Sort by creation date (oldest first -- first imported gets priority)
|
||||
const sorted = [...skills].sort(
|
||||
@@ -653,8 +620,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---- Local directory walker ----
|
||||
|
||||
function findSkillMdFiles(rootDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
function walk(dir: string) {
|
||||
@@ -701,8 +666,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return inventory;
|
||||
}
|
||||
|
||||
// ---- Workspace .agents/skills/ scanning (Bug 6) ----
|
||||
|
||||
/**
|
||||
* Scan all workspace local paths for a company, looking for
|
||||
* `.agents/skills/` directories containing SKILL.md files.
|
||||
@@ -761,8 +724,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
return allResults;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
return {
|
||||
importFromSource,
|
||||
importFromGitHub,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill system type definitions and constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Source type for a skill -- matches the company_skills.source_type column.
|
||||
*/
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
* minimum description quality, acceptance criteria, and capability matching.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaskValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
@@ -28,9 +24,6 @@ export interface CapabilityMatchDb {
|
||||
getCompanySkill?(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_DESCRIPTION_LENGTH = 50;
|
||||
|
||||
@@ -49,9 +42,6 @@ export const ACCEPTANCE_CRITERIA_KEYWORDS: readonly string[] = [
|
||||
"exit criteria",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkAcceptanceCriteria
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function checkAcceptanceCriteria(description: string): { hasCriteria: boolean; suggestion: string } {
|
||||
const lower = description.toLowerCase();
|
||||
@@ -68,9 +58,6 @@ export function checkAcceptanceCriteria(description: string): { hasCriteria: boo
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateTaskCreation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function validateTaskCreation(
|
||||
input: { title: string; description: string },
|
||||
@@ -102,9 +89,6 @@ export function validateTaskCreation(
|
||||
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[] {
|
||||
@@ -162,9 +146,6 @@ export function checkCapabilityMatch(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TASK-04: Stale Review Escalation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (D-08)
|
||||
|
||||
|
||||
+33
-12
@@ -498,25 +498,46 @@ export function createWorkerService({
|
||||
pendingRetryTimers.clear();
|
||||
|
||||
if (worker) {
|
||||
// Cancel all running/queued runs in the worker before killing it.
|
||||
// This tells the worker to SIGTERM all child CLI processes (claude, codex, etc.)
|
||||
const snapshot = db.listSnapshot();
|
||||
for (const run of snapshot.runs) {
|
||||
if (run.status === "running" || run.status === "queued") {
|
||||
try {
|
||||
worker.postMessage({ type: "cancel-run", runId: run.id });
|
||||
} catch { /* worker may already be dead */ }
|
||||
}
|
||||
}
|
||||
// Tell the worker to kill all its child CLI processes and exit.
|
||||
// Previous approach sent individual cancel-run messages then immediately
|
||||
// killed the worker — a race condition that left CLI processes as orphans
|
||||
// because the worker died before it could forward SIGTERM to its children.
|
||||
try {
|
||||
worker.postMessage({ type: "shutdown" });
|
||||
} catch { /* worker may already be dead */ }
|
||||
|
||||
worker.kill();
|
||||
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
||||
// Safety net: force-kill worker after 2s if shutdown didn't complete
|
||||
const ref = worker;
|
||||
setTimeout(() => {
|
||||
try { ref.kill(); } catch { /* already dead */ }
|
||||
}, 2000);
|
||||
worker = null;
|
||||
}
|
||||
|
||||
// Mark all running/queued runs as interrupted so DB state is clean on restart
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
for (const run of snapshot.runs) {
|
||||
if (run.status === "running" || run.status === "queued") {
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "interrupted",
|
||||
summary: "Application shut down.",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
if (run.workspaceId) {
|
||||
db.releaseWorkspaceLock(run.workspaceId, run.id);
|
||||
}
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
}
|
||||
}
|
||||
} catch { /* best effort during shutdown */ }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user