update
This commit is contained in:
1 parent
806200b3d1
commit
5547c29491
216 files changed
+5043
-3352
No files matched your search
@@ -1,6 +1,5 @@
|
||||
AGENTCOMPANY_UPDATE_URL=
|
||||
AGENTCOMPANY_DISABLE_UPDATES=true
|
||||
AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS=false
|
||||
CSC_LINK=
|
||||
CSC_KEY_PASSWORD=
|
||||
WIN_CSC_LINK=
|
||||
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm typecheck:all
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 2: Unit Tests
|
||||
|
||||
@@ -28,6 +28,8 @@ playwright-report/
|
||||
*.pem
|
||||
*.key
|
||||
*.p8
|
||||
*.csr
|
||||
*.certSigningRequest
|
||||
*.provisionprofile
|
||||
*.mobileprovision
|
||||
|
||||
|
||||
+1
-2
@@ -24,12 +24,11 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:all": "pnpm -r typecheck",
|
||||
"test:unit": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/unit",
|
||||
"test:integration": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run --config vitest.config.ts tests/integration",
|
||||
"test:e2e": "node --no-warnings ./node_modules/@playwright/test/cli.js test -c tests/e2e/playwright.config.ts",
|
||||
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm typecheck:all && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
||||
"verify:release": "pnpm verify && pnpm build:dir",
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
packages: []
|
||||
@@ -160,6 +160,30 @@ function getDockerBuildCommands(platform) {
|
||||
];
|
||||
}
|
||||
|
||||
// Paths under rootDir that the container needs. Credentials, env files, docs,
|
||||
// screenshots, pi-mono-reference, and other host-only state are deliberately
|
||||
// excluded to keep secrets out of the Wine/Linux builder image.
|
||||
const containerSourceMounts = [
|
||||
{ host: "src", mode: "ro" },
|
||||
{ host: "scripts", mode: "ro" },
|
||||
{ host: "assets", mode: "ro" },
|
||||
{ host: "builtin-skills", mode: "ro" },
|
||||
{ host: "electron-builder.yml", mode: "ro" },
|
||||
{ host: "package.json", mode: "ro" },
|
||||
{ host: "pnpm-lock.yaml", mode: "ro" },
|
||||
{ host: "electron.vite.config.ts", mode: "ro" },
|
||||
{ host: "tsconfig.json", mode: "ro" },
|
||||
{ host: "tsconfig.base.json", mode: "ro" },
|
||||
{ host: "tsconfig.check.json", mode: "ro" },
|
||||
{ host: "eslint.config.mjs", mode: "ro" },
|
||||
];
|
||||
|
||||
// Writable host paths — electron-vite writes to out/, electron-builder writes to build/.
|
||||
const containerOutputMounts = [
|
||||
{ host: "out" },
|
||||
{ host: "build" },
|
||||
];
|
||||
|
||||
async function runDockerBuild(platform) {
|
||||
const isWindows = platform === "win";
|
||||
const image = isWindows ? "electronuserland/builder:wine" : "electronuserland/builder";
|
||||
@@ -172,16 +196,28 @@ async function runDockerBuild(platform) {
|
||||
"pnpm exec electron-vite build",
|
||||
...getDockerBuildCommands(platform),
|
||||
].join(" && ");
|
||||
// Ensure host-side output dirs exist before binding (docker would otherwise create
|
||||
// them as root-owned on Linux hosts).
|
||||
for (const mount of containerOutputMounts) {
|
||||
await mkdir(join(rootDir, mount.host), { recursive: true });
|
||||
}
|
||||
const dockerArgs = [
|
||||
"run",
|
||||
"--rm",
|
||||
"--platform", "linux/amd64",
|
||||
"-v", `${rootDir}:/project`,
|
||||
];
|
||||
for (const mount of containerSourceMounts) {
|
||||
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}:${mount.mode}`);
|
||||
}
|
||||
for (const mount of containerOutputMounts) {
|
||||
dockerArgs.push("-v", `${join(rootDir, mount.host)}:/project/${mount.host}`);
|
||||
}
|
||||
dockerArgs.push(
|
||||
"-v", `${dockerNodeModulesVolume}:/project/node_modules`,
|
||||
"-v", `${join(cacheDir, "electron")}:/root/.cache/electron`,
|
||||
"-v", `${join(cacheDir, "electron-builder")}:/root/.cache/electron-builder`,
|
||||
"-w", "/project",
|
||||
];
|
||||
);
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
dockerArgs.push("-e", `${key}=${value}`);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
interface BeforeRequestPayload {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface AfterRequestPayload {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface HumanApprovalHookDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* pi `beforeToolCall` / `afterToolCall` bridge.
|
||||
*
|
||||
* **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the
|
||||
* pi Agent fully autonomously — the framework layer never gates a tool
|
||||
* call with a synchronous human-approval promise. Under AI Co-Founder, the
|
||||
* model itself is instructed (via the system prompt + a dedicated
|
||||
* `request_approval` tool) to pause when it judges human review necessary.
|
||||
* Blocking every `bash` / `edit` / `write` at the Electron layer would
|
||||
* defeat the pi-mono runtime and starve the agent of progress.
|
||||
*
|
||||
* `before` therefore always approves. `after` is a no-op audit point; the
|
||||
* agent-event stream persists the real tool record via SqliteSessionStore.
|
||||
* `cancelAll` remains for shutdown symmetry but has no queued callers.
|
||||
*/
|
||||
export class HumanApprovalHook {
|
||||
constructor(private readonly _deps: HumanApprovalHookDeps) {
|
||||
void this._deps;
|
||||
}
|
||||
|
||||
async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
/**
|
||||
* Retained so `RuntimeBridge.release()` can call it during app shutdown
|
||||
* without knowing whether any approvals were actually queued. The
|
||||
* autonomous hook never queues anything, so this is always a no-op.
|
||||
*/
|
||||
cancelAll(_reason: string): void {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { resolveModel } from "@main/model-resolver";
|
||||
|
||||
export interface CostWriterDeps {
|
||||
db: AppDatabase;
|
||||
logger: { warn: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
interface ObserveInput {
|
||||
@@ -26,21 +25,24 @@ export class CostWriter {
|
||||
constructor(private readonly deps: CostWriterDeps) {}
|
||||
|
||||
observeAssistantMessage(input: ObserveInput): void {
|
||||
let amountUsd: number | null = null;
|
||||
try {
|
||||
const definition = getConnectorDefinition(input.connectorId);
|
||||
if (definition.provider) {
|
||||
const model = resolveModel(definition.provider, input.modelId);
|
||||
if (model) {
|
||||
const cost = calculateCost(model, input.usage);
|
||||
amountUsd = Number.isFinite(cost.total) ? cost.total : null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logger.warn(
|
||||
`[cost-writer] cost calculation failed for ${input.connectorId}/${input.modelId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
// Fail closed on cost calculation — budget policies read `cost_entries`
|
||||
// to enforce spending gates; an "unattributed" row would silently let
|
||||
// an agent run unbounded. If pricing cannot be resolved, refuse to
|
||||
// persist the row and let the caller see the error. The failure is
|
||||
// visible (throws) instead of invisible (null amount, unattributed=1).
|
||||
const definition = getConnectorDefinition(input.connectorId);
|
||||
if (!definition.provider) {
|
||||
throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`);
|
||||
}
|
||||
const model = resolveModel(definition.provider, input.modelId);
|
||||
if (!model) {
|
||||
throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`);
|
||||
}
|
||||
const cost = calculateCost(model, input.usage);
|
||||
if (!Number.isFinite(cost.total)) {
|
||||
throw new Error(
|
||||
`[cost-writer] cost calculation returned non-finite total for ${input.connectorId}/${input.modelId}`,
|
||||
);
|
||||
amountUsd = null;
|
||||
}
|
||||
|
||||
this.deps.db.queryAll(
|
||||
@@ -51,8 +53,8 @@ export class CostWriter {
|
||||
input.taskId,
|
||||
input.agentId,
|
||||
input.connectorId,
|
||||
amountUsd,
|
||||
amountUsd === null ? 1 : 0,
|
||||
cost.total,
|
||||
0,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { SqliteSessionStore } from "./sqlite-session-store";
|
||||
export { HumanApprovalHook } from "./approval-hook";
|
||||
export { CostWriter } from "./cost-writer";
|
||||
export { RuntimeBridge } from "./runtime-bridge";
|
||||
@@ -3,7 +3,6 @@ import type { AppDatabase } from "@main/database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { ensureTaskThread } from "@main/thread-integration/thread-helpers";
|
||||
import { SqliteSessionStore } from "./sqlite-session-store";
|
||||
import { HumanApprovalHook } from "./approval-hook";
|
||||
import { CostWriter } from "./cost-writer";
|
||||
import type { CredentialResolver } from "@main/credentials";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
@@ -132,35 +131,33 @@ interface ActiveRunContext {
|
||||
* - Forwards `agent-event` from the worker to the renderer (push channel).
|
||||
* - Persists each `message_end` message via `SqliteSessionStore`.
|
||||
* - Calls `CostWriter` for assistant messages with usage.
|
||||
* - Translates `beforeToolCall` RPC into an approval row + waits.
|
||||
* - Translates `afterToolCall` RPC into best-effort audit hooks.
|
||||
*
|
||||
* Tool-call gating runs fully in the model layer (AI CEO and AI Co-Founder
|
||||
* both autonomous); no `beforeToolCall` / `afterToolCall` IPC hop exists.
|
||||
*/
|
||||
export class RuntimeBridge {
|
||||
readonly approvalHook: HumanApprovalHook;
|
||||
private readonly costWriter: CostWriter;
|
||||
private readonly activeRuns = new Map<string, ActiveRunContext>();
|
||||
private readonly sessionStores = new Map<string, SqliteSessionStore>();
|
||||
|
||||
constructor(private readonly deps: RuntimeBridgeDeps) {
|
||||
this.approvalHook = new HumanApprovalHook({
|
||||
db: deps.db,
|
||||
emitEvent: deps.emitEvent,
|
||||
logger: deps.logger,
|
||||
});
|
||||
this.costWriter = new CostWriter({ db: deps.db, logger: deps.logger });
|
||||
this.costWriter = new CostWriter({ db: deps.db });
|
||||
}
|
||||
|
||||
registerActiveRun(context: ActiveRunContext): void {
|
||||
private registerActiveRun(context: ActiveRunContext): void {
|
||||
this.activeRuns.set(context.runId, context);
|
||||
this.getOrCreateStore(context.taskSessionId);
|
||||
}
|
||||
|
||||
releaseActiveRun(runId: string): void {
|
||||
/**
|
||||
* Evicts the per-run `activeRuns` context and the per-session store cache.
|
||||
* Callers must invoke after a `run-finished` worker message so the maps
|
||||
* stay bounded by concurrent-run count rather than process lifetime.
|
||||
*/
|
||||
releaseRunResources(runId: string): void {
|
||||
const ctx = this.activeRuns.get(runId);
|
||||
this.activeRuns.delete(runId);
|
||||
}
|
||||
|
||||
loadPriorMessages(taskSessionId: string): AgentMessage[] {
|
||||
return this.getOrCreateStore(taskSessionId).loadMessages();
|
||||
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
|
||||
}
|
||||
|
||||
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
|
||||
@@ -206,15 +203,26 @@ export class RuntimeBridge {
|
||||
| { role?: string; usage?: unknown; content?: unknown; toolCallId?: string }
|
||||
| undefined;
|
||||
if (ctx && messageRaw?.role === "assistant" && messageRaw.usage) {
|
||||
this.costWriter.observeAssistantMessage({
|
||||
runId: ctx.runId,
|
||||
companyId: ctx.companyId,
|
||||
agentId: ctx.agentId,
|
||||
taskId: ctx.taskId,
|
||||
connectorId: ctx.connectorId,
|
||||
modelId: ctx.modelId,
|
||||
usage: messageRaw.usage as never,
|
||||
});
|
||||
try {
|
||||
this.costWriter.observeAssistantMessage({
|
||||
runId: ctx.runId,
|
||||
companyId: ctx.companyId,
|
||||
agentId: ctx.agentId,
|
||||
taskId: ctx.taskId,
|
||||
connectorId: ctx.connectorId,
|
||||
modelId: ctx.modelId,
|
||||
usage: messageRaw.usage as never,
|
||||
});
|
||||
} catch (error) {
|
||||
// Cost writer fails closed (RUNTIME-105) — surface the failure
|
||||
// loudly so operators can see a pricing catalog break, but let
|
||||
// the run continue so the agent's work is not lost. The missing
|
||||
// row means budget enforcement under-counts this message; a
|
||||
// repeated failure in the same run is still visible in logs.
|
||||
this.deps.logger.error(
|
||||
`[runtime-bridge] cost recording failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.recordThreadEvent(envelope, messageRaw);
|
||||
}
|
||||
@@ -265,30 +273,11 @@ export class RuntimeBridge {
|
||||
/** Handles RPC requests issued by the worker (`rpc-request` envelope). */
|
||||
async handleRpc(request: {
|
||||
rpcId: string;
|
||||
method: "beforeToolCall" | "afterToolCall" | "persistMessage" | "prepareRun";
|
||||
method: "persistMessage" | "prepareRun";
|
||||
runId: string;
|
||||
payload: unknown;
|
||||
}): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
|
||||
try {
|
||||
if (request.method === "beforeToolCall") {
|
||||
const ctx = this.activeRuns.get(request.runId);
|
||||
if (!ctx) return { ok: true, result: {} };
|
||||
const payload = request.payload as {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
};
|
||||
const verdict = await this.approvalHook.before(request.runId, payload);
|
||||
return { ok: true, result: verdict };
|
||||
}
|
||||
if (request.method === "afterToolCall") {
|
||||
const payload = request.payload as { toolCallId: string; toolName: string; isError: boolean };
|
||||
await this.approvalHook.after(request.runId, payload);
|
||||
return { ok: true, result: undefined };
|
||||
}
|
||||
if (request.method === "persistMessage") {
|
||||
const payload = request.payload as { taskSessionId: string; message: AgentMessage };
|
||||
this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message);
|
||||
|
||||
@@ -19,12 +19,7 @@ export class SqliteSessionStore {
|
||||
const result: AgentMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "message") {
|
||||
try {
|
||||
result.push(JSON.parse(row.payloadJson) as AgentMessage);
|
||||
} catch {
|
||||
// Skip malformed entries (defensive) — preserves the rest of the
|
||||
// transcript instead of failing the entire load.
|
||||
}
|
||||
result.push(JSON.parse(row.payloadJson) as AgentMessage);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { canControlAgent } from "../agent-permissions";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
@@ -82,7 +83,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
|
||||
server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => {
|
||||
const agentId = requireParam(params, "agentId");
|
||||
let agent;
|
||||
try { agent = db.getAgent(agentId); } catch { agent = null; }
|
||||
try {
|
||||
agent = db.getAgent(agentId);
|
||||
} catch (error) {
|
||||
log.warn(`[api/agent-skills:GET] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
agent = null;
|
||||
}
|
||||
if (!agent || agent.companyId !== token.companyId) {
|
||||
server.json(res, 404, { error: "Agent not found in your company" });
|
||||
return;
|
||||
@@ -109,7 +115,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
|
||||
return;
|
||||
}
|
||||
let agent;
|
||||
try { agent = db.getAgent(agentId); } catch { agent = null; }
|
||||
try {
|
||||
agent = db.getAgent(agentId);
|
||||
} catch (error) {
|
||||
log.warn(`[api/agent-skills:POST] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
agent = null;
|
||||
}
|
||||
if (!agent || agent.companyId !== token.companyId) {
|
||||
server.json(res, 404, { error: "Agent not found in your company" });
|
||||
return;
|
||||
@@ -128,7 +139,12 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
|
||||
const agentId = requireParam(params, "agentId");
|
||||
const skillId = requireParam(params, "skillId");
|
||||
let agent;
|
||||
try { agent = db.getAgent(agentId); } catch { agent = null; }
|
||||
try {
|
||||
agent = db.getAgent(agentId);
|
||||
} catch (error) {
|
||||
log.warn(`[api/agent-skills:DELETE] getAgent(${agentId}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
agent = null;
|
||||
}
|
||||
if (!agent || agent.companyId !== token.companyId) {
|
||||
server.json(res, 404, { error: "Agent not found in your company" });
|
||||
return;
|
||||
|
||||
@@ -166,7 +166,16 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
}
|
||||
const directReports = db.getDirectReports(agent.id, token.companyId);
|
||||
const reportsTo = agent.reportsTo;
|
||||
const manager = reportsTo ? (() => { try { return db.getAgent(reportsTo); } catch { return null; } })() : null;
|
||||
const manager = reportsTo
|
||||
? (() => {
|
||||
try {
|
||||
return db.getAgent(reportsTo);
|
||||
} catch (error) {
|
||||
log.warn(`[api/agents] manager lookup failed for ${reportsTo}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
server.json(res, 200, {
|
||||
...agent,
|
||||
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
|
||||
@@ -285,7 +294,11 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
|
||||
return;
|
||||
}
|
||||
} catch { /* invalid payloadJson, skip */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[api/agents] invalid pending-hire payloadJson for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const managerLabel = resolvedReportsTo
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { IMPORTANT_DOC_TYPE_SET } from "@shared/document-categories";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
@@ -109,7 +110,11 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
if (run.companyId === token.companyId) {
|
||||
taskId = run.taskId;
|
||||
}
|
||||
} catch { /* run may have finished or been deleted */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[api/documents] getRun(${token.runId}) failed while deriving taskId: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fire document creation automation rules
|
||||
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
|
||||
@@ -143,11 +148,19 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[api/documents] heartbeat pause for agent ${token.agentId} failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
onDomainChanged();
|
||||
}
|
||||
}
|
||||
} catch { /* deliverable review gate is best-effort */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[api/documents] deliverable review gate failed for company ${token.companyId}, document ${id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server.json(res, 201, { id });
|
||||
@@ -258,7 +271,10 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
|
||||
const products = db.listWorkProductsForTask(task.id, task.companyId);
|
||||
server.json(res, 200, products);
|
||||
} catch { server.json(res, 404, { error: "Task not found" }); }
|
||||
} catch (error) {
|
||||
log.warn(`[api/work-products] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
server.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
|
||||
@@ -286,7 +302,10 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
});
|
||||
onDomainChanged();
|
||||
server.json(res, 201, { id });
|
||||
} catch { server.json(res, 404, { error: "Task not found" }); }
|
||||
} catch (error) {
|
||||
log.warn(`[api/work-products] create failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
server.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
|
||||
@@ -317,7 +336,10 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
|
||||
const links = db.listDocumentsForTask(task.id, task.companyId);
|
||||
server.json(res, 200, links);
|
||||
} catch { server.json(res, 404, { error: "Task not found" }); }
|
||||
} catch (error) {
|
||||
log.warn(`[api/task-documents] list failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
server.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
|
||||
@@ -337,7 +359,10 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key);
|
||||
onDomainChanged();
|
||||
server.json(res, 200, { id: linkId, key });
|
||||
} catch { server.json(res, 404, { error: "Task not found" }); }
|
||||
} catch (error) {
|
||||
log.warn(`[api/task-documents] link failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
server.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
|
||||
@@ -348,6 +373,9 @@ export function registerFileRoutes(server: AgentApiServer) {
|
||||
db.unlinkDocumentFromTask(task.id, key, task.companyId);
|
||||
onDomainChanged();
|
||||
server.json(res, 200, { unlinked: true });
|
||||
} catch { server.json(res, 404, { error: "Task not found" }); }
|
||||
} catch (error) {
|
||||
log.warn(`[api/task-documents] unlink failed for issue ${params.issueId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
server.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { taskInputSchema } from "@shared/contracts";
|
||||
import { IMPORTANT_DOC_TYPES } from "@shared/document-categories";
|
||||
import { canAssignTask, canUpdateTask } from "../agent-permissions";
|
||||
import { inferTaskType } from "../connector-matching";
|
||||
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
|
||||
import { validateTaskCreation } from "../task-quality-gates";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
|
||||
@@ -221,7 +221,11 @@ export function registerTaskRoutes(server: AgentApiServer) {
|
||||
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
|
||||
onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
|
||||
}
|
||||
} catch { /* parent task may have been deleted */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[api/tasks] parent task lookup failed for ${task.parentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const chain = db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0) {
|
||||
@@ -304,8 +308,9 @@ export function registerTaskRoutes(server: AgentApiServer) {
|
||||
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
const isHeartbeat = isHeartbeatTaskTitle(input.title);
|
||||
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat);
|
||||
// Agent-authored tasks are never heartbeat tasks; the is_heartbeat flag
|
||||
// is set only by acquireHeartbeatTask in the orchestrator.
|
||||
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, false);
|
||||
if (!taskValidation.valid) {
|
||||
server.json(res, 422, { error: taskValidation.errors.join("; ") });
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,13 @@ interface AppLifecycleDependencies {
|
||||
getMainWindow: () => Electron.BrowserWindow | null;
|
||||
ensureWindow: () => Promise<Electron.BrowserWindow | null>;
|
||||
shouldQuit: () => boolean;
|
||||
/**
|
||||
* Invoked on the Electron `before-quit` event. The lifecycle service will
|
||||
* call `event.preventDefault()` on the first invocation so the owner of
|
||||
* this hook can perform an awaited drain and then invoke `app.exit()`
|
||||
* explicitly when the drain resolves. Subsequent `before-quit` events are
|
||||
* ignored because the drain is already in flight.
|
||||
*/
|
||||
onBeforeQuit: () => void;
|
||||
}
|
||||
|
||||
@@ -52,7 +59,13 @@ export function createAppLifecycleService({
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
let drainStarted = false;
|
||||
app.on("before-quit", (event) => {
|
||||
if (drainStarted) return;
|
||||
drainStarted = true;
|
||||
// Block Electron's default quit until the owner's drain resolves. The
|
||||
// owner must call app.exit() itself when drain is complete.
|
||||
event.preventDefault();
|
||||
onBeforeQuit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { createAutomationService, autoAssignSkillsToAgent } from "./automation/index";
|
||||
export { createAutomationService } from "./automation/index";
|
||||
export type { AutomationEventContext } from "./automation/index";
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { approvalTypeEnum } from "@shared/types";
|
||||
|
||||
const messageChannelSchema = z.enum(["direct", "department", "company", "project", "incident"]);
|
||||
const messagePrioritySchema = z.enum(["urgent", "normal", "low"]);
|
||||
@@ -41,7 +42,7 @@ export const triggerHeartbeatConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const createApprovalConfigSchema = z.object({
|
||||
approvalType: z.string().optional(),
|
||||
approvalType: approvalTypeEnum.optional(),
|
||||
summary: z.string().optional(),
|
||||
impact: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AutomationAction, DesktopEvent } from "@shared/types";
|
||||
import { dispatchAgentMessage } from "../message-dispatch";
|
||||
import { validateTaskCreation } from "../task-quality-gates";
|
||||
import type { AutomationEventContext } from "./types";
|
||||
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
|
||||
import { parseActionConfig, type AutomationActionConfigMap } from "./automation-action-schemas";
|
||||
|
||||
// Map trigger type → default wake trigger when automation fires a heartbeat.
|
||||
@@ -116,8 +115,9 @@ export function executeAutomationAction(
|
||||
const description = cfg.description ?? "";
|
||||
const priority = cfg.priority ?? "medium";
|
||||
const assigneeAgentId = cfg.assigneeAgentId ?? null;
|
||||
const isHeartbeat = isHeartbeatTaskTitle(title);
|
||||
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
|
||||
// Automation rules create user-facing tasks; heartbeat tasks are
|
||||
// synthesized only by acquireHeartbeatTask, never via create_task.
|
||||
const taskValidation = validateTaskCreation({ title, description }, false);
|
||||
if (!taskValidation.valid) {
|
||||
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
|
||||
break;
|
||||
@@ -231,7 +231,15 @@ export function executeAutomationAction(
|
||||
// Keep the fallback title when the task no longer exists.
|
||||
}
|
||||
// Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels.
|
||||
if (isHeartbeatTaskTitle(taskTitle)) {
|
||||
let isHeartbeatTask = false;
|
||||
if (ctx.taskId) {
|
||||
try {
|
||||
isHeartbeatTask = db.getTask(ctx.taskId).isHeartbeat === true;
|
||||
} catch {
|
||||
isHeartbeatTask = false;
|
||||
}
|
||||
}
|
||||
if (isHeartbeatTask) {
|
||||
logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AppDatabase } from "../database";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import { extractApprovalDocumentIds } from "@shared/approval-documents";
|
||||
import { notifyHireApproved } from "../hire-hook";
|
||||
import { autoAssignSkillsToAgent } from "./automation-skills";
|
||||
import type { AutomationEventContext } from "./types";
|
||||
import { normalizeWorkflowSteps } from "./automation-workflow";
|
||||
|
||||
@@ -95,140 +94,110 @@ export function handleApprovalResolved(
|
||||
});
|
||||
|
||||
if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) {
|
||||
try {
|
||||
const agent = db.getAgent(approval.relatedAgentId);
|
||||
const agent = db.getAgent(approval.relatedAgentId);
|
||||
|
||||
if (agent.status === "idle") {
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "agent.hired",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `${agent.name} hire approved and activated.`,
|
||||
});
|
||||
if (agent.status === "idle") {
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "agent.hired",
|
||||
entityType: "agent",
|
||||
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");
|
||||
|
||||
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();
|
||||
const hireLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
|
||||
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "orgchart", entityId: agent.id },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
// Notify adapter that hire was approved (Paperclip hire-hook parity)
|
||||
void notifyHireApproved(db, {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
source: "approval",
|
||||
sourceId: approvalId,
|
||||
approvedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
const ceo = db.getCeoForCompany(companyId);
|
||||
if (ceo && ceo.id !== agent.id) {
|
||||
wakeAgentIfPossible(ceo.id, companyId, "assignment");
|
||||
}
|
||||
} catch {
|
||||
// Hire follow-up is best-effort; approval state is already committed in storage.
|
||||
|
||||
publishDomainChanged();
|
||||
const hireLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
|
||||
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "orgchart", entityId: agent.id },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
// Notify adapter that hire was approved (Paperclip hire-hook parity)
|
||||
void notifyHireApproved(db, {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
source: "approval",
|
||||
sourceId: approvalId,
|
||||
approvedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "deliverable_review") {
|
||||
try {
|
||||
// Update linked document statuses from payloadJson
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved"
|
||||
? "approved"
|
||||
: decision === "rejected" || decision === "revision_requested"
|
||||
? "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") {
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
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") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
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(approval.requestedByAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* agent lookup non-fatal */ }
|
||||
if (decision !== "approved") {
|
||||
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
} catch {
|
||||
// Deliverable review follow-up is non-fatal — approval state is already committed.
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved"
|
||||
? "approved"
|
||||
: decision === "rejected" || decision === "revision_requested"
|
||||
? "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) {
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
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") {
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
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") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
} else if (approval.requestedByAgentId) {
|
||||
const agent = db.getAgent(approval.requestedByAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
if (decision !== "approved") {
|
||||
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
if (approval.type === "draft_review" || approval.type === "strategy_review") {
|
||||
try {
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved" ? "approved" : "draft";
|
||||
for (const docId of documentIds) {
|
||||
db.updateDocumentStatus(docId, companyId, docStatus);
|
||||
}
|
||||
publishDomainChanged();
|
||||
} catch {
|
||||
// Document checkpoint follow-up is non-fatal — approval state is already committed.
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved" ? "approved" : "draft";
|
||||
for (const docId of documentIds) {
|
||||
db.updateDocumentStatus(docId, companyId, docStatus);
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
|
||||
// Handle budget override approvals
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RunRecord, SectionId } from "@shared/types";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { AutomationEventContext, RunFinishedEvent } from "./types";
|
||||
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
|
||||
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
|
||||
|
||||
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
|
||||
return locale === "zh" ? zh : en;
|
||||
@@ -225,11 +224,11 @@ export function processRunCompletion(
|
||||
let effectiveStatus = task.status;
|
||||
|
||||
if (isSuccess && effectiveStatus === "in_progress") {
|
||||
// Check if non-code task produced a deliverable before auto-advancing
|
||||
// Delegate the task-type → deliverable-required decision to the pure
|
||||
// helper so production and tests share a single source of truth.
|
||||
const taskType = task.taskType ?? "general";
|
||||
const needsDeliverable = taskType === "research" || taskType === "content";
|
||||
|
||||
if (needsDeliverable) {
|
||||
if (needsDeliverableCheck(taskType)) {
|
||||
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id);
|
||||
if (!hasDeliverable) {
|
||||
// Don't auto-advance -- flag for manager review
|
||||
@@ -243,7 +242,11 @@ export function processRunCompletion(
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
} catch (error) {
|
||||
deps.logger.warn(
|
||||
`[automation] deliverable-review manager wake failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
publishDomainChanged();
|
||||
// Skip the normal in_review advance
|
||||
} else {
|
||||
@@ -257,7 +260,7 @@ export function processRunCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess && isHeartbeatTaskTitle(task.title) && effectiveStatus !== "done") {
|
||||
if (isSuccess && task.isHeartbeat && effectiveStatus !== "done") {
|
||||
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
|
||||
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
|
||||
import type { DepartmentId } from "@shared/types/enums";
|
||||
|
||||
const DEPARTMENT_SKILL_KEYWORDS: Record<DepartmentId, 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 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 ?? "") as DepartmentId;
|
||||
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. Use the shared leadership
|
||||
// keyword list with word-boundary matching so "lead" does not match
|
||||
// "cleaner" / "leaderboard" in agent role strings.
|
||||
if (roleMatchesLeadershipKeywords(agent.role, agent.title)) {
|
||||
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));
|
||||
|
||||
// Always assign basic-for-all builtin skills regardless of score
|
||||
let assigned = 0;
|
||||
for (const skill of allSkills) {
|
||||
if (existingAssignments.has(skill.id)) continue;
|
||||
try {
|
||||
const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {};
|
||||
if (meta.builtinCategory === "basic-for-all") {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
existingAssignments.add(skill.id);
|
||||
assigned++;
|
||||
logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`);
|
||||
}
|
||||
} catch { /* skip malformed metadata */ }
|
||||
}
|
||||
|
||||
const scored = allSkills
|
||||
.filter((skill) => !existingAssignments.has(skill.id))
|
||||
.map((skill) => ({
|
||||
skill,
|
||||
score: scoreSkillAgentMatch(skill, agent),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const MIN_SCORE = 3;
|
||||
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;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers
|
||||
import type { HandlerDeps } from "./automation-handlers";
|
||||
|
||||
export type { AutomationEventContext } from "./types";
|
||||
export { autoAssignSkillsToAgent } from "./automation-skills";
|
||||
|
||||
export function createAutomationService({
|
||||
db,
|
||||
|
||||
@@ -106,6 +106,14 @@ export function prepareServices(deps: PrepareServicesDeps): ServiceGraphHandles
|
||||
onRunCompleted: (runId, taskId, agentId, companyId, status, summary, errorMessage) =>
|
||||
runHousekeeping.onRunCompleted(runId, taskId, agentId, companyId, status, summary, errorMessage),
|
||||
checkpointEngine,
|
||||
// Lazy forwarder — ipcSupport is constructed below but only invoked
|
||||
// inside serviceGraph.init(), which runs after that point.
|
||||
registerHandle: (channel, handler) => {
|
||||
if (!ipcSupport) {
|
||||
throw new Error("IPC support is not initialized.");
|
||||
}
|
||||
ipcSupport.registerHandle(channel, handler);
|
||||
},
|
||||
});
|
||||
ipcSupport = createIpcSupport({
|
||||
isDev: deps.isDev,
|
||||
|
||||
@@ -35,7 +35,11 @@ export function recoverScheduledPosts(deps: RecoverScheduledPostsDeps): Set<Retu
|
||||
}, delay);
|
||||
timers.add(timer);
|
||||
deps.logger.info(`[bootstrap] Re-registered scheduled post timer: action ${action.id} in ${Math.round(delay / 1000)}s`);
|
||||
} catch { /* ignore malformed payload */ }
|
||||
} catch (error) {
|
||||
deps.logger.warn(
|
||||
`[bootstrap] malformed scheduled-post payload for action ${action.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentApiServer } from "../api-server";
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { DesktopEvent, SectionId } from "@shared/types";
|
||||
@@ -68,7 +69,11 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
|
||||
try {
|
||||
const task = deps.db.getTask(taskId);
|
||||
deps.checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId);
|
||||
} catch { /* task may not exist */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[wire-api-server] checkpointEngine.onTaskStarted failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
|
||||
|
||||
@@ -313,7 +313,11 @@ export async function runAction(
|
||||
try {
|
||||
const image: NativeImage = await win.webContents.capturePage();
|
||||
if (!image.isEmpty()) writeFileSync(screenshotPath, image.toPNG());
|
||||
} catch { /* ignore */ }
|
||||
} catch (screenshotError) {
|
||||
log.warn(
|
||||
`[browser] failure screenshot capture failed for action ${action.id}: ${screenshotError instanceof Error ? screenshotError.message : String(screenshotError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!win.isDestroyed()) win.close();
|
||||
|
||||
|
||||
@@ -44,10 +44,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) {
|
||||
if (mode !== "ai_cofounder") return null;
|
||||
|
||||
const task = db.getTask(taskId);
|
||||
const taskType = db.queryAll<{ taskType: string }>(
|
||||
"SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1",
|
||||
taskId,
|
||||
)[0]?.taskType;
|
||||
const taskType = db.getTaskType(taskId);
|
||||
|
||||
// Only create plan checkpoints for non-code task types
|
||||
if (!taskType || !CHECKPOINT_TASK_TYPES.has(taskType)) return null;
|
||||
@@ -136,16 +133,7 @@ export function createCheckpointEngine(deps: CheckpointDeps) {
|
||||
}
|
||||
|
||||
// Check for pending approvals related to this agent's tasks
|
||||
const pendingApprovals = db.queryAll<{ id: string }>(
|
||||
`SELECT a.id FROM approvals a
|
||||
JOIN tasks t ON a.related_task_id = t.id
|
||||
WHERE a.company_id = ? AND a.state = 'pending'
|
||||
AND t.assignee_agent_id = ?
|
||||
AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review')
|
||||
LIMIT 1`,
|
||||
companyId, agentId,
|
||||
);
|
||||
return pendingApprovals.length > 0;
|
||||
return db.hasPendingReviewApprovalForAgent(companyId, agentId);
|
||||
} catch (err) {
|
||||
logger.error(`[checkpoint] shouldPauseHeartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
* token in the `credentials` table, and passes it as the API key when
|
||||
* invoking pi-ai.
|
||||
*
|
||||
* The `oauthId` surfaced here is the AgentCompany internal id that the
|
||||
* catalog test pins (`anthropic` / `openai-codex` / `github-copilot` /
|
||||
* `gemini-cli` / `antigravity`). It is mapped to the pi-ai provider id
|
||||
* when resolving the actual OAuth flow — e.g. `gemini-cli` →
|
||||
* `google-gemini-cli`, `antigravity` → `google-antigravity`.
|
||||
* The `oauthId` surfaced here matches pi-ai's five registered provider ids
|
||||
* verbatim (`anthropic` / `openai-codex` / `github-copilot` /
|
||||
* `google-gemini-cli` / `google-antigravity`) and is passed straight to
|
||||
* `getOAuthProvider`.
|
||||
*/
|
||||
|
||||
import type { ConnectorDefinition } from "./connector-definitions";
|
||||
@@ -58,7 +57,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [
|
||||
description:
|
||||
"Google Gemini models via Cloud Code Assist OAuth. Free tier works with any Google account (rate-limited).",
|
||||
defaultModel: "gemini-2.5-pro",
|
||||
auth: { type: "oauth", oauthId: "gemini-cli" },
|
||||
auth: { type: "oauth", oauthId: "google-gemini-cli" },
|
||||
notes:
|
||||
"For paid Cloud Code Assist, set GOOGLE_CLOUD_PROJECT. Google-gemini-cli OAuth provider id in pi-ai.",
|
||||
},
|
||||
@@ -69,7 +68,7 @@ export const OAUTH_CONNECTORS: ConnectorDefinition[] = [
|
||||
description:
|
||||
"Google Antigravity sandbox with Gemini 3, Claude, and GPT-OSS models. Free with any Google account.",
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
auth: { type: "oauth", oauthId: "antigravity" },
|
||||
auth: { type: "oauth", oauthId: "google-antigravity" },
|
||||
notes: "Maps to pi-ai OAuth provider id `google-antigravity`.",
|
||||
},
|
||||
];
|
||||
@@ -30,16 +30,14 @@ import { CLOUD_CONNECTORS } from "./connector-definitions-data-cloud";
|
||||
|
||||
/**
|
||||
* OAuth provider id recognised by the AgentCompany credential layer. These
|
||||
* map 1:1 to pi-ai's five built-in OAuth handlers, with the Google handlers
|
||||
* shortened from `google-gemini-cli` / `google-antigravity` to `gemini-cli` /
|
||||
* `antigravity` in the UI.
|
||||
* match pi-ai's five built-in OAuth handler ids verbatim.
|
||||
*/
|
||||
export type PiOAuthProviderId =
|
||||
| "anthropic"
|
||||
| "openai-codex"
|
||||
| "github-copilot"
|
||||
| "gemini-cli"
|
||||
| "antigravity";
|
||||
| "google-gemini-cli"
|
||||
| "google-antigravity";
|
||||
|
||||
export type ConnectorAuthKind = "oauth" | "api_key" | "aws" | "azure" | "gcp_adc";
|
||||
|
||||
|
||||
@@ -57,6 +57,17 @@ export class CredentialResolver {
|
||||
if (stored.value.source === "literal") {
|
||||
return { apiKey: stored.value.value };
|
||||
}
|
||||
// Only the connector's own declared envKey may be read from process.env.
|
||||
// Arbitrary env var names would let a malicious payload exfil secrets
|
||||
// like CSC_KEY_PASSWORD or AWS_SECRET_ACCESS_KEY by routing them to a
|
||||
// provider as the "api key".
|
||||
if (stored.value.value !== auth.envKey) {
|
||||
throw new CredentialError(
|
||||
"CREDENTIAL_INVALID",
|
||||
connectorId,
|
||||
`Env var ${stored.value.value} is not permitted for connector ${connectorId}.`,
|
||||
);
|
||||
}
|
||||
const fromEnv = process.env[stored.value.value];
|
||||
if (!fromEnv) throw new CredentialError("CREDENTIAL_MISSING", connectorId, `Env var ${stored.value.value} is not set.`);
|
||||
return { apiKey: fromEnv };
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AppDatabaseBase {
|
||||
public readonly sessionEntries: ReturnType<typeof buildSessionEntryMethods>;
|
||||
public readonly credentialsTable: ReturnType<typeof buildCredentialMethods>;
|
||||
|
||||
constructor(rootDir: string) {
|
||||
constructor(rootDir: string, options: { isPackaged?: boolean } = {}) {
|
||||
this.profileDir = rootDir;
|
||||
this.dbPath = join(rootDir, "profile.sqlite");
|
||||
this.logsDir = join(rootDir, "logs");
|
||||
@@ -79,7 +79,7 @@ export class AppDatabaseBase {
|
||||
mkdirSync(this.logsDir, { recursive: true });
|
||||
mkdirSync(this.artifactsDir, { recursive: true });
|
||||
mkdirSync(this.backupsDir, { recursive: true });
|
||||
this.vault = new SecretVault(rootDir);
|
||||
this.vault = new SecretVault(rootDir, { isPackaged: options.isPackaged });
|
||||
this.db = this.openConnection();
|
||||
|
||||
const getDb = () => this.db;
|
||||
@@ -241,6 +241,12 @@ export class AppDatabaseBase {
|
||||
getHeartbeatPolicy(agentId: string) { return this.agents.getHeartbeatPolicy(agentId); }
|
||||
setHeartbeatPolicy(agentId: string, companyId: string, policy: { enabled?: boolean; intervalSec?: number; cooldownSec?: number; maxConcurrentRuns?: number; wakeOnDemand?: boolean }) { return this.agents.setHeartbeatPolicy(agentId, companyId, policy); }
|
||||
countActiveRunsForAgent(agentId: string, companyId: string) { return this.agents.countActiveRunsForAgent(agentId, companyId); }
|
||||
listDepartmentAgentIds(companyId: string, department: string) { return this.agents.listDepartmentAgentIds(companyId, department); }
|
||||
listTopLevelAgentIds(companyId: string) { return this.agents.listTopLevelAgentIds(companyId); }
|
||||
listIncidentPingableAgentIds(companyId: string) { return this.agents.listIncidentPingableAgentIds(companyId); }
|
||||
findAgentName(agentId: string) { return this.agents.findAgentName(agentId); }
|
||||
listAgentNamesByIds(companyId: string, agentIds: readonly string[]) { return this.agents.listAgentNamesByIds(companyId, agentIds); }
|
||||
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]) { return this.agents.listActiveAgentIdsFromIds(companyId, agentIds); }
|
||||
|
||||
// ─── Tasks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -258,8 +264,8 @@ export class AppDatabaseBase {
|
||||
checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); }
|
||||
releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); }
|
||||
renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); }
|
||||
listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); }
|
||||
listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); }
|
||||
listAgentTasks(agentId: string, companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listAgentTasks(agentId, companyId, statuses, options); }
|
||||
listTasksByStatuses(companyId: string, statuses: string[], options?: { includeHeartbeat?: boolean }) { return this.tasks.listTasksByStatuses(companyId, statuses, options); }
|
||||
listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); }
|
||||
listAssigneeTasksForStandup(companyId: string, agentId: string) { return this.tasks.listAssigneeTasksForStandup(companyId, agentId); }
|
||||
listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); }
|
||||
@@ -282,6 +288,9 @@ export class AppDatabaseBase {
|
||||
dismissInterruptedTask(taskId: string) { return this.tasks.dismissInterruptedTask(taskId); }
|
||||
markInterruptedTaskFailed(taskId: string) { return this.tasks.markInterruptedTaskFailed(taskId); }
|
||||
setTaskType(taskId: string, taskType: string) { return this.tasks.setTaskType(taskId, taskType); }
|
||||
listProjectTaskAssigneeIds(projectId: string, companyId: string) { return this.tasks.listProjectTaskAssigneeIds(projectId, companyId); }
|
||||
listReviewTasksForSweep(companyId: string) { return this.tasks.listReviewTasksForSweep(companyId); }
|
||||
getTaskType(taskId: string) { return this.tasks.getTaskType(taskId); }
|
||||
|
||||
// ─── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -322,6 +331,8 @@ export class AppDatabaseBase {
|
||||
listAgentResolvedApprovals(companyId: string, agentId: string, limit?: number) { return this.approvalsMod.listAgentResolvedApprovals(companyId, agentId, limit); }
|
||||
listPendingHireApprovalPayloads(companyId: string) { return this.approvalsMod.listPendingHireApprovalPayloads(companyId); }
|
||||
listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); }
|
||||
findPendingApprovalForTask(companyId: string, taskId: string) { return this.approvalsMod.findPendingApprovalForTask(companyId, taskId); }
|
||||
hasPendingReviewApprovalForAgent(companyId: string, agentId: string) { return this.approvalsMod.hasPendingReviewApprovalForAgent(companyId, agentId); }
|
||||
|
||||
// ─── Workspaces ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -170,7 +170,13 @@ export class AppDatabase extends AppDatabaseBase {
|
||||
listSubscriptions(threadId: string) { return this.threads.listSubscriptions(threadId); }
|
||||
listSubscribedThreads(subscriberId: string, companyId: string) { return this.threads.listSubscribedThreads(subscriberId, companyId); }
|
||||
|
||||
// ─── Public query ──────────────────────────────────────────────────────────
|
||||
// ─── Internal raw-SQL escape hatch ────────────────────────────────────────
|
||||
//
|
||||
// Not part of the public API. New callers MUST add a typed method on the
|
||||
// appropriate `database/<module>.ts` and route through there. The existing
|
||||
// call sites remain only to avoid a repo-wide migration; see
|
||||
// `.planning/review2/02-persistence-ipc.md` DB-001 for the migration list.
|
||||
|
||||
/** @internal */
|
||||
queryAll<T>(sql: string, ...params: SQLInputValue[]): T[] { return this.readAll<T>(sql, ...params); }
|
||||
}
|
||||
@@ -408,5 +408,80 @@ export function buildAgentHierarchyMethods(
|
||||
)
|
||||
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of non-terminated agents in a department. Used by
|
||||
* `resolveMessageWakeTargets` to fan out "department" channel messages.
|
||||
*/
|
||||
listDepartmentAgentIds(companyId: string, department: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
|
||||
companyId,
|
||||
department,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of non-terminated agents that have no manager (top of the
|
||||
* reporting tree). Used by `resolveMessageWakeTargets` for "company"
|
||||
* channel broadcast.
|
||||
*/
|
||||
listTopLevelAgentIds(companyId: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of agents that can receive incident pings: everybody not
|
||||
* terminated or still pending approval.
|
||||
*/
|
||||
listIncidentPingableAgentIds(companyId: string): string[] {
|
||||
return readAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the agent's display name, or null if the agent no longer
|
||||
* exists. Used for notification subject lines.
|
||||
*/
|
||||
findAgentName(agentId: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare("select name from agents where id = ? limit 1")
|
||||
.get(agentId) as { name: string } | undefined;
|
||||
return row?.name ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns (id, name) pairs for the given ids, scoped to company. Used
|
||||
* for batched name resolution when fanning out message wake targets.
|
||||
*/
|
||||
listAgentNamesByIds(companyId: string, agentIds: readonly string[]): Array<{ id: string; name: string }> {
|
||||
if (agentIds.length === 0) return [];
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
return readAll<{ id: string; name: string }>(
|
||||
`select id, name from agents where id in (${placeholders}) and company_id = ?`,
|
||||
...agentIds,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns ids of active agents with the given ids, filtered to the
|
||||
* company scope. Used by project-channel wake targeting to drop
|
||||
* terminated members before pinging.
|
||||
*/
|
||||
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]): string[] {
|
||||
if (agentIds.length === 0) return [];
|
||||
const placeholders = agentIds.map(() => "?").join(",");
|
||||
return readAll<{ id: string }>(
|
||||
`select id from agents where id in (${placeholders}) and company_id = ? and status != 'terminated'`,
|
||||
...agentIds,
|
||||
companyId,
|
||||
).map((row) => row.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ApprovalRecord } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { normalizeOptionalId, nowIso, parseJson } from "./helpers";
|
||||
@@ -54,7 +55,11 @@ export function buildApprovalMethods(
|
||||
try {
|
||||
const payload = JSON.parse(input.payloadJson) as { agentId?: string };
|
||||
relatedAgentId = payload.agentId ?? null;
|
||||
} catch { /* ignore malformed payload */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[db/approvals] malformed payloadJson on hire_agent request for company ${input.companyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.type === "hire_agent") {
|
||||
if (!relatedAgentId) {
|
||||
@@ -346,5 +351,40 @@ export function buildApprovalMethods(
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the first pending approval linked to a task (id + type),
|
||||
* or null when none exists. Used by task-quality-gates to decide
|
||||
* whether an in-review task still has a blocking approval.
|
||||
*/
|
||||
findPendingApprovalForTask(companyId: string, taskId: string): { id: string; type: string } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT id, type FROM approvals
|
||||
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(companyId, taskId) as { id: string; type: string } | undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true when the given agent has a pending review-type approval
|
||||
* against one of their tasks. Used by checkpoint-engine to decide
|
||||
* whether heartbeat should pause in co-founder mode.
|
||||
*/
|
||||
hasPendingReviewApprovalForAgent(companyId: string, agentId: string): boolean {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT 1 as hit FROM approvals a
|
||||
JOIN tasks t ON a.related_task_id = t.id
|
||||
WHERE a.company_id = ? AND a.state = 'pending'
|
||||
AND t.assignee_agent_id = ?
|
||||
AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(companyId, agentId) as { hit: number } | undefined;
|
||||
return row !== undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,8 +32,6 @@ export function buildBackupMethods(
|
||||
backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")),
|
||||
cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
|
||||
cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
|
||||
copyIfExists(join(deps.profileDir, "vault.key"), join(targetDir, "vault.key")),
|
||||
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(targetDir, "agent-api-signing.key")),
|
||||
copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
|
||||
copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
|
||||
];
|
||||
|
||||
@@ -49,11 +49,12 @@ export function buildEntityCrudMethods(
|
||||
getTask(id: string): TaskRecord {
|
||||
const row = getDb()
|
||||
.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, task_type as taskType, 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 = ?",
|
||||
"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, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, 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.isHeartbeat = Boolean(row.isHeartbeat);
|
||||
row.startedAt = row.startedAt ?? null;
|
||||
row.completedAt = row.completedAt ?? null;
|
||||
row.cancelledAt = row.cancelledAt ?? null;
|
||||
|
||||
@@ -279,14 +279,15 @@ export function buildRunMethods(
|
||||
|
||||
getInterruptedRunsForRecovery(): Array<{
|
||||
runId: string; taskId: string; taskTitle: string; agentId: string;
|
||||
agentName: string; connectorId: string; interruptedAt: string;
|
||||
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
|
||||
}> {
|
||||
return readAll<{
|
||||
runId: string; taskId: string; taskTitle: string; agentId: string;
|
||||
agentName: string; connectorId: string; interruptedAt: string;
|
||||
agentName: string; companyId: string; connectorId: string; interruptedAt: string;
|
||||
}>(
|
||||
`select r.id as runId, r.task_id as taskId, t.title as taskTitle,
|
||||
r.agent_id as agentId, a.name as agentName,
|
||||
r.company_id as companyId,
|
||||
r.connector_id as connectorId, r.finished_at as interruptedAt
|
||||
from runs r
|
||||
join tasks t on t.id = r.task_id
|
||||
|
||||
@@ -40,6 +40,7 @@ export function ensureOptionalColumns(db: DatabaseSync) {
|
||||
addColumnIfMissing("tasks", "checkout_run_id", "alter table tasks add column checkout_run_id text");
|
||||
addColumnIfMissing("tasks", "execution_locked_at", "alter table tasks add column execution_locked_at text");
|
||||
addColumnIfMissing("tasks", "active_session_id", "alter table tasks add column active_session_id text");
|
||||
addColumnIfMissing("tasks", "is_heartbeat", "alter table tasks add column is_heartbeat integer not null default 0");
|
||||
addColumnIfMissing("cost_entries", "project_id", "alter table cost_entries add column project_id text");
|
||||
db.exec(`
|
||||
create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null);
|
||||
@@ -455,6 +456,20 @@ export function runMigrations(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// Explicit flag for synthesized heartbeat tasks. Replaces title-prefix
|
||||
// sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and
|
||||
// automation gates. Backfills from existing `[Heartbeat]` titles so the
|
||||
// new column agrees with historical data on first boot.
|
||||
version: 33, name: "add_tasks_is_heartbeat",
|
||||
fn: () => {
|
||||
if (!columnExists(db, "tasks", "is_heartbeat")) {
|
||||
db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run();
|
||||
}
|
||||
db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run();
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)");
|
||||
},
|
||||
},
|
||||
{
|
||||
// pi-mono session transcripts + credential vault. Idempotent; safe to
|
||||
// re-apply on fresh DBs or DBs that already have the tables.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, lstatSync, realpathSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve, sep } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
@@ -14,7 +15,10 @@ function isWithinOrEqual(targetPath: string, rootPath: string) {
|
||||
function safeRealpath(targetPath: string) {
|
||||
try {
|
||||
return realpathSync(targetPath);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
`[db/settings] realpathSync fallback for ${targetPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return resolve(targetPath);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +117,11 @@ export function buildSettingsMethods(
|
||||
if (run.logPath) {
|
||||
try {
|
||||
unlinkSync(run.logPath);
|
||||
} catch { /* Log files may already be gone */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[db/settings] purgeExpiredRunLogs: unlink failed for run ${run.id} (${run.logPath}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
getDb()
|
||||
.prepare("update runs set log_path = null, updated_at = ? where id = ?")
|
||||
|
||||
@@ -75,9 +75,9 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
),
|
||||
tasks: 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, task_type as taskType, 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 limit 2000`,
|
||||
`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, task_type as taskType, requires_user_review as requiresUserReview, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere ? `${companyWhere} and is_heartbeat = 0` : " where is_heartbeat = 0"} order by created_at desc limit 2000`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), isHeartbeat: toBoolean(record.isHeartbeat), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
|
||||
approvals: 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 limit 1000`,
|
||||
...companyArgs,
|
||||
|
||||
@@ -33,6 +33,7 @@ export type TaskListByStatusesRow = {
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
requiresUserReview: boolean;
|
||||
isHeartbeat: boolean;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
@@ -168,12 +169,24 @@ export function buildTaskCrudMethods(
|
||||
});
|
||||
},
|
||||
|
||||
listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] {
|
||||
/**
|
||||
* Returns actionable real work for an agent. Heartbeat tasks are
|
||||
* excluded by default since they are synthesized wake-up placeholders;
|
||||
* pass `{ includeHeartbeat: true }` to include them (heartbeat-loop
|
||||
* callers that already filter by `is_heartbeat` do not need this).
|
||||
*/
|
||||
listAgentTasks(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
statuses: string[],
|
||||
options: { includeHeartbeat?: boolean } = {},
|
||||
): TaskRecord[] {
|
||||
const placeholders = statuses.map(() => "?").join(",");
|
||||
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
|
||||
return 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, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
||||
`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, task_type as taskType, is_heartbeat as isHeartbeat, created_at as createdAt, updated_at as updatedAt
|
||||
from tasks
|
||||
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
|
||||
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})${heartbeatClause}
|
||||
order by
|
||||
case status
|
||||
when 'in_progress' then 0
|
||||
@@ -187,20 +200,21 @@ export function buildTaskCrudMethods(
|
||||
companyId,
|
||||
agentId,
|
||||
...statuses,
|
||||
);
|
||||
).map((t) => ({ ...t, isHeartbeat: Boolean(t.isHeartbeat) }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim task projection (id, title, status, priority) for a
|
||||
* single assignee, ordered by created_at desc. Used by the alternate
|
||||
* me/standup builder in routes-agents-standup.ts.
|
||||
* me/standup builder in routes-agents-standup.ts. Heartbeat tasks are
|
||||
* excluded so UI aggregations do not surface synthetic wake tasks.
|
||||
*/
|
||||
listAssigneeTasksForStandup(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Array<{ id: string; title: string; status: string; priority: string }> {
|
||||
return readAll(
|
||||
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
|
||||
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? and is_heartbeat = 0 order by created_at desc",
|
||||
companyId,
|
||||
agentId,
|
||||
);
|
||||
@@ -209,7 +223,8 @@ export function buildTaskCrudMethods(
|
||||
/**
|
||||
* Returns the slim task projection for a set of assignees. Safe on
|
||||
* an empty assignee list (returns []). Used by the alternate
|
||||
* me/standup builder to fetch reports' tasks in one query.
|
||||
* me/standup builder to fetch reports' tasks in one query. Heartbeat
|
||||
* tasks are excluded.
|
||||
*/
|
||||
listTasksForAssignees(
|
||||
companyId: string,
|
||||
@@ -224,7 +239,7 @@ export function buildTaskCrudMethods(
|
||||
if (assigneeIds.length === 0) return [];
|
||||
const placeholders = assigneeIds.map(() => "?").join(",");
|
||||
return readAll(
|
||||
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
|
||||
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) and is_heartbeat = 0 order by created_at desc`,
|
||||
companyId,
|
||||
...assigneeIds,
|
||||
);
|
||||
@@ -232,7 +247,7 @@ export function buildTaskCrudMethods(
|
||||
|
||||
/**
|
||||
* Returns the slim task projection used by the me/standup aggregation.
|
||||
* Ordered by created_at desc.
|
||||
* Ordered by created_at desc. Heartbeat tasks are excluded.
|
||||
*/
|
||||
listTasksForStandup(companyId: string): Array<{
|
||||
id: string;
|
||||
@@ -243,23 +258,32 @@ export function buildTaskCrudMethods(
|
||||
priority: string;
|
||||
}> {
|
||||
return readAll(
|
||||
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
|
||||
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? and is_heartbeat = 0 order by created_at desc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns tasks filtered by status. Heartbeat tasks are excluded by
|
||||
* default — they are synthesized wake placeholders and should not
|
||||
* appear in the user-facing issue list. Pass `{ includeHeartbeat: true }`
|
||||
* to include them.
|
||||
*/
|
||||
listTasksByStatuses(
|
||||
companyId: string,
|
||||
statuses: string[],
|
||||
options: { includeHeartbeat?: boolean } = {},
|
||||
): TaskListByStatusesRow[] {
|
||||
const placeholders = statuses.map(() => "?").join(", ");
|
||||
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
|
||||
`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 company_id = ? and status in (${placeholders}) order by created_at desc`,
|
||||
const heartbeatClause = options.includeHeartbeat ? "" : " and is_heartbeat = 0";
|
||||
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean; isHeartbeat: number | boolean }>(
|
||||
`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, is_heartbeat as isHeartbeat, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders})${heartbeatClause} order by created_at desc`,
|
||||
companyId,
|
||||
...statuses,
|
||||
).map((t) => ({
|
||||
...t,
|
||||
requiresUserReview: Boolean(t.requiresUserReview),
|
||||
isHeartbeat: Boolean(t.isHeartbeat),
|
||||
startedAt: t.startedAt ?? null,
|
||||
completedAt: t.completedAt ?? null,
|
||||
cancelledAt: t.cancelledAt ?? null,
|
||||
@@ -269,7 +293,7 @@ export function buildTaskCrudMethods(
|
||||
countActiveTasksForCompany(companyId: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and title not like '[Heartbeat]%'",
|
||||
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled') and is_heartbeat = 0",
|
||||
)
|
||||
.get(companyId) as { cnt: number };
|
||||
return row.cnt;
|
||||
@@ -518,5 +542,50 @@ export function buildTaskCrudMethods(
|
||||
.run(ts, taskId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the set of distinct assignee agent ids on tasks in a project,
|
||||
* excluding unassigned tasks. Used by message-dispatch to fan out wake
|
||||
* targets for a project channel.
|
||||
*/
|
||||
listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] {
|
||||
const rows = readAll<{ assigneeAgentId: string | null }>(
|
||||
"select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
|
||||
projectId,
|
||||
companyId,
|
||||
);
|
||||
return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns assignee agent ids for a set of task ids scoped to a company.
|
||||
* Used by task-quality-gates sweep to find reviewers of in-review tasks
|
||||
* that have exceeded the review timeout.
|
||||
*/
|
||||
listReviewTasksForSweep(companyId: string): Array<{
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}> {
|
||||
return readAll(
|
||||
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND status = 'in_review'`,
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns `task_type` for a single task id, or null when the task is
|
||||
* missing. Used by checkpoint-engine to branch on task type without
|
||||
* loading the full row.
|
||||
*/
|
||||
getTaskType(taskId: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT task_type as taskType FROM tasks WHERE id = ? LIMIT 1")
|
||||
.get(taskId) as { taskType: string | null } | undefined;
|
||||
return row?.taskType ?? null;
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
@@ -111,9 +111,10 @@ export function buildTaskStatusMethods(
|
||||
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,
|
||||
is_heartbeat as isHeartbeat,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
|
||||
AND status IN ('done', 'in_review')
|
||||
ORDER BY updated_at DESC, rowid DESC
|
||||
LIMIT 1`,
|
||||
@@ -121,6 +122,7 @@ export function buildTaskStatusMethods(
|
||||
.get(companyId, agentId) as TaskRecord | undefined;
|
||||
if (row) {
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
row.isHeartbeat = Boolean(row.isHeartbeat);
|
||||
}
|
||||
return row ?? null;
|
||||
},
|
||||
@@ -141,7 +143,7 @@ export function buildTaskStatusMethods(
|
||||
const claimable = db
|
||||
.prepare(
|
||||
`SELECT id, status FROM tasks
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
|
||||
AND status IN ('todo', 'backlog', 'done', 'in_review')
|
||||
AND checkout_run_id IS NULL
|
||||
ORDER BY
|
||||
@@ -172,7 +174,7 @@ export function buildTaskStatusMethods(
|
||||
const inflight = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM tasks
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND title LIKE '[Heartbeat]%'
|
||||
WHERE company_id = ? AND assignee_agent_id = ? AND is_heartbeat = 1
|
||||
AND status NOT IN ('done', 'cancelled')
|
||||
LIMIT 1`,
|
||||
)
|
||||
@@ -184,9 +186,9 @@ export function buildTaskStatusMethods(
|
||||
`INSERT INTO tasks
|
||||
(id, company_id, project_id, goal_id, parent_id, title, description,
|
||||
assignee_agent_id, workspace_id, status, priority,
|
||||
checkout_run_id, execution_locked_at, started_at,
|
||||
checkout_run_id, execution_locked_at, started_at, is_heartbeat,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, NULL, NULL, NULL, ?, ?, ?, ?, 'in_progress', ?, ?, ?, ?, 1, ?, ?)`,
|
||||
).run(
|
||||
taskId,
|
||||
input.companyId,
|
||||
@@ -211,7 +213,7 @@ export function buildTaskStatusMethods(
|
||||
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,
|
||||
task_type as taskType,
|
||||
is_heartbeat as isHeartbeat, task_type as taskType,
|
||||
started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM tasks WHERE id = ?`,
|
||||
@@ -219,6 +221,7 @@ export function buildTaskStatusMethods(
|
||||
.get(taskId) as TaskRecord | undefined;
|
||||
if (!row) return null;
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
row.isHeartbeat = Boolean(row.isHeartbeat);
|
||||
row.startedAt = row.startedAt ?? null;
|
||||
row.completedAt = row.completedAt ?? null;
|
||||
row.cancelledAt = row.cancelledAt ?? null;
|
||||
@@ -231,13 +234,13 @@ export function buildTaskStatusMethods(
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks SET status = 'cancelled', updated_at = ?
|
||||
WHERE company_id = ? AND title LIKE '[Heartbeat]%'
|
||||
WHERE company_id = ? AND is_heartbeat = 1
|
||||
AND status IN ('done', 'in_review', 'todo', 'blocked', 'backlog')
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignee_agent_id ORDER BY updated_at DESC, rowid DESC) as rn
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND title LIKE '[Heartbeat]%' AND status != 'cancelled'
|
||||
WHERE company_id = ? AND is_heartbeat = 1 AND status != 'cancelled'
|
||||
) WHERE rn = 1
|
||||
)`,
|
||||
)
|
||||
@@ -252,10 +255,11 @@ export function buildTaskStatusMethods(
|
||||
t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId,
|
||||
t.status, t.priority, t.active_run_id as activeRunId,
|
||||
t.session_json as sessionJson, t.session_display_id as sessionDisplayId,
|
||||
t.cost_usd as costUsd, t.created_at as createdAt, t.updated_at as updatedAt
|
||||
t.cost_usd as costUsd, t.is_heartbeat as isHeartbeat,
|
||||
t.created_at as createdAt, t.updated_at as updatedAt
|
||||
from tasks t
|
||||
inner join agents a on t.assignee_agent_id = a.id
|
||||
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ?
|
||||
where t.company_id = ? and t.status = 'in_review' and a.reports_to = ? and t.is_heartbeat = 0
|
||||
order by ${taskPriorityOrderSql("t.priority")} desc, t.created_at asc`,
|
||||
companyId,
|
||||
managerId,
|
||||
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
shell,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { ok } from "./ipc-core-common";
|
||||
|
||||
export interface DesktopShellMenuDependencies {
|
||||
iconPath: string;
|
||||
@@ -20,6 +22,10 @@ export interface DesktopShellMenuDependencies {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
ensureWindow: () => Promise<BrowserWindow | null>;
|
||||
requestQuitWithConfirmation: () => Promise<void>;
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
|
||||
@@ -32,6 +38,7 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
|
||||
getMainWindow,
|
||||
ensureWindow,
|
||||
requestQuitWithConfirmation,
|
||||
registerHandle,
|
||||
} = deps;
|
||||
|
||||
let appTray: Tray | null = null;
|
||||
@@ -231,18 +238,29 @@ export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
|
||||
}
|
||||
|
||||
function registerWindowControlIpc() {
|
||||
// Window control IPC for custom title bar (Windows/Linux frameless windows)
|
||||
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => { getMainWindow()?.minimize(); });
|
||||
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => {
|
||||
// Window control IPC for custom title bar (Windows/Linux frameless windows).
|
||||
// Routed through registerHandle so the trusted-sender check blocks
|
||||
// embedded webContents from closing/minimizing the app.
|
||||
registerHandle(IPC_CHANNELS.windowMinimize, () => {
|
||||
getMainWindow()?.minimize();
|
||||
return ok(null);
|
||||
});
|
||||
registerHandle(IPC_CHANNELS.windowMaximize, () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
return ok(null);
|
||||
});
|
||||
registerHandle(IPC_CHANNELS.windowClose, () => {
|
||||
getMainWindow()?.close();
|
||||
return ok(null);
|
||||
});
|
||||
registerHandle(IPC_CHANNELS.windowIsMaximized, () => {
|
||||
return ok(getMainWindow()?.isMaximized() ?? false);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.windowClose, () => { getMainWindow()?.close(); });
|
||||
ipcMain.handle(IPC_CHANNELS.windowIsMaximized, () => getMainWindow()?.isMaximized() ?? false);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createDesktopShellWindow, type DesktopShellWindowDependencies } from "./desktop-shell-window";
|
||||
import { createDesktopShellMenu } from "./desktop-shell-menu";
|
||||
import { createDesktopShellMenu, type DesktopShellMenuDependencies } from "./desktop-shell-menu";
|
||||
|
||||
export function createDesktopShellService(deps: DesktopShellWindowDependencies) {
|
||||
export type DesktopShellServiceDependencies = DesktopShellWindowDependencies & {
|
||||
registerHandle: DesktopShellMenuDependencies["registerHandle"];
|
||||
};
|
||||
|
||||
export function createDesktopShellService(deps: DesktopShellServiceDependencies) {
|
||||
const window = createDesktopShellWindow(deps);
|
||||
const menu = createDesktopShellMenu({
|
||||
iconPath: deps.iconPath,
|
||||
@@ -12,6 +16,7 @@ export function createDesktopShellService(deps: DesktopShellWindowDependencies)
|
||||
getMainWindow: window.getMainWindow,
|
||||
ensureWindow: window.ensureWindow,
|
||||
requestQuitWithConfirmation: window.requestQuitWithConfirmation,
|
||||
registerHandle: deps.registerHandle,
|
||||
});
|
||||
|
||||
function initialize() {
|
||||
|
||||
@@ -84,6 +84,10 @@ export async function notifyHireApproved(
|
||||
entityId: input.agentId,
|
||||
detail: message,
|
||||
});
|
||||
} catch { /* activity logging itself failed — nothing more we can do */ }
|
||||
} catch (activityError) {
|
||||
log.error(
|
||||
`[hire-hook] Activity logging also failed for agent ${input.agentId}: ${activityError instanceof Error ? activityError.message : String(activityError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export interface RegisterCoreHandlersDependencies {
|
||||
getApiPort: () => number;
|
||||
listConnectorModels: (connectorId: string) => Promise<Array<{ id: string; label: string }>>;
|
||||
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): DesktopResult<T> {
|
||||
|
||||
@@ -4,18 +4,13 @@ import {
|
||||
approvalInputSchema,
|
||||
cancelRunSchema,
|
||||
commentInputSchema,
|
||||
getAgentPermissionsSchema,
|
||||
getConfigRevisionSchema,
|
||||
heartbeatPolicySchema,
|
||||
heartbeatSettingsSchema,
|
||||
listCommentsSchema,
|
||||
listConfigRevisionsSchema,
|
||||
listRunEventsSchema,
|
||||
rollbackConfigRevisionSchema,
|
||||
runtimeStateSchema,
|
||||
runLogChunkSchema,
|
||||
runLogSchema,
|
||||
setAgentPermissionSchema,
|
||||
setHeartbeatPolicySchema,
|
||||
setRuntimeStateSchema,
|
||||
startRunSchema,
|
||||
@@ -257,35 +252,6 @@ export function registerCoreOperationHandlers({
|
||||
return ok(runId);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listConfigRevisions, async (_event, payload) => {
|
||||
const parsed = listConfigRevisionsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
return ok(db.listConfigRevisions(parsed.agentId, parsed.companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getConfigRevision, async (_event, payload) => {
|
||||
const parsed = getConfigRevisionSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.getConfigRevision(parsed.revisionId, parsed.companyId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.rollbackConfigRevision, async (_event, payload) => {
|
||||
const parsed = rollbackConfigRevisionSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
const newRevisionId = db.rollbackConfigRevision(parsed.agentId, parsed.revisionId, parsed.companyId);
|
||||
publishDomainChanged();
|
||||
return ok({ revisionId: newRevisionId });
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listRunEvents, async (_event, payload) => {
|
||||
const parsed = listRunEventsSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
@@ -296,23 +262,6 @@ export function registerCoreOperationHandlers({
|
||||
return ok(db.listRunEvents(parsed.runId, parsed.companyId, { offset: parsed.offset, limit: parsed.limit }));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getAgentPermissions, async (_event, payload) => {
|
||||
const parsed = getAgentPermissionsSchema.parse(payload);
|
||||
return ok(db.getAgentPermissions(parsed.agentId));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.setAgentPermission, async (_event, payload) => {
|
||||
const parsed = setAgentPermissionSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.agentId, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
db.setAgentPermission(parsed.agentId, parsed.companyId, parsed.permissionKey, parsed.enabled);
|
||||
publishDomainChanged();
|
||||
return ok(null);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getHeartbeatPolicy, async (_event, payload) => {
|
||||
const parsed = heartbeatPolicySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
taskInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
import { isHeartbeatTaskTitle } from "./orchestration/heartbeat-task";
|
||||
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
|
||||
|
||||
export function registerCoreTaskGraphHandlers({
|
||||
@@ -19,6 +18,7 @@ export function registerCoreTaskGraphHandlers({
|
||||
wakeAgentIfPossible,
|
||||
handleTaskCreated,
|
||||
handleTaskStatusChange,
|
||||
logger,
|
||||
}: Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
@@ -30,6 +30,7 @@ export function registerCoreTaskGraphHandlers({
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleTaskCreated"
|
||||
| "handleTaskStatusChange"
|
||||
| "logger"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
|
||||
const parsed = goalInputSchema.parse(payload);
|
||||
@@ -101,8 +102,9 @@ export function registerCoreTaskGraphHandlers({
|
||||
}
|
||||
const isNew = !parsed.id;
|
||||
if (isNew) {
|
||||
const isHeartbeat = isHeartbeatTaskTitle(parsed.title);
|
||||
const validation = validateTaskCreation(parsed, isHeartbeat);
|
||||
// User-created tasks are never heartbeat tasks; the is_heartbeat flag
|
||||
// is set only by acquireHeartbeatTask in the orchestrator.
|
||||
const validation = validateTaskCreation(parsed, false);
|
||||
if (!validation.valid) {
|
||||
return fail("TASK_VALIDATION_FAILED", validation.errors.join("; "));
|
||||
}
|
||||
@@ -143,7 +145,13 @@ export function registerCoreTaskGraphHandlers({
|
||||
}
|
||||
// User (board) manually advancing a flagged task to done: clear the review flag
|
||||
if (parsed.status === "done") {
|
||||
try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ }
|
||||
try {
|
||||
db.setTaskRequiresUserReview(id, false);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[ipc-task] setTaskRequiresUserReview(false) failed for task ${id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Wake new assignee when task is reassigned (matches api-server.ts behavior)
|
||||
if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) {
|
||||
@@ -157,7 +165,11 @@ export function registerCoreTaskGraphHandlers({
|
||||
if (!match.matched) {
|
||||
capabilityWarning = match.warning;
|
||||
}
|
||||
} catch { /* capability check is advisory — never block task creation */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[ipc-task] capability check failed for new task ${id} (advisory, not blocking): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
emitEvent({ type: "task-updated", taskId: id, action: isNew ? "created" : "updated" });
|
||||
return ok({ id, capabilityWarning });
|
||||
|
||||
@@ -15,6 +15,7 @@ export function registerCoreWorkGraphHandlers(
|
||||
| "handleApprovalResolved"
|
||||
| "handleTaskCreated"
|
||||
| "handleTaskStatusChange"
|
||||
| "logger"
|
||||
>,
|
||||
) {
|
||||
registerCoreAgentGraphHandlers({
|
||||
@@ -35,5 +36,6 @@ export function registerCoreWorkGraphHandlers(
|
||||
wakeAgentIfPossible: deps.wakeAgentIfPossible,
|
||||
handleTaskCreated: deps.handleTaskCreated,
|
||||
handleTaskStatusChange: deps.handleTaskStatusChange,
|
||||
logger: deps.logger,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
|
||||
const recoveryRetrySchema = z.object({
|
||||
taskId: z.string(),
|
||||
@@ -10,22 +10,30 @@ const recoveryRetrySchema = z.object({
|
||||
|
||||
const recoveryTaskSchema = z.object({
|
||||
taskId: z.string(),
|
||||
companyId: z.string(),
|
||||
});
|
||||
|
||||
export function registerRecoveryHandlers({
|
||||
registerHandle,
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
}: Pick<
|
||||
RegisterDomainHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
>) {
|
||||
registerHandle(IPC_CHANNELS.recoveryRetry, async (_event, payload) => {
|
||||
const parsed = recoveryRetrySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId);
|
||||
try {
|
||||
wakeAgentIfPossible(parsed.agentId, parsed.companyId, "retry_interrupted");
|
||||
@@ -38,6 +46,11 @@ export function registerRecoveryHandlers({
|
||||
|
||||
registerHandle(IPC_CHANNELS.recoveryDismiss, async (_event, payload) => {
|
||||
const parsed = recoveryTaskSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
db.dismissInterruptedTask(parsed.taskId);
|
||||
publishDomainChanged();
|
||||
return ok(null);
|
||||
@@ -45,6 +58,11 @@ export function registerRecoveryHandlers({
|
||||
|
||||
registerHandle(IPC_CHANNELS.recoveryFail, async (_event, payload) => {
|
||||
const parsed = recoveryTaskSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("tasks", parsed.taskId, parsed.companyId)) {
|
||||
return fail("TASK_NOT_FOUND", "Task not found in the current company.");
|
||||
}
|
||||
db.markInterruptedTaskFailed(parsed.taskId);
|
||||
publishDomainChanged();
|
||||
return ok(null);
|
||||
|
||||
@@ -162,6 +162,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
const policy = getConnectorModelPolicy(requireDb(), connectorId as ConnectorId);
|
||||
return policy.enabledModels;
|
||||
},
|
||||
logger: log,
|
||||
});
|
||||
|
||||
registerDomainHandlers({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { app } from "electron";
|
||||
import { AgentApiServer, createApiServer } from "./api-server";
|
||||
import { createAgentToken, initializeAgentTokenSigningKey } from "./agent-auth";
|
||||
import { bootstrapApplication } from "./bootstrap-application";
|
||||
@@ -53,7 +54,7 @@ export function createMainRuntime({
|
||||
isDev,
|
||||
devServerOrigin,
|
||||
profileDir,
|
||||
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir),
|
||||
appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir, { isPackaged: app.isPackaged }),
|
||||
initSigningKey: initializeAgentTokenSigningKey,
|
||||
windowStatePath,
|
||||
preloadPath,
|
||||
|
||||
@@ -193,29 +193,17 @@ function resolveProjectWakeTargets(
|
||||
input: NormalizedAgentMessageInput,
|
||||
): string[] {
|
||||
if (!input.channelTargetId) return [];
|
||||
const projectRows = db.queryAll<{ leadAgentId: string | null }>(
|
||||
"select lead_agent_id as leadAgentId from projects where id = ? and company_id = ? limit 1",
|
||||
input.channelTargetId, input.companyId,
|
||||
);
|
||||
const project = projectRows[0] ?? null;
|
||||
const project = db.getProjectForUpdate(input.channelTargetId, input.companyId);
|
||||
const participantIds = new Set<string>();
|
||||
if (project?.leadAgentId) participantIds.add(project.leadAgentId);
|
||||
const taskAssignees = db.queryAll<{ assigneeAgentId: string | null }>(
|
||||
"select assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
|
||||
input.channelTargetId, input.companyId,
|
||||
);
|
||||
for (const t of taskAssignees) {
|
||||
if (t.assigneeAgentId) participantIds.add(t.assigneeAgentId);
|
||||
for (const assigneeId of db.listProjectTaskAssigneeIds(input.channelTargetId, input.companyId)) {
|
||||
participantIds.add(assigneeId);
|
||||
}
|
||||
|
||||
if (participantIds.size === 0) return [];
|
||||
const ids = Array.from(participantIds).filter((id) => id !== input.fromAgentId);
|
||||
if (ids.length === 0) return [];
|
||||
const activeAgents = db.queryAll<{ id: string }>(
|
||||
`select id from agents where id in (${ids.map(() => "?").join(",")}) and company_id = ? and status != 'terminated'`,
|
||||
...ids, input.companyId,
|
||||
);
|
||||
return activeAgents.map((a) => a.id);
|
||||
return db.listActiveAgentIdsFromIds(input.companyId, ids);
|
||||
}
|
||||
|
||||
export function resolveMessageWakeTargets(
|
||||
@@ -229,22 +217,14 @@ export function resolveMessageWakeTargets(
|
||||
}
|
||||
|
||||
if (input.channel === "department" && input.channelTargetId) {
|
||||
const deptAgents = db.queryAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
|
||||
input.companyId, input.channelTargetId,
|
||||
);
|
||||
for (const agent of deptAgents) {
|
||||
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
|
||||
for (const agentId of db.listDepartmentAgentIds(input.companyId, input.channelTargetId)) {
|
||||
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.channel === "company") {
|
||||
const topLevel = db.queryAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
|
||||
input.companyId,
|
||||
);
|
||||
for (const agent of topLevel) {
|
||||
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
|
||||
for (const agentId of db.listTopLevelAgentIds(input.companyId)) {
|
||||
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,12 +235,8 @@ export function resolveMessageWakeTargets(
|
||||
}
|
||||
|
||||
if (input.channel === "incident") {
|
||||
const activeAgents = db.queryAll<{ id: string }>(
|
||||
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
|
||||
input.companyId,
|
||||
);
|
||||
for (const agent of activeAgents) {
|
||||
if (agent.id !== input.fromAgentId) wakeTargets.add(agent.id);
|
||||
for (const agentId of db.listIncidentPingableAgentIds(input.companyId)) {
|
||||
if (agentId !== input.fromAgentId) wakeTargets.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +279,7 @@ export function dispatchAgentMessage(
|
||||
!normalized.fromHumanUser &&
|
||||
(message.channel !== "direct" || message.toAgentId === HUMAN_SENDER_ID)
|
||||
) {
|
||||
const senderRows = deps.db.queryAll<{ name: string }>(
|
||||
"select name from agents where id = ? limit 1",
|
||||
message.fromAgentId,
|
||||
);
|
||||
const senderName = senderRows[0]?.name ?? "Agent";
|
||||
const senderName = deps.db.findAgentName(message.fromAgentId) ?? "Agent";
|
||||
deps.notify({
|
||||
title: truncateForNotification(`${senderName} · ${message.subject}`, 80),
|
||||
body: truncateForNotification(message.body, 140),
|
||||
@@ -327,12 +299,7 @@ export function dispatchAgentMessage(
|
||||
const mentionResults: MentionResult[] = [];
|
||||
|
||||
if (deps.wakeAgentIfPossible) {
|
||||
const agentNameRows = wakeTargets.length > 0
|
||||
? deps.db.queryAll<{ id: string; name: string }>(
|
||||
`select id, name from agents where id in (${wakeTargets.map(() => "?").join(",")}) and company_id = ?`,
|
||||
...wakeTargets, normalized.companyId,
|
||||
)
|
||||
: [];
|
||||
const agentNameRows = deps.db.listAgentNamesByIds(normalized.companyId, wakeTargets);
|
||||
const agentNameMap = new Map(agentNameRows.map((a) => [a.id, a.name]));
|
||||
for (const wakeTarget of wakeTargets) {
|
||||
const agentName = agentNameMap.get(wakeTarget) ?? wakeTarget;
|
||||
|
||||
@@ -17,7 +17,11 @@ export interface NotificationOptions {
|
||||
interface NotificationServiceDependencies {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
/** Returns the active UI locale ("en", "zh", ...). Batch summary copy is
|
||||
* localized; fallbacks to "en" when unset. */
|
||||
getLocale?: () => string | null | undefined;
|
||||
@@ -198,7 +202,13 @@ export function createNotificationService(deps: NotificationServiceDependencies)
|
||||
// Dismiss any still-visible notifications so Windows toast host does not
|
||||
// leave ghost toasts after quit.
|
||||
for (const notification of activeNotifications) {
|
||||
try { notification.close(); } catch { /* toast may have already closed */ }
|
||||
try {
|
||||
notification.close();
|
||||
} catch (error) {
|
||||
deps.logger.warn(
|
||||
`[notification] notification.close() during destroy failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
activeNotifications.clear();
|
||||
}
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
* Heartbeat task title conventions.
|
||||
*
|
||||
* Heartbeat tasks are synthesized by the orchestrator when an agent wakes with
|
||||
* no actionable work; they carry the `[Heartbeat]` title prefix so the rest of
|
||||
* the system can detect them without a dedicated `kind` column.
|
||||
*
|
||||
* Centralizing the prefix here prevents drift between the writer (heartbeat
|
||||
* dispatch) and readers (automation, escalation, route filters).
|
||||
* no actionable work. The `tasks.is_heartbeat` column is the authoritative
|
||||
* flag; the `[Heartbeat]` title prefix remains only for human-readable display
|
||||
* so existing runs, logs, and UI affordances stay stable.
|
||||
*/
|
||||
export const HEARTBEAT_TASK_TITLE_PREFIX = "[Heartbeat]";
|
||||
|
||||
export function isHeartbeatTaskTitle(title: string | null | undefined): boolean {
|
||||
return !!title && title.startsWith(HEARTBEAT_TASK_TITLE_PREFIX);
|
||||
}
|
||||
@@ -212,7 +212,11 @@ export function createHeartbeat(
|
||||
const actorType = trigger === "timer" || trigger === "bootstrap" ? "system" : "agent";
|
||||
const actorId = trigger === "timer" || trigger === "bootstrap" ? null : trigger;
|
||||
db.updateRunActor(run.id, actorType, actorId);
|
||||
} catch { /* Non-fatal */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[heartbeat] updateRunActor failed for run ${run.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
db.addActivity({
|
||||
companyId,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { buildTaskPrompt } from "../prompt-builder";
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { RuntimeSkillEntry } from "../skill-types";
|
||||
import type { ProjectedSkill } from "../skill-types";
|
||||
import type { ConnectorId, ExecutionMode } from "@shared/types";
|
||||
import type { WorkspaceLockMode, EnqueueRunPayload } from "./types";
|
||||
import { normalizeMessagesForPrompt, normalizeCommentsForPrompt } from "../message-normalize";
|
||||
@@ -33,7 +33,7 @@ export interface TurnContextDeps {
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
|
||||
getApiPort: () => number;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[];
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
|
||||
getSessionHandoff?: (taskId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
|
||||
rotated: boolean;
|
||||
@@ -109,7 +109,7 @@ function resolveSkills(
|
||||
connectorId: string,
|
||||
agentName: string,
|
||||
deps: Pick<TurnContextDeps, "listRuntimeSkillEntries" | "logger">,
|
||||
): RuntimeSkillEntry[] {
|
||||
): ProjectedSkill[] {
|
||||
try {
|
||||
return deps.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch (err) {
|
||||
@@ -162,13 +162,21 @@ function resolveSession(
|
||||
return { taskSession, sessionHandoff: finalHandoff };
|
||||
}
|
||||
|
||||
function extractThinkingEffort(metadataJson: string | null): string | null {
|
||||
function extractThinkingEffort(
|
||||
metadataJson: string | null,
|
||||
logger: TurnContextDeps["logger"],
|
||||
agentId: string,
|
||||
): string | null {
|
||||
try {
|
||||
const meta = JSON.parse(metadataJson || "{}");
|
||||
if (typeof meta.thinkingEffort === "string" && meta.thinkingEffort) {
|
||||
return meta.thinkingEffort;
|
||||
}
|
||||
} catch { /* malformed metadata */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[run] malformed agent metadataJson for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -251,13 +259,10 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
|
||||
const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot);
|
||||
let sessionHandoff = resolveSessionHandoff(task.id, deps);
|
||||
const initialHandoff = resolveSessionHandoff(task.id, deps);
|
||||
const { taskSession, sessionHandoff } = resolveSession(db, task, connector.id, initialHandoff, deps);
|
||||
const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps);
|
||||
|
||||
const skillNames = skills
|
||||
.map((s) => s.name ?? s.runtimeName)
|
||||
.filter(Boolean);
|
||||
|
||||
const systemPrompt = apiUrl ? buildTaskPrompt({
|
||||
agent,
|
||||
apiUrl,
|
||||
@@ -293,14 +298,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
recentDocuments: filterRecentDocuments(snapshot.documents, companyId, agent.id),
|
||||
allConnectors: buildConnectorCatalog(db, snapshot.connectors),
|
||||
sessionHandoff,
|
||||
skillNames,
|
||||
skillEntries: skills.map((s) => ({
|
||||
name: s.name ?? s.runtimeName,
|
||||
whenToUse: s.whenToUse,
|
||||
allowedTools: s.allowedTools,
|
||||
effort: s.effort,
|
||||
contextMode: s.contextMode,
|
||||
})),
|
||||
skills,
|
||||
companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
@@ -312,12 +310,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId),
|
||||
}) : null;
|
||||
|
||||
const { taskSession, sessionHandoff: finalHandoff } = resolveSession(db, task, connector.id, sessionHandoff, deps);
|
||||
if (finalHandoff !== sessionHandoff && systemPrompt && finalHandoff) {
|
||||
sessionHandoff = finalHandoff;
|
||||
}
|
||||
|
||||
const thinkingEffort = extractThinkingEffort(agent.metadataJson);
|
||||
const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id);
|
||||
const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model);
|
||||
if (!modelSelection.ok) {
|
||||
throw new Error(modelSelection.error);
|
||||
@@ -342,7 +335,6 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
sessionParams: taskSession.sessionParams,
|
||||
systemPrompt,
|
||||
trigger,
|
||||
managedSkills: skills.map((s) => ({ runtimeName: s.runtimeName, sourcePath: s.sourcePath })),
|
||||
taskSessionId: taskSession.id ?? runId,
|
||||
executionMode: db.getCompanyExecutionMode(companyId) as "ai_ceo" | "ai_cofounder",
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AppDatabase } from "../database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
|
||||
import type { RuntimeSkillEntry } from "../skill-types";
|
||||
import type { ProjectedSkill } from "../skill-types";
|
||||
|
||||
export type WorkspaceLockMode = "exclusive" | "shared" | "none";
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface EnqueueRunPayload {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
|
||||
/** Task session id used by SqliteSessionStore to load/append messages. */
|
||||
taskSessionId: string;
|
||||
/** Execution mode from the owning company. */
|
||||
@@ -67,7 +66,7 @@ export interface OrchestrationDependencies {
|
||||
hasWorkerReady: () => boolean;
|
||||
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
|
||||
isQuitting: () => boolean;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => RuntimeSkillEntry[];
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
/** Checkpoint engine for unified co-founder mode pause decisions. */
|
||||
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, KnowledgeEntryRecord, MeetingRecord, TaskRecord } from "@shared/types";
|
||||
import { inferTaskType } from "../connector-matching";
|
||||
import { resolveAttachmentContent } from "./attachment-resolver";
|
||||
@@ -112,7 +113,11 @@ export function buildMessagesSection(ctx: MessagesSectionCtx): string | null {
|
||||
if (Array.isArray(atts) && atts.length > 0) {
|
||||
attachmentNote = ` [Attached: ${atts.map((a) => a.title ?? "document").join(", ")}]`;
|
||||
}
|
||||
} catch { /* malformed attachment JSON */ }
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[prompt-builder] malformed attachmentsJson on message ${m.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (ctx.companyDocuments && ctx.companyDocuments.length > 0) {
|
||||
attachmentContent = resolveAttachmentContent(m.attachmentsJson, ctx.companyDocuments, 4000);
|
||||
}
|
||||
@@ -142,7 +147,14 @@ export function buildMeetingsSection(ctx: MeetingsSectionCtx): string | null {
|
||||
const meetingLines = ctx.upcomingMeetings.slice(0, 5).map((m) => {
|
||||
const organizer = m.organizerAgentId ? (ctx.allAgents.find(a => a.id === m.organizerAgentId)?.name ?? "Unknown") : "TBD";
|
||||
let participants: string[];
|
||||
try { participants = JSON.parse(m.participantAgentIds); } catch { participants = []; }
|
||||
try {
|
||||
participants = JSON.parse(m.participantAgentIds);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`[prompt-builder] malformed participantAgentIds on meeting ${m.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
participants = [];
|
||||
}
|
||||
const participantNames = participants
|
||||
.map(id => ctx.allAgents.find(a => a.id === id)?.name ?? id)
|
||||
.join(", ");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentRecord, AgentMessageRecord, ApprovalRecord, DocumentRecord, ExecutionMode, GoalRecord, KnowledgeEntryRecord, MeetingRecord, ProjectRecord, SprintRecord, TaskRecord } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import type { ProjectedSkill } from "../skill-types";
|
||||
import { buildSystemPrompt } from "./system-prompt";
|
||||
import { renderWakeReason } from "./wake-reasons";
|
||||
import { getSystemMessages } from "./messages";
|
||||
@@ -35,10 +36,8 @@ export interface PromptContext {
|
||||
allConnectors?: Array<{ id: string; label: string; status: string; enabledModels?: 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[];
|
||||
/** Full skill entries with semantic metadata for capability manifest */
|
||||
skillEntries?: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string; contextMode: string }>;
|
||||
/** Skills visible to this agent via pi's progressive-disclosure model. */
|
||||
skills?: ProjectedSkill[];
|
||||
/** All company documents for attachment content resolution in messages */
|
||||
companyDocuments?: DocumentRecord[];
|
||||
/** Company-wide skill catalog for capability-aware delegation. */
|
||||
@@ -52,6 +51,15 @@ export function buildTaskPrompt(ctx: PromptContext): string {
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
const parts: string[] = [systemPrompt];
|
||||
|
||||
// Volatile per-turn context lives AFTER the cacheable system prefix so the
|
||||
// Anthropic prompt cache hits across heartbeats. Putting the wall-clock
|
||||
// time inside the system prompt would invalidate the cache on every call.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split("T")[0];
|
||||
const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00";
|
||||
const dayOfWeek = m.dayNames[now.getUTCDay()] ?? "";
|
||||
parts.push(`\n${m.currentDateSection(dayOfWeek, dateStr, timeStr)}`);
|
||||
|
||||
if (ctx.wakeReason) {
|
||||
const explanation = renderWakeReason(ctx.wakeReason, {
|
||||
companyId: ctx.agent.companyId,
|
||||
|
||||
@@ -270,20 +270,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId:
|
||||
`;
|
||||
}
|
||||
|
||||
export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string {
|
||||
const lines = entries.map((s) => {
|
||||
const parts = [`- ${s.name}`];
|
||||
if (s.whenToUse) parts.push(` Use when: ${s.whenToUse}`);
|
||||
if (s.allowedTools.length > 0) parts.push(` Allowed tools: ${s.allowedTools.join(", ")}`);
|
||||
if (s.effort !== "medium") parts.push(` Effort: ${s.effort}`);
|
||||
return parts.join("\n");
|
||||
});
|
||||
return `Your domain skills:\n${lines.join("\n")}\nThese skills are loaded into your environment — use them when relevant to your work.`;
|
||||
}
|
||||
|
||||
export function skillManifestNames(names: string[]): string {
|
||||
return `Your domain skills: ${names.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.`;
|
||||
}
|
||||
|
||||
export const wakeReasonHeading = "# Wake Reason";
|
||||
|
||||
|
||||
@@ -265,20 +265,6 @@ export function importantRulesSection(companyId: string, agentId: string, runId:
|
||||
`;
|
||||
}
|
||||
|
||||
export function skillManifestEntries(entries: Array<{ name: string; whenToUse: string; allowedTools: string[]; effort: string }>): string {
|
||||
const lines = entries.map((s) => {
|
||||
const parts = [`- ${s.name}`];
|
||||
if (s.whenToUse) parts.push(` 使用场景:${s.whenToUse}`);
|
||||
if (s.allowedTools.length > 0) parts.push(` 允许工具:${s.allowedTools.join("、")}`);
|
||||
if (s.effort !== "medium") parts.push(` 资源消耗:${s.effort}`);
|
||||
return parts.join("\n");
|
||||
});
|
||||
return `你的领域 skill:\n${lines.join("\n")}\n这些 skill 已加载到你的运行环境 —— 相关时请使用它们。`;
|
||||
}
|
||||
|
||||
export function skillManifestNames(names: string[]): string {
|
||||
return `你的领域 skill:${names.join("、")}。这些 skill 已加载到你的运行环境,为你提供领域专长 —— 相关时请使用它们。`;
|
||||
}
|
||||
|
||||
export const wakeReasonHeading = "# 唤醒原因";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEPARTMENT_LABELS } from "@shared/constants";
|
||||
import type { DepartmentId } from "@shared/types";
|
||||
import { formatSkillsForPrompt } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
buildTaskSection,
|
||||
buildReviewSection,
|
||||
@@ -18,9 +19,9 @@ import {
|
||||
buildDepartmentSection,
|
||||
} from "./operating-instructions";
|
||||
import type { PromptContext } from "./index";
|
||||
import type { ProjectedSkill } from "../skill-types";
|
||||
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
|
||||
import { getSystemMessages } from "./messages";
|
||||
import type { SystemMessages } from "./messages";
|
||||
|
||||
function isLeadershipRole(ctx: PromptContext): boolean {
|
||||
// HR roles inherit leadership scope for manager-style prompts;
|
||||
@@ -29,14 +30,28 @@ function isLeadershipRole(ctx: PromptContext): boolean {
|
||||
return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null);
|
||||
}
|
||||
|
||||
function buildSkillManifest(
|
||||
m: SystemMessages,
|
||||
skillEntries?: PromptContext["skillEntries"],
|
||||
skillNames?: string[],
|
||||
): string {
|
||||
if (skillEntries && skillEntries.length > 0) return m.skillManifestEntries(skillEntries);
|
||||
if (skillNames && skillNames.length > 0) return m.skillManifestNames(skillNames);
|
||||
return "";
|
||||
/**
|
||||
* Render the skill manifest block using pi-coding-agent's XML format.
|
||||
*
|
||||
* The full SKILL.md content is NOT injected — only name/description/filePath.
|
||||
* The agent loads SKILL.md on demand via the `read` tool (progressive
|
||||
* disclosure). Pi's `formatSkillsForPrompt` expects its full `Skill` shape, so
|
||||
* we supply synthetic values for the two fields we don't track (`sourceInfo`,
|
||||
* `disableModelInvocation`). Cast is safe because pi's XML renderer only reads
|
||||
* `name`, `description`, `filePath`, and `disableModelInvocation`.
|
||||
*/
|
||||
function buildSkillManifest(skills: ProjectedSkill[] | undefined): string {
|
||||
if (!skills || skills.length === 0) return "";
|
||||
const piShapedSkills = skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
filePath: s.filePath,
|
||||
baseDir: s.baseDir,
|
||||
disableModelInvocation: false,
|
||||
// sourceInfo is required by pi's Skill interface but never read by
|
||||
// formatSkillsForPrompt. Leaving it undefined and asserting the shape.
|
||||
}));
|
||||
return formatSkillsForPrompt(piShapedSkills as never);
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
@@ -46,11 +61,10 @@ export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
const isCEO = ctx.chainOfCommand.length === 0;
|
||||
const isManager = !isCEO && (ctx.directReports.length > 0 || isLeadershipRole(ctx));
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split("T")[0];
|
||||
const timeStr = now.toISOString().split("T")[1]?.slice(0, 5) ?? "00:00";
|
||||
const dayOfWeek = m.dayNames[now.getUTCDay()] ?? "";
|
||||
sections.push(m.currentDateSection(dayOfWeek, dateStr, timeStr));
|
||||
// NOTE: the current date/time is intentionally NOT rendered here. This
|
||||
// function's output is intended to be cacheable by the Anthropic prompt
|
||||
// cache keyed by a stable prefix; per-turn volatile values live in the
|
||||
// buildTaskPrompt() tail so the cached prefix hits across heartbeats.
|
||||
|
||||
const budgetPct = ctx.agent.budgetMonthlyUsd > 0
|
||||
? Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)
|
||||
@@ -64,7 +78,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
budgetMonthly: ctx.agent.budgetMonthlyUsd,
|
||||
spentMonthly: ctx.agent.spentMonthlyUsd,
|
||||
budgetPctUsed: budgetPct,
|
||||
skillManifest: buildSkillManifest(m, ctx.skillEntries, ctx.skillNames),
|
||||
skillManifest: buildSkillManifest(ctx.skills),
|
||||
}));
|
||||
|
||||
const capabilityRegistrySection = buildCapabilityRegistrySection({
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { ThreadIntegrationService } from "./thread-integration";
|
||||
|
||||
interface RunHousekeepingDeps {
|
||||
db: AppDatabase;
|
||||
logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
threadIntegration: ThreadIntegrationService;
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ function shouldProduceKnowledge(
|
||||
db: AppDatabase,
|
||||
taskId: string,
|
||||
agentId: string,
|
||||
logger: RunHousekeepingDeps["logger"],
|
||||
): boolean {
|
||||
try {
|
||||
const taskTypeRow = db.queryAll<{ taskType: string | null }>(
|
||||
@@ -43,13 +48,18 @@ function shouldProduceKnowledge(
|
||||
if (taskTypeRow?.taskType && KNOWLEDGE_PRODUCING_TASK_TYPES.has(taskTypeRow.taskType)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Task deleted; fall through to agent check
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[run-housekeeping] task lookup failed for ${taskId}; falling back to agent department check: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
return Boolean(agent.department && KNOWLEDGE_PRODUCING_DEPARTMENTS.has(agent.department));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[run-housekeeping] agent lookup failed for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -79,14 +89,18 @@ export function createRunHousekeeping({ db, logger, threadIntegration }: RunHous
|
||||
status === "succeeded" &&
|
||||
summary &&
|
||||
summary.length > 50 &&
|
||||
shouldProduceKnowledge(db, taskId, agentId)
|
||||
shouldProduceKnowledge(db, taskId, agentId, logger)
|
||||
) {
|
||||
try {
|
||||
let topic = `Run ${runId.slice(0, 8)}`;
|
||||
try {
|
||||
const task = db.getTask(taskId);
|
||||
topic = task.title;
|
||||
} catch { /* task may have been deleted */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[run-housekeeping] getTask failed for ${taskId}; using run id as topic: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
db.saveKnowledgeEntry({
|
||||
companyId,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import type { createSessionManagementService } from "./session-management-service";
|
||||
import type { RuntimeSkillEntry } from "./skill-types";
|
||||
import type { ProjectedSkill } from "./skill-types";
|
||||
|
||||
interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
@@ -16,7 +16,7 @@ interface Logger {
|
||||
|
||||
export function makeListRuntimeSkillEntries(deps: { db: AppDatabase; logger: Logger }) {
|
||||
let cache: ReturnType<typeof createSkillService> | null = null;
|
||||
return (companyId: string, agentId: string, connectorId: string): RuntimeSkillEntry[] => {
|
||||
return (companyId: string, agentId: string, connectorId: string): ProjectedSkill[] => {
|
||||
try {
|
||||
if (!cache) cache = createSkillService({ db: deps.db, profileDir: deps.db.profileDir, logger: deps.logger });
|
||||
return cache.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
|
||||
@@ -51,8 +51,6 @@ export function createPiRuntimeServices(deps: PiRuntimeServicesDeps): PiRuntimeS
|
||||
credentialResolver,
|
||||
oauthLoginService,
|
||||
runtimeBridge,
|
||||
release: () => {
|
||||
runtimeBridge.approvalHook.cancelAll("AgentCompany shutting down.");
|
||||
},
|
||||
release: () => {},
|
||||
};
|
||||
}
|
||||
+131
-236
@@ -1,23 +1,25 @@
|
||||
import { app, nativeTheme } from "electron";
|
||||
import { nativeTheme } from "electron";
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
import { createAutomationService, autoAssignSkillsToAgent } from "./automation-service";
|
||||
import { createBrowserActionService } from "./browser-action-service";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { createConnectorHealthService } from "./connector-health-service";
|
||||
import { createDesktopShellService } from "./desktop-shell-service";
|
||||
import { createOnboardingService } from "./onboarding-service";
|
||||
import { createOrchestrationService } from "./orchestration-service";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import { createUpdaterService } from "./updater-service";
|
||||
import { createWorkerService } from "./worker-service";
|
||||
import { createAppLifecycleService } from "./app-lifecycle-service";
|
||||
import { createBudgetEnforcementService } from "./budget-enforcement-service";
|
||||
import { createSessionManagementService } from "./session-management-service";
|
||||
import { createPiRuntimeServices, type PiRuntimeServices } from "./service-graph-pi-runtime";
|
||||
import type { DesktopEvent, RunRecord } from "@shared/types";
|
||||
import type { AutomationEventContext } from "./automation-service";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
import type { createAutomationService } from "./automation-service";
|
||||
import type { createBrowserActionService } from "./browser-action-service";
|
||||
import type { createOnboardingService } from "./onboarding-service";
|
||||
import type { createOrchestrationService } from "./orchestration-service";
|
||||
import type { createUpdaterService } from "./updater-service";
|
||||
import { createLeafServices } from "./service-graph/create-leaf-services";
|
||||
import { createDesktopShell } from "./service-graph/create-desktop-shell";
|
||||
import { buildWorkerService } from "./service-graph/create-worker-service";
|
||||
import { buildOrchestrationService } from "./service-graph/create-orchestration-service";
|
||||
import { createBackgroundServices } from "./service-graph/create-background-services";
|
||||
import { createLifecycle } from "./service-graph/create-lifecycle";
|
||||
import { LateBinding } from "./service-graph/late-binding";
|
||||
|
||||
interface ServiceGraphDependencies {
|
||||
db: AppDatabase;
|
||||
@@ -46,145 +48,93 @@ interface ServiceGraphDependencies {
|
||||
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
|
||||
/** Checkpoint engine for unified co-founder mode pause decisions in scheduler. */
|
||||
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
|
||||
/** Trusted-sender IPC wrapper from createIpcSupport. Window-control channels use it. */
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createServiceGraph({
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
onRunStarted,
|
||||
onRunCompleted,
|
||||
checkpointEngine,
|
||||
}: ServiceGraphDependencies) {
|
||||
/**
|
||||
* Thin orchestrator: assembles services in a deterministic topological order
|
||||
* and exposes them via read-only getters. All wiring is performed via
|
||||
* explicit dependency passing; late-bound cycles are handled by
|
||||
* `LateBinding` slots that throw if accessed before assignment.
|
||||
*/
|
||||
export function createServiceGraph(deps: ServiceGraphDependencies) {
|
||||
const {
|
||||
db,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
isQuittingRef,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
workerPath,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
createRunToken,
|
||||
seedDefaultAutomationRules,
|
||||
onRunStarted,
|
||||
onRunCompleted,
|
||||
checkpointEngine,
|
||||
registerHandle,
|
||||
} = deps;
|
||||
|
||||
// Service slots owned by the graph. Null before init() completes and after
|
||||
// the lifecycle drain has torn services down.
|
||||
let desktopShellService: ReturnType<typeof createDesktopShell> | null = null;
|
||||
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
|
||||
let workerService: ReturnType<typeof buildWorkerService> | null = null;
|
||||
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
|
||||
let automationService: ReturnType<typeof createAutomationService> | null = null;
|
||||
let browserActionService: ReturnType<typeof createBrowserActionService> | null = null;
|
||||
let connectorHealthService: ReturnType<typeof createConnectorHealthService> | null = null;
|
||||
let onboardingService: ReturnType<typeof createOnboardingService> | null = null;
|
||||
let desktopShellService: ReturnType<typeof createDesktopShellService> | null = null;
|
||||
let orchestrationService: ReturnType<typeof createOrchestrationService> | null = null;
|
||||
let workerService: ReturnType<typeof createWorkerService> | null = null;
|
||||
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createAppLifecycleService> | null = null;
|
||||
// Eagerly constructed inside init() and resolved via getOrCreate* getters.
|
||||
let skillService: ReturnType<typeof createSkillService> | null = null;
|
||||
let budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService> | null = null;
|
||||
let sessionManagementService: ReturnType<typeof createSessionManagementService> | null = null;
|
||||
let appLifecycleService: ReturnType<typeof createLifecycle> | null = null;
|
||||
let leaf: ReturnType<typeof createLeafServices> | null = null;
|
||||
const piRuntimeRef: { current: PiRuntimeServices | null } = { current: null };
|
||||
|
||||
function getOrCreateSkillService() {
|
||||
if (!skillService) throw new Error("Skill service is not initialized.");
|
||||
return skillService;
|
||||
// Late-bound slots resolve circular construction: worker needs orchestration
|
||||
// (for dispatchRunToWorker), orchestration needs worker (for enqueueWorkerRun),
|
||||
// orchestration needs automation (for dispatchAutomation), automation/orchestration
|
||||
// need browser-action (for dispatchBrowserAction). Each is filled inside
|
||||
// init() before the corresponding getter closure is ever invoked.
|
||||
const orchestrationRef = new LateBinding<ReturnType<typeof createOrchestrationService>>("orchestrationService");
|
||||
const automationRef = new LateBinding<ReturnType<typeof createAutomationService>>("automationService");
|
||||
const browserActionRef = new LateBinding<ReturnType<typeof createBrowserActionService>>("browserActionService");
|
||||
const updaterRef = new LateBinding<ReturnType<typeof createUpdaterService>>("updaterService");
|
||||
|
||||
function requireLeaf() {
|
||||
if (!leaf) throw new Error("Leaf services are not initialized.");
|
||||
return leaf;
|
||||
}
|
||||
|
||||
function getOrCreateBudgetEnforcementService() {
|
||||
if (!budgetEnforcementService) throw new Error("Budget enforcement service is not initialized.");
|
||||
return budgetEnforcementService;
|
||||
}
|
||||
|
||||
function getOrCreateSessionManagementService() {
|
||||
if (!sessionManagementService) throw new Error("Session management service is not initialized.");
|
||||
return sessionManagementService;
|
||||
}
|
||||
|
||||
const wakeAgentIfPossible =(agentId: string | null | undefined, companyId: string, trigger: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
};
|
||||
|
||||
const dispatchBrowserAction = async (actionId: string, companyId: string) => {
|
||||
if (!browserActionService) {
|
||||
throw new Error("Browser action service is not initialized");
|
||||
}
|
||||
return browserActionService.dispatchBrowserAction(actionId, companyId);
|
||||
};
|
||||
|
||||
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.dispatchAutomation(trigger, ctx);
|
||||
};
|
||||
|
||||
const processRunCompletion = (run: RunRecord, event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1]) => {
|
||||
if (!automationService) {
|
||||
throw new Error("Automation service is not initialized.");
|
||||
}
|
||||
automationService.processRunCompletion(run, event);
|
||||
};
|
||||
|
||||
const queueTaskRun = (taskId: string, trigger: string, actor: string) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.queueTaskRun(taskId, trigger, actor);
|
||||
};
|
||||
|
||||
const dispatchRunToWorker = (params: Parameters<ReturnType<typeof createOrchestrationService>["dispatchRunToWorker"]>[0]) => {
|
||||
if (!orchestrationService) {
|
||||
throw new Error("Orchestration service is not initialized.");
|
||||
}
|
||||
return orchestrationService.dispatchRunToWorker(params);
|
||||
};
|
||||
|
||||
function init() {
|
||||
nativeTheme.themeSource = db.listSnapshot().theme;
|
||||
// Eagerly construct leaf services that other services depend on. Keeps
|
||||
// the graph deterministic and lets getOrCreate* getters stay pure readers.
|
||||
skillService = createSkillService({ db, profileDir: db.profileDir, logger });
|
||||
budgetEnforcementService = createBudgetEnforcementService({ db, logger, notify, publishDomainChanged });
|
||||
sessionManagementService = createSessionManagementService({ db, logger });
|
||||
|
||||
desktopShellService = createDesktopShellService({
|
||||
leaf = createLeafServices({ db, logger, notify, publishDomainChanged });
|
||||
|
||||
desktopShellService = createDesktopShell({
|
||||
db,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onRequestQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
if (!updaterService) throw new Error("Updater service is not initialized.");
|
||||
await updaterService.checkForUpdates();
|
||||
},
|
||||
onBackupProfile: async () => {
|
||||
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
}));
|
||||
if (target.canceled || target.filePaths.length === 0) return;
|
||||
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
|
||||
},
|
||||
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
||||
isQuittingRef,
|
||||
emitEvent,
|
||||
notify,
|
||||
getActiveRunCount: () => {
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
|
||||
} catch (error) {
|
||||
logger.warn("[service-graph] getActiveRunCount failed:", error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
registerHandle,
|
||||
updaterRef,
|
||||
logger,
|
||||
});
|
||||
desktopShellService.initialize();
|
||||
|
||||
piRuntimeRef.current = createPiRuntimeServices({
|
||||
db,
|
||||
@@ -204,25 +154,24 @@ export function createServiceGraph({
|
||||
credentialStore: piRuntimeRef.current.credentialStore,
|
||||
});
|
||||
|
||||
workerService = createWorkerService({
|
||||
workerService = buildWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
isQuittingRef,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion: (run, event) => processRunCompletion(run, event),
|
||||
orchestrationRef,
|
||||
automationRef,
|
||||
onRunStarted,
|
||||
onRunCompleted,
|
||||
notify,
|
||||
budgetService: getOrCreateBudgetEnforcementService(),
|
||||
sessionService: getOrCreateSessionManagementService(),
|
||||
getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null,
|
||||
budgetService: leaf.budgetEnforcementService,
|
||||
sessionService: leaf.sessionManagementService,
|
||||
piRuntimeRef,
|
||||
});
|
||||
|
||||
orchestrationService = createOrchestrationService({
|
||||
orchestrationService = buildOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
@@ -230,104 +179,52 @@ export function createServiceGraph({
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort: () => apiServerRef.current?.port ?? 0,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady: () => Boolean(workerService?.getWorker()),
|
||||
enqueueWorkerRun: (payload) => {
|
||||
workerService?.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => {
|
||||
try {
|
||||
return getOrCreateSkillService().listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
budgetService: getOrCreateBudgetEnforcementService(),
|
||||
isQuittingRef,
|
||||
skillService: leaf.skillService,
|
||||
budgetService: leaf.budgetEnforcementService,
|
||||
sessionService: leaf.sessionManagementService,
|
||||
workerService,
|
||||
automationRef,
|
||||
browserActionRef,
|
||||
checkpointEngine,
|
||||
getSessionHandoff: (taskId: string) => {
|
||||
try {
|
||||
return getOrCreateSessionManagementService().getHandoffContext(taskId);
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
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 (error) {
|
||||
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
}
|
||||
},
|
||||
});
|
||||
orchestrationRef.set(orchestrationService);
|
||||
|
||||
workerService.createWorker();
|
||||
|
||||
automationService = createAutomationService({
|
||||
const background = createBackgroundServices({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
});
|
||||
|
||||
onboardingService = createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun,
|
||||
publishDomainChanged,
|
||||
scanAndAssignSkills: async (companyId: string, agentId: string) => {
|
||||
const svc = getOrCreateSkillService();
|
||||
await svc.seedBuiltinSkills(companyId);
|
||||
svc.assignRoleSkillsToAgent(companyId, agentId, "CEO");
|
||||
await svc.scanWorkspaceSkills(companyId);
|
||||
autoAssignSkillsToAgent(db, logger, companyId, agentId);
|
||||
},
|
||||
});
|
||||
|
||||
browserActionService = createBrowserActionService({
|
||||
db,
|
||||
isQuittingRef,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
skillService: leaf.skillService,
|
||||
orchestrationService,
|
||||
seedDefaultAutomationRules,
|
||||
});
|
||||
browserActionService.resetBrowserManager();
|
||||
automationService = background.automationService;
|
||||
browserActionService = background.browserActionService;
|
||||
onboardingService = background.onboardingService;
|
||||
updaterService = background.updaterService;
|
||||
automationRef.set(automationService);
|
||||
browserActionRef.set(browserActionService);
|
||||
updaterRef.set(updaterService);
|
||||
|
||||
updaterService = createUpdaterService({
|
||||
appLifecycleService = createLifecycle({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
});
|
||||
updaterService.attachListeners();
|
||||
|
||||
appLifecycleService = createAppLifecycleService({
|
||||
getMainWindow: () => desktopShellService?.getMainWindow() ?? null,
|
||||
ensureWindow: async () => desktopShellService?.ensureWindow() ?? null,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onBeforeQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
orchestrationService?.stopAllSchedulers();
|
||||
automationService?.destroy();
|
||||
apiServerRef.current?.stop();
|
||||
apiServerRef.current = null;
|
||||
browserManagerRef.current?.shutdown();
|
||||
browserManagerRef.current = null;
|
||||
desktopShellService?.destroy();
|
||||
isQuittingRef,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
piRuntimeRef,
|
||||
orchestrationService,
|
||||
automationService,
|
||||
workerService,
|
||||
desktopShellService,
|
||||
clearServiceRefs: () => {
|
||||
desktopShellService = null;
|
||||
workerService?.destroy();
|
||||
workerService = null;
|
||||
browserActionService = null;
|
||||
automationService = null;
|
||||
@@ -336,16 +233,14 @@ export function createServiceGraph({
|
||||
orchestrationService = null;
|
||||
updaterService = null;
|
||||
appLifecycleService = null;
|
||||
skillService = null;
|
||||
budgetEnforcementService = null;
|
||||
sessionManagementService = null;
|
||||
piRuntimeRef.current?.release();
|
||||
piRuntimeRef.current = null;
|
||||
db.close();
|
||||
// Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux)
|
||||
// Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely
|
||||
setTimeout(() => { app.exit(0); }, 3000);
|
||||
leaf = null;
|
||||
},
|
||||
lateBindings: [
|
||||
orchestrationRef as LateBinding<unknown>,
|
||||
automationRef as LateBinding<unknown>,
|
||||
browserActionRef as LateBinding<unknown>,
|
||||
updaterRef as LateBinding<unknown>,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,9 +255,9 @@ export function createServiceGraph({
|
||||
getOnboardingService: () => onboardingService,
|
||||
getUpdaterService: () => updaterService,
|
||||
getAppLifecycleService: () => appLifecycleService,
|
||||
getOrCreateSkillService,
|
||||
getOrCreateBudgetEnforcementService,
|
||||
getOrCreateSessionManagementService,
|
||||
getOrCreateSkillService: () => requireLeaf().skillService,
|
||||
getOrCreateBudgetEnforcementService: () => requireLeaf().budgetEnforcementService,
|
||||
getOrCreateSessionManagementService: () => requireLeaf().sessionManagementService,
|
||||
getPiRuntime: () => piRuntimeRef.current,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import { BrowserManager } from "../browser-manager";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { createAutomationService } from "../automation-service";
|
||||
import { createBrowserActionService } from "../browser-action-service";
|
||||
import { createOnboardingService } from "../onboarding-service";
|
||||
import { createUpdaterService } from "../updater-service";
|
||||
import type { createOrchestrationService } from "../orchestration-service";
|
||||
import type { createSkillService } from "../skill-service";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
|
||||
export interface BackgroundServicesDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
publishDomainChanged: () => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
isQuittingRef: { current: boolean };
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
skillService: ReturnType<typeof createSkillService>;
|
||||
orchestrationService: ReturnType<typeof createOrchestrationService>;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
}
|
||||
|
||||
export interface BackgroundServices {
|
||||
automationService: ReturnType<typeof createAutomationService>;
|
||||
browserActionService: ReturnType<typeof createBrowserActionService>;
|
||||
onboardingService: ReturnType<typeof createOnboardingService>;
|
||||
updaterService: ReturnType<typeof createUpdaterService>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build background services that run off the main dispatch loop: automation
|
||||
* triggers, browser-action dispatch, onboarding hire flow, and the updater.
|
||||
* Orchestration is fully assembled at this point, so all dependencies are
|
||||
* concrete references rather than lazy slots.
|
||||
*/
|
||||
export function createBackgroundServices({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
isQuittingRef,
|
||||
browserManagerRef,
|
||||
skillService,
|
||||
orchestrationService,
|
||||
seedDefaultAutomationRules,
|
||||
}: BackgroundServicesDeps): BackgroundServices {
|
||||
const browserActionService = createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef,
|
||||
publishDomainChanged,
|
||||
});
|
||||
browserActionService.resetBrowserManager();
|
||||
|
||||
const dispatchBrowserAction = async (actionId: string, companyId: string) =>
|
||||
browserActionService.dispatchBrowserAction(actionId, companyId);
|
||||
|
||||
const automationService = createAutomationService({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) =>
|
||||
orchestrationService.wakeAgentIfPossible(agentId, companyId, trigger),
|
||||
dispatchBrowserAction,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
});
|
||||
|
||||
const onboardingService = createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules,
|
||||
queueTaskRun: (taskId, trigger, actor) =>
|
||||
orchestrationService.queueTaskRun(taskId, trigger, actor),
|
||||
publishDomainChanged,
|
||||
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
|
||||
// All company skills are visible to every agent via progressive disclosure.
|
||||
// Seed the builtin catalog + any workspace-local SKILL.md bundles.
|
||||
await skillService.seedBuiltinSkills(companyId);
|
||||
await skillService.scanWorkspaceSkills(companyId);
|
||||
},
|
||||
});
|
||||
|
||||
const updaterService = createUpdaterService({ db, logger, notify });
|
||||
updaterService.attachListeners();
|
||||
|
||||
return { automationService, browserActionService, onboardingService, updaterService };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { createDesktopShellService } from "../desktop-shell-service";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { LateBinding } from "./late-binding";
|
||||
import type { createUpdaterService } from "../updater-service";
|
||||
|
||||
export interface DesktopShellDeps {
|
||||
db: AppDatabase;
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
iconPath: string;
|
||||
isDev: boolean;
|
||||
devServerUrl?: string;
|
||||
windowStatePath: string;
|
||||
isQuittingRef: { current: boolean };
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
registerHandle: (
|
||||
channel: string,
|
||||
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
|
||||
) => void;
|
||||
updaterRef: LateBinding<ReturnType<typeof createUpdaterService>>;
|
||||
logger: { warn: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the desktop shell service. `updaterRef` is late-bound because the
|
||||
* updater is constructed after the shell; the menu's "Check for updates"
|
||||
* handler resolves it at call time.
|
||||
*/
|
||||
export function createDesktopShell({
|
||||
db,
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
isQuittingRef,
|
||||
emitEvent,
|
||||
notify,
|
||||
registerHandle,
|
||||
updaterRef,
|
||||
logger,
|
||||
}: DesktopShellDeps) {
|
||||
const desktopShellService = createDesktopShellService({
|
||||
preloadPath,
|
||||
rendererDist,
|
||||
iconPath,
|
||||
isDev,
|
||||
devServerUrl,
|
||||
windowStatePath,
|
||||
registerHandle,
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onRequestQuit: () => {
|
||||
isQuittingRef.current = true;
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
await updaterRef.get().checkForUpdates();
|
||||
},
|
||||
onBackupProfile: async () => {
|
||||
const target = await import("electron").then(({ dialog }) => dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
}));
|
||||
if (target.canceled || target.filePaths.length === 0) return;
|
||||
await db.exportBackup(`${target.filePaths[0]!}/agentcompany-backup-${Date.now()}`);
|
||||
},
|
||||
onShortcut: (action) => emitEvent({ type: "shortcut", action }),
|
||||
notify,
|
||||
getActiveRunCount: () => {
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
return snapshot.runs.filter((r) => r.status === "running" || r.status === "queued").length;
|
||||
} catch (error) {
|
||||
logger.warn("[service-graph] getActiveRunCount failed:", error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
desktopShellService.initialize();
|
||||
return desktopShellService;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import { createBudgetEnforcementService } from "../budget-enforcement-service";
|
||||
import { createSessionManagementService } from "../session-management-service";
|
||||
import { createSkillService } from "../skill-service";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
|
||||
export interface LeafServicesDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
notify: (options: NotificationOptions) => void;
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export interface LeafServices {
|
||||
skillService: ReturnType<typeof createSkillService>;
|
||||
budgetEnforcementService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
sessionManagementService: ReturnType<typeof createSessionManagementService>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build leaf services that no other service depends on except through this
|
||||
* bundle. Constructed first so downstream factories can receive concrete
|
||||
* references without null-checks.
|
||||
*/
|
||||
export function createLeafServices({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
publishDomainChanged,
|
||||
}: LeafServicesDeps): LeafServices {
|
||||
const skillService = createSkillService({ db, profileDir: db.profileDir, logger });
|
||||
const budgetEnforcementService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger,
|
||||
notify,
|
||||
publishDomainChanged,
|
||||
});
|
||||
const sessionManagementService = createSessionManagementService({ db, logger });
|
||||
|
||||
return { skillService, budgetEnforcementService, sessionManagementService };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { app } from "electron";
|
||||
import type { AgentApiServer } from "../api-server";
|
||||
import type { AppDatabase } from "../database";
|
||||
import { BrowserManager } from "../browser-manager";
|
||||
import { createAppLifecycleService } from "../app-lifecycle-service";
|
||||
import type { createAutomationService } from "../automation-service";
|
||||
import type { createDesktopShellService } from "../desktop-shell-service";
|
||||
import type { createOrchestrationService } from "../orchestration-service";
|
||||
import type { createWorkerService } from "../worker-service";
|
||||
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
|
||||
import type { LateBinding } from "./late-binding";
|
||||
|
||||
/** Overall budget for awaited shutdown drain (worker exit + API close). */
|
||||
const SHUTDOWN_BUDGET_MS = 5_000;
|
||||
|
||||
export interface LifecycleDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
isQuittingRef: { current: boolean };
|
||||
apiServerRef: { current: AgentApiServer | null };
|
||||
browserManagerRef: { current: BrowserManager | null };
|
||||
piRuntimeRef: { current: PiRuntimeServices | null };
|
||||
orchestrationService: ReturnType<typeof createOrchestrationService>;
|
||||
automationService: ReturnType<typeof createAutomationService>;
|
||||
workerService: ReturnType<typeof createWorkerService>;
|
||||
desktopShellService: ReturnType<typeof createDesktopShellService>;
|
||||
/**
|
||||
* Clears graph-owned service refs (so getters return null) after the drain
|
||||
* completes. Mirrors the original synchronous null-out at the tail of the
|
||||
* old onBeforeQuit.
|
||||
*/
|
||||
clearServiceRefs: () => void;
|
||||
/**
|
||||
* Late bindings owned by the graph; cleared after drain so any stray
|
||||
* post-quit access fails loudly rather than returning stale data.
|
||||
*/
|
||||
lateBindings: Array<LateBinding<unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the app lifecycle service and wire a deterministic shutdown drain
|
||||
* that awaits the utility-process worker's exit (and API server close)
|
||||
* before calling app.exit(). On 5s timeout we log the stuck run ids and
|
||||
* exit(1) so operators see an unclean shutdown rather than a silent
|
||||
* force-kill.
|
||||
*/
|
||||
export function createLifecycle(deps: LifecycleDeps) {
|
||||
const {
|
||||
db,
|
||||
logger,
|
||||
isQuittingRef,
|
||||
apiServerRef,
|
||||
browserManagerRef,
|
||||
piRuntimeRef,
|
||||
orchestrationService,
|
||||
automationService,
|
||||
workerService,
|
||||
desktopShellService,
|
||||
clearServiceRefs,
|
||||
lateBindings,
|
||||
} = deps;
|
||||
|
||||
let drainPromise: Promise<number> | null = null;
|
||||
|
||||
function listActiveRunIds(): string[] {
|
||||
try {
|
||||
return db.listSnapshot().runs
|
||||
.filter((r) => r.status === "running" || r.status === "queued")
|
||||
.map((r) => r.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(): Promise<number> {
|
||||
// Stop accepting new scheduled work first; cancel in-flight runs in the
|
||||
// worker via the utility-process shutdown postMessage inside destroy().
|
||||
try { orchestrationService.stopAllSchedulers(); } catch (err) { logger.warn("[lifecycle] stopAllSchedulers failed:", err); }
|
||||
try { automationService.destroy(); } catch (err) { logger.warn("[lifecycle] automationService.destroy failed:", err); }
|
||||
try { apiServerRef.current?.stop(); } catch (err) { logger.warn("[lifecycle] apiServer.stop failed:", err); }
|
||||
apiServerRef.current = null;
|
||||
try { browserManagerRef.current?.shutdown(); } catch (err) { logger.warn("[lifecycle] browserManager.shutdown failed:", err); }
|
||||
browserManagerRef.current = null;
|
||||
|
||||
// workerService.destroy() sends "shutdown" to the utility process and has
|
||||
// its own 2s SIGKILL safety net. Awaiting the worker's exit event closes
|
||||
// the remaining event-loop holder (the utilityProcess handle).
|
||||
const workerExit = new Promise<void>((resolve) => {
|
||||
const worker = workerService.getWorker();
|
||||
if (!worker) { resolve(); return; }
|
||||
worker.once("exit", () => resolve());
|
||||
});
|
||||
workerService.destroy();
|
||||
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
const handle = setTimeout(() => resolve("timeout"), SHUTDOWN_BUDGET_MS);
|
||||
if (typeof handle.unref === "function") handle.unref();
|
||||
});
|
||||
const outcome = await Promise.race([
|
||||
workerExit.then(() => "drained" as const),
|
||||
timeout,
|
||||
]);
|
||||
|
||||
try { desktopShellService.destroy(); } catch (err) { logger.warn("[lifecycle] desktopShell.destroy failed:", err); }
|
||||
try { piRuntimeRef.current?.release(); } catch (err) { logger.warn("[lifecycle] piRuntime.release failed:", err); }
|
||||
piRuntimeRef.current = null;
|
||||
try { db.close(); } catch (err) { logger.warn("[lifecycle] db.close failed:", err); }
|
||||
|
||||
clearServiceRefs();
|
||||
for (const binding of lateBindings) binding.clear();
|
||||
|
||||
if (outcome === "timeout") {
|
||||
const stuck = listActiveRunIds();
|
||||
logger.error(
|
||||
`[lifecycle] Drain exceeded ${SHUTDOWN_BUDGET_MS}ms; stuck runs: ${stuck.length ? stuck.join(", ") : "<none>"}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const appLifecycleService = createAppLifecycleService({
|
||||
getMainWindow: () => desktopShellService.getMainWindow(),
|
||||
ensureWindow: async () => desktopShellService.ensureWindow(),
|
||||
shouldQuit: () => isQuittingRef.current,
|
||||
onBeforeQuit: () => {
|
||||
if (drainPromise) return;
|
||||
isQuittingRef.current = true;
|
||||
drainPromise = drain().catch((err) => {
|
||||
logger.error("[lifecycle] drain threw unexpectedly:", err);
|
||||
return 1;
|
||||
});
|
||||
drainPromise.then((code) => {
|
||||
app.exit(code);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return appLifecycleService;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { createOrchestrationService } from "../orchestration-service";
|
||||
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
|
||||
import type { createSessionManagementService } from "../session-management-service";
|
||||
import type { createSkillService } from "../skill-service";
|
||||
import type { createWorkerService } from "../worker-service";
|
||||
import type { createAutomationService, AutomationEventContext } from "../automation-service";
|
||||
import type { createBrowserActionService } from "../browser-action-service";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { LateBinding } from "./late-binding";
|
||||
|
||||
export interface OrchestrationServiceDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
getApiPort: () => number;
|
||||
isQuittingRef: { current: boolean };
|
||||
skillService: ReturnType<typeof createSkillService>;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
sessionService: ReturnType<typeof createSessionManagementService>;
|
||||
workerService: ReturnType<typeof createWorkerService>;
|
||||
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
|
||||
browserActionRef: LateBinding<ReturnType<typeof createBrowserActionService>>;
|
||||
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the orchestration service with resolved dependencies. Late bindings
|
||||
* for automation and browser-action are required because orchestration's
|
||||
* dispatchAutomation closure is invoked long after assembly — the slots are
|
||||
* filled in the same init() pass, before any scheduler tick.
|
||||
*/
|
||||
export function buildOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort,
|
||||
isQuittingRef,
|
||||
skillService,
|
||||
budgetService,
|
||||
sessionService,
|
||||
workerService,
|
||||
automationRef,
|
||||
checkpointEngine,
|
||||
}: OrchestrationServiceDeps) {
|
||||
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
|
||||
automationRef.get().dispatchAutomation(trigger, ctx);
|
||||
};
|
||||
|
||||
return createOrchestrationService({
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
notify,
|
||||
createRunToken,
|
||||
getApiPort,
|
||||
dispatchAutomation,
|
||||
hasWorkerReady: () => Boolean(workerService.getWorker()),
|
||||
enqueueWorkerRun: (payload) => {
|
||||
workerService.getWorker()?.postMessage({ type: "enqueue-run", payload });
|
||||
},
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
listRuntimeSkillEntries: (companyId, agentId, connectorId) => {
|
||||
try {
|
||||
return skillService.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
budgetService,
|
||||
checkpointEngine,
|
||||
getSessionHandoff: (taskId) => {
|
||||
try {
|
||||
return sessionService.getHandoffContext(taskId);
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
evaluatePreRunSessionRotation: (taskId, connectorId) => {
|
||||
try {
|
||||
const activeSession = db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionService.evaluateRotation(activeSession, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionService.rotateSession(activeSession);
|
||||
const handoff = sessionService.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { DesktopEvent, RunRecord } from "@shared/types";
|
||||
import { createWorkerService } from "../worker-service";
|
||||
import type { createOrchestrationService } from "../orchestration-service";
|
||||
import type { createAutomationService } from "../automation-service";
|
||||
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
|
||||
import type { createSessionManagementService } from "../session-management-service";
|
||||
import type { PiRuntimeServices } from "../service-graph-pi-runtime";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { LateBinding } from "./late-binding";
|
||||
|
||||
export interface WorkerServiceDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
workerPath: string;
|
||||
isQuittingRef: { current: boolean };
|
||||
publishDomainChanged: () => void;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
orchestrationRef: LateBinding<ReturnType<typeof createOrchestrationService>>;
|
||||
automationRef: LateBinding<ReturnType<typeof createAutomationService>>;
|
||||
onRunStarted?: (runId: string, taskId: string, agentId: string, companyId: string) => void;
|
||||
onRunCompleted?: (runId: string, taskId: string, agentId: string, companyId: string, status: string, summary: string | null, errorMessage: string | null) => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
sessionService: ReturnType<typeof createSessionManagementService>;
|
||||
piRuntimeRef: { current: PiRuntimeServices | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker service. Orchestration and automation are late-bound via
|
||||
* LateBinding slots because they are constructed after the worker but close
|
||||
* over the worker's dispatch functions.
|
||||
*/
|
||||
export function buildWorkerService(deps: WorkerServiceDeps) {
|
||||
const {
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuittingRef,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
orchestrationRef,
|
||||
automationRef,
|
||||
onRunStarted,
|
||||
onRunCompleted,
|
||||
notify,
|
||||
budgetService,
|
||||
sessionService,
|
||||
piRuntimeRef,
|
||||
} = deps;
|
||||
|
||||
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) =>
|
||||
orchestrationRef.get().wakeAgentIfPossible(agentId, companyId, trigger);
|
||||
|
||||
const dispatchRunToWorker: Parameters<typeof createWorkerService>[0]["dispatchRunToWorker"] = (params) =>
|
||||
orchestrationRef.get().dispatchRunToWorker(params);
|
||||
|
||||
const processRunCompletion = (
|
||||
run: RunRecord,
|
||||
event: Parameters<ReturnType<typeof createAutomationService>["processRunCompletion"]>[1],
|
||||
) => {
|
||||
automationRef.get().processRunCompletion(run, event);
|
||||
};
|
||||
|
||||
return createWorkerService({
|
||||
db,
|
||||
logger,
|
||||
workerPath,
|
||||
isQuitting: () => isQuittingRef.current,
|
||||
publishDomainChanged,
|
||||
emitEvent,
|
||||
wakeAgentIfPossible,
|
||||
dispatchRunToWorker,
|
||||
processRunCompletion,
|
||||
onRunStarted,
|
||||
onRunCompleted,
|
||||
notify,
|
||||
budgetService,
|
||||
sessionService,
|
||||
getRuntimeBridge: () => piRuntimeRef.current?.runtimeBridge ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* One-time settable slot for services that participate in a cycle during
|
||||
* assembly. `set()` may be called exactly once; `get()` throws until the
|
||||
* slot has been set. Eliminates the lazy-null `let` pattern that service-graph
|
||||
* previously used to wire `workerService.dispatchRunToWorker` against
|
||||
* `orchestrationService` before the latter exists.
|
||||
*/
|
||||
export class LateBinding<T> {
|
||||
private value: T | null = null;
|
||||
private assigned = false;
|
||||
|
||||
constructor(private readonly label: string) {}
|
||||
|
||||
set(value: T): void {
|
||||
if (this.assigned) {
|
||||
throw new Error(`LateBinding<${this.label}> is already assigned.`);
|
||||
}
|
||||
this.value = value;
|
||||
this.assigned = true;
|
||||
}
|
||||
|
||||
get(): T {
|
||||
if (!this.assigned || this.value === null) {
|
||||
throw new Error(`LateBinding<${this.label}> accessed before assignment.`);
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.value = null;
|
||||
this.assigned = false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js";
|
||||
import type { FileInventoryEntry, TrustLevel } from "./skill-types.js";
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -220,56 +220,3 @@ export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel {
|
||||
return "markdown_only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the token count for a text string.
|
||||
* Uses a rough 1:4 character-to-token ratio.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (text.length === 0) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime semantics extracted from a skill's YAML frontmatter.
|
||||
* These fields control how the skill interacts with the execution runtime.
|
||||
*/
|
||||
export interface SkillRuntimeSemantics {
|
||||
allowedTools: string[];
|
||||
hooks: string[];
|
||||
contextMode: SkillContextMode;
|
||||
isAgent: boolean;
|
||||
effort: "low" | "medium" | "high";
|
||||
whenToUse: string;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
|
||||
if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract runtime semantics from parsed frontmatter.
|
||||
* Missing fields default to safe, passive values.
|
||||
*/
|
||||
export function extractRuntimeSemantics(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): SkillRuntimeSemantics {
|
||||
const contextRaw = typeof frontmatter.context === "string" ? frontmatter.context : "inline";
|
||||
const contextMode: SkillContextMode = contextRaw === "fork" ? "fork" : "inline";
|
||||
|
||||
const effortRaw = typeof frontmatter.effort === "string" ? frontmatter.effort : "medium";
|
||||
const validEfforts = new Set(["low", "medium", "high"]);
|
||||
const effort: "low" | "medium" | "high" = validEfforts.has(effortRaw)
|
||||
? (effortRaw as "low" | "medium" | "high")
|
||||
: "medium";
|
||||
|
||||
return {
|
||||
allowedTools: toStringArray(frontmatter["allowed-tools"] ?? frontmatter.allowedTools),
|
||||
hooks: toStringArray(frontmatter.hooks),
|
||||
contextMode,
|
||||
isAgent: frontmatter.agent === true || frontmatter.agent === "true",
|
||||
effort,
|
||||
whenToUse: typeof frontmatter.when_to_use === "string" ? frontmatter.when_to_use : "",
|
||||
};
|
||||
}
|
||||
+14
-62
@@ -3,43 +3,22 @@
|
||||
export type { SkillSourceType, TrustLevel, FileInventoryEntry, SkillRecord, SkillImportResult, SkillUpdateStatus } from "@shared/types";
|
||||
|
||||
/**
|
||||
* Skill execution context mode — controls how the skill is invoked.
|
||||
* - "inline": skill content is injected into the current prompt (default)
|
||||
* - "fork": skill runs in an isolated context / sub-conversation
|
||||
* Runtime projection of a company skill, matching pi-coding-agent's progressive-
|
||||
* disclosure model: only name/description/filePath/baseDir are exposed to the
|
||||
* prompt. The full SKILL.md is loaded on-demand by the agent via the `read` tool.
|
||||
*
|
||||
* Fields mirror pi's `Skill` interface (the full Skill has sourceInfo +
|
||||
* disableModelInvocation, which we don't need to forward to the prompt layer).
|
||||
*/
|
||||
export type SkillContextMode = "inline" | "fork";
|
||||
|
||||
/**
|
||||
* Runtime representation of a skill entry for adapter injection.
|
||||
* Includes strong semantic fields inspired by Reference's skill loader:
|
||||
* allowed-tools, hooks, context mode, agent flag, effort hint.
|
||||
*/
|
||||
export interface RuntimeSkillEntry {
|
||||
runtimeName: string;
|
||||
sourcePath: string;
|
||||
export interface ProjectedSkill {
|
||||
/** Skill name (Agent Skills spec: lowercase-kebab, matches parent dir). */
|
||||
name: string;
|
||||
tokenEstimate: number;
|
||||
/** Tools this skill is allowed to use. Empty = unrestricted. */
|
||||
allowedTools: string[];
|
||||
/** Lifecycle hooks the skill registers (e.g., "on_task_start", "on_run_complete"). */
|
||||
hooks: string[];
|
||||
/** Context mode: "inline" (default) or "fork" (isolated sub-conversation). */
|
||||
contextMode: SkillContextMode;
|
||||
/** Whether this skill acts as an autonomous agent (true) or passive knowledge (false). */
|
||||
isAgent: boolean;
|
||||
/** Effort hint: "low", "medium", "high". Informs scheduling and token budget allocation. */
|
||||
effort: "low" | "medium" | "high";
|
||||
/** When this skill should be activated. Empty = always active when assigned. */
|
||||
whenToUse: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal skill reference passed to worker threads.
|
||||
* Subset of RuntimeSkillEntry to minimize serialization overhead.
|
||||
*/
|
||||
export interface ManagedSkillRef {
|
||||
runtimeName: string;
|
||||
sourcePath: string;
|
||||
/** Skill description shown in the prompt manifest. */
|
||||
description: string;
|
||||
/** Absolute path to the SKILL.md file. */
|
||||
filePath: string;
|
||||
/** Directory containing SKILL.md (used for relative-path resolution). */
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,30 +28,3 @@ export interface SkillAssignment {
|
||||
agentId: string;
|
||||
skillId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default token budget used when a connector does not declare an override.
|
||||
*/
|
||||
const DEFAULT_TOKEN_BUDGET = 30000;
|
||||
|
||||
/**
|
||||
* Per-connector token budgets for skill content injection.
|
||||
* Values represent the maximum token count of skill content that can be
|
||||
* included in a single prompt for a given connector.
|
||||
*/
|
||||
export const TOKEN_BUDGETS: Readonly<Record<string, number>> = {
|
||||
// High-context Anthropic-style connectors run with a larger injection budget.
|
||||
"claude-pro": 50000,
|
||||
"anthropic": 50000,
|
||||
"amazon-bedrock": 50000,
|
||||
"gemini-cli": 50000,
|
||||
"google": 50000,
|
||||
"google-vertex": 50000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the token budget for a given connector id.
|
||||
*/
|
||||
export function resolveTokenBudget(connectorId: string): number {
|
||||
return TOKEN_BUDGETS[connectorId] ?? DEFAULT_TOKEN_BUDGET;
|
||||
}
|
||||
@@ -58,7 +58,5 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
|
||||
listRuntimeSkillEntries: runtime.listRuntimeSkillEntries,
|
||||
scanWorkspaceSkills: runtime.scanWorkspaceSkills,
|
||||
seedBuiltinSkills: runtime.seedBuiltinSkills,
|
||||
assignBasicSkillsToAgent: runtime.assignBasicSkillsToAgent,
|
||||
assignRoleSkillsToAgent: runtime.assignRoleSkillsToAgent,
|
||||
};
|
||||
}
|
||||
+37
-115
@@ -1,29 +1,8 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadSkills } from "@mariozechner/pi-coding-agent";
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { SkillImportResult, RuntimeSkillEntry } from "../skill-types.js";
|
||||
import { estimateTokens, parseFrontmatterMarkdown, extractRuntimeSemantics } from "../skill-format.js";
|
||||
import { resolveTokenBudget } from "../skill-types.js";
|
||||
|
||||
/**
|
||||
* Role/department keywords → builtin skill category manifest. Centralized at
|
||||
* module scope so future changes do not silently drift from the category
|
||||
* names used by `meta.builtinCategory` on disk.
|
||||
*
|
||||
* Category names mirror SKILL.md directory names (mixed-case is
|
||||
* load-bearing; e.g. "iOS-Dev" and "Video-maker" match file system layout).
|
||||
*/
|
||||
const ROLE_SKILL_CATEGORIES: Record<string, readonly string[]> = {
|
||||
ceo: ["basic-for-all", "software-engineer", "document-process", "frontend-design", "marketing", "iOS-Dev", "my-writing-style", "Video-maker"],
|
||||
engineer: ["basic-for-all", "software-engineer"],
|
||||
designer: ["basic-for-all", "frontend-design"],
|
||||
marketing: ["basic-for-all", "marketing", "my-writing-style"],
|
||||
writer: ["basic-for-all", "document-process", "my-writing-style"],
|
||||
ios: ["basic-for-all", "software-engineer", "iOS-Dev"],
|
||||
mobile: ["basic-for-all", "software-engineer", "iOS-Dev"],
|
||||
video: ["basic-for-all", "Video-maker"],
|
||||
frontend: ["basic-for-all", "software-engineer", "frontend-design"],
|
||||
};
|
||||
import type { SkillImportResult, ProjectedSkill } from "../skill-types.js";
|
||||
|
||||
interface RuntimeDeps {
|
||||
db: AppDatabase;
|
||||
@@ -40,55 +19,35 @@ interface RuntimeDeps {
|
||||
|
||||
export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, importFromLocal }: RuntimeDeps) {
|
||||
/**
|
||||
* Build the list of RuntimeSkillEntry for adapter injection.
|
||||
* Applies token budget enforcement: skills sorted by creation date,
|
||||
* accumulated until budget exceeded, then skipped with warning.
|
||||
* Project all company skills through pi-coding-agent's progressive-disclosure
|
||||
* loader. Every agent in the company sees the full catalog — descriptions
|
||||
* only, full SKILL.md loaded on-demand by the agent via the `read` tool.
|
||||
*
|
||||
* The agentId / connectorId parameters are retained for signature
|
||||
* compatibility with the orchestrator but are intentionally unused: pi's
|
||||
* model has no per-agent filtering and no per-connector token budget.
|
||||
*/
|
||||
function listRuntimeSkillEntries(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
adapterType: string,
|
||||
): RuntimeSkillEntry[] {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
const agentAssignments = db.listAgentSkillAssignments(agentId);
|
||||
const skills = allSkills.filter((s) => agentAssignments.includes(s.id));
|
||||
_agentId: string,
|
||||
_connectorId: string,
|
||||
): ProjectedSkill[] {
|
||||
const companySkills = db.listCompanySkills(companyId);
|
||||
const skillPaths = companySkills.map((s) => skillFilesDir(companyId, s.slug));
|
||||
const { skills, diagnostics } = loadSkills({ skillPaths, includeDefaults: false });
|
||||
|
||||
const sorted = [...skills].sort(
|
||||
(a, b) => a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
|
||||
const budget = resolveTokenBudget(adapterType);
|
||||
let accumulated = 0;
|
||||
const entries: RuntimeSkillEntry[] = [];
|
||||
|
||||
for (const skill of sorted) {
|
||||
const tokenEstimate = estimateTokens(skill.markdown);
|
||||
if (accumulated + tokenEstimate > budget) {
|
||||
logger.warn(
|
||||
`Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`,
|
||||
);
|
||||
continue;
|
||||
for (const diag of diagnostics) {
|
||||
if (diag.type === "warning") {
|
||||
logger.warn(`[skills] ${diag.message} (${diag.path})`);
|
||||
}
|
||||
accumulated += tokenEstimate;
|
||||
|
||||
const { frontmatter } = parseFrontmatterMarkdown(skill.markdown);
|
||||
const semantics = extractRuntimeSemantics(frontmatter);
|
||||
|
||||
entries.push({
|
||||
runtimeName: skill.slug,
|
||||
sourcePath: skillFilesDir(companyId, skill.slug),
|
||||
name: skill.name,
|
||||
tokenEstimate,
|
||||
allowedTools: semantics.allowedTools,
|
||||
hooks: semantics.hooks,
|
||||
contextMode: semantics.contextMode,
|
||||
isAgent: semantics.isAgent,
|
||||
effort: semantics.effort,
|
||||
whenToUse: semantics.whenToUse,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
return skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
filePath: s.filePath,
|
||||
baseDir: s.baseDir,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +121,14 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
|
||||
const { readdirSync, statSync: statSyncLocal } = await import("node:fs");
|
||||
categories = readdirSync(builtinDir).filter(name => {
|
||||
if (name.startsWith(".")) return false;
|
||||
try { return statSyncLocal(join(builtinDir, name)).isDirectory(); } catch { return false; }
|
||||
try {
|
||||
return statSyncLocal(join(builtinDir, name)).isDirectory();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[skills] statSync failed for builtin entry ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
logger.warn(`[skills] Failed to read builtin skills directory: ${builtinDir}`);
|
||||
@@ -182,7 +148,11 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
|
||||
existingMeta.sourceKind = "builtin";
|
||||
db.updateCompanySkillMetadata(companyId, result.skillId, JSON.stringify(existingMeta));
|
||||
}
|
||||
} catch { /* non-fatal metadata update */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[skills] metadata update failed for skill ${result.skillId} (category ${category}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
allResults.push(...results);
|
||||
logger.info(`[skills] Seeded ${results.length} builtin skill(s) from category: ${category}`);
|
||||
@@ -195,57 +165,9 @@ export function createSkillRuntime({ db, logger, skillsRoot, skillFilesDir, impo
|
||||
return allResults;
|
||||
}
|
||||
|
||||
function assignBasicSkillsToAgent(companyId: string, agentId: string): number {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
let assigned = 0;
|
||||
for (const skill of allSkills) {
|
||||
try {
|
||||
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
|
||||
if (meta.builtinCategory === "basic-for-all") {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
}
|
||||
} catch { /* skip malformed metadata */ }
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
function assignRoleSkillsToAgent(companyId: string, agentId: string, role: string, department?: string | null): number {
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
let assigned = 0;
|
||||
|
||||
const normalizedRole = role.toLowerCase();
|
||||
const normalizedDept = department?.toLowerCase() ?? "";
|
||||
|
||||
let categories = ROLE_SKILL_CATEGORIES[normalizedRole] ?? ["basic-for-all"];
|
||||
|
||||
for (const [key, cats] of Object.entries(ROLE_SKILL_CATEGORIES)) {
|
||||
if (normalizedDept.includes(key) || normalizedRole.includes(key)) {
|
||||
categories = [...new Set([...categories, ...cats])];
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedRole === "ceo" || normalizedRole.includes("chief")) {
|
||||
categories = ROLE_SKILL_CATEGORIES.ceo ?? ["basic-for-all"];
|
||||
}
|
||||
|
||||
for (const skill of allSkills) {
|
||||
try {
|
||||
const meta = skill.metadata ? JSON.parse(typeof skill.metadata === "string" ? skill.metadata : JSON.stringify(skill.metadata)) : {};
|
||||
if (meta.builtinCategory && categories.includes(meta.builtinCategory)) {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
return {
|
||||
listRuntimeSkillEntries,
|
||||
scanWorkspaceSkills,
|
||||
seedBuiltinSkills,
|
||||
assignBasicSkillsToAgent,
|
||||
assignRoleSkillsToAgent,
|
||||
};
|
||||
}
|
||||
@@ -202,17 +202,7 @@ export function sweepStaleReviews(
|
||||
const isCofounderMode = deps.getCompanyExecutionMode(companyId) === "ai_cofounder";
|
||||
|
||||
let escalatedCount = 0;
|
||||
const reviewTasks = deps.db.queryAll<{
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}>(
|
||||
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
||||
FROM tasks
|
||||
WHERE company_id = ? AND status = 'in_review'`,
|
||||
companyId,
|
||||
);
|
||||
const reviewTasks = deps.db.listReviewTasksForSweep(companyId);
|
||||
|
||||
for (const task of reviewTasks) {
|
||||
const reviewAge = now - new Date(task.updatedAt).getTime();
|
||||
@@ -227,26 +217,20 @@ export function sweepStaleReviews(
|
||||
const chain = deps.db.getChainOfCommand(reviewer.id);
|
||||
const escalationTarget = chain.length > 0 ? chain[0] : null;
|
||||
|
||||
const pendingApprovals = deps.db.queryAll<{ id: string; type: string }>(
|
||||
`SELECT id, type FROM approvals
|
||||
WHERE company_id = ? AND related_task_id = ? AND state = 'pending'
|
||||
LIMIT 1`,
|
||||
companyId,
|
||||
task.id,
|
||||
);
|
||||
const pendingApproval = deps.db.findPendingApprovalForTask(companyId, task.id);
|
||||
|
||||
// In ai_cofounder mode, ALL approvals require human decision — never auto-advance.
|
||||
if (isCofounderMode && pendingApprovals.length > 0) {
|
||||
if (isCofounderMode && pendingApproval) {
|
||||
deps.notify({
|
||||
title: "Your Review Required",
|
||||
body: `"${task.title}" is waiting for your review. AI Co-Founder mode requires human sign-off.`,
|
||||
urgency: "critical",
|
||||
navigation: { section: "approvals", entityId: pendingApprovals[0]?.id },
|
||||
navigation: { section: "approvals", entityId: pendingApproval.id },
|
||||
batchKey: "cofounder_review_needed",
|
||||
});
|
||||
deps.logger.info("[sweepStaleReviews] Skipping auto-approval for co-founder task", {
|
||||
taskId: task.id,
|
||||
approvalType: pendingApprovals[0]?.type,
|
||||
approvalType: pendingApproval.type,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -283,13 +267,13 @@ export function sweepStaleReviews(
|
||||
|
||||
// D-10: Auto-approve any pending approval linked to this task.
|
||||
// In ai_cofounder mode, protected approval types are already handled above (continue).
|
||||
for (const approval of pendingApprovals) {
|
||||
if (pendingApproval) {
|
||||
try {
|
||||
deps.resolveApprovalDecision({
|
||||
db: deps.db,
|
||||
handleApprovalResolved: deps.handleApprovalResolved,
|
||||
companyId,
|
||||
approvalId: approval.id,
|
||||
approvalId: pendingApproval.id,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved: review timeout exceeded 30 minutes.",
|
||||
detail: "Auto-approved by review timeout escalation",
|
||||
|
||||
+12
-1
@@ -3,11 +3,19 @@ import { closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs
|
||||
import { join } from "node:path";
|
||||
import { safeStorage } from "electron";
|
||||
|
||||
export interface SecretVaultOptions {
|
||||
/** When true, the AES-GCM file-key fallback is refused even if the env flag
|
||||
* is set. Packaged main-process callers should pass `app.isPackaged`. */
|
||||
isPackaged?: boolean;
|
||||
}
|
||||
|
||||
export class SecretVault {
|
||||
private readonly keyPath: string;
|
||||
private readonly isPackaged: boolean;
|
||||
|
||||
constructor(private readonly profileDir: string) {
|
||||
constructor(private readonly profileDir: string, options: SecretVaultOptions = {}) {
|
||||
this.keyPath = join(profileDir, "vault.key");
|
||||
this.isPackaged = options.isPackaged === true;
|
||||
}
|
||||
|
||||
getBackendLabel(): string {
|
||||
@@ -104,6 +112,9 @@ export class SecretVault {
|
||||
}
|
||||
|
||||
private allowInsecureVaultFallback() {
|
||||
// Hard gate: the AES-GCM fallback is a dev-only escape hatch. Packaged
|
||||
// builds must refuse it regardless of the env flag.
|
||||
if (this.isPackaged) return false;
|
||||
return process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT === "true";
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,13 @@ export function createWorkerService({
|
||||
// exits — otherwise the pending timer keeps the event loop alive.
|
||||
const ref = worker;
|
||||
const killTimer = setTimeout(() => {
|
||||
try { ref.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
ref.kill();
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[worker-service] worker kill() failed (likely already dead): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
ref.once("exit", () => clearTimeout(killTimer));
|
||||
worker = null;
|
||||
@@ -180,7 +186,11 @@ export function createWorkerService({
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
}
|
||||
}
|
||||
} catch { /* best effort during shutdown */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[worker-service] interrupted-run cleanup during shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -76,8 +76,10 @@ export function handleFailureEscalation(
|
||||
`[escalation] Escalated task ${run.taskId} to manager ${chain[0].name} (${chain[0].id})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Chain lookup failed (agent deleted)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[escalation] Chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!escalated) {
|
||||
@@ -90,7 +92,11 @@ export function handleFailureEscalation(
|
||||
`[escalation] Escalated task ${run.taskId} to CEO ${ceo.name} (${ceo.id})`,
|
||||
);
|
||||
}
|
||||
} catch { /* CEO lookup failed */ }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[escalation] CEO lookup failed for company ${run.companyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!escalated) {
|
||||
@@ -111,8 +117,13 @@ export function handleFailureEscalation(
|
||||
export function handleSuccessReset(
|
||||
db: EscalationDeps["db"],
|
||||
taskId: string,
|
||||
logger?: EscalationDeps["logger"],
|
||||
): void {
|
||||
try {
|
||||
db.resetTaskRetryCount(taskId);
|
||||
} catch { /* best effort */ }
|
||||
} catch (error) {
|
||||
logger?.warn(
|
||||
`[escalation] resetTaskRetryCount failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ export function finalizeRun(
|
||||
let run: RunRecord;
|
||||
try {
|
||||
run = db.getRun(event.runId);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[finalizeRun] getRun(${event.runId}) failed; run may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -55,7 +58,10 @@ export function finalizeRun(
|
||||
let task: TaskRecord | null = null;
|
||||
try {
|
||||
task = db.getTask(run.taskId);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[finalizeRun] getTask(${run.taskId}) failed; task may have been deleted: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
task = null;
|
||||
}
|
||||
|
||||
@@ -120,7 +126,7 @@ export function emitAndNotifyCompletion(
|
||||
deps: HandlerDeps,
|
||||
rfCtx: RunFinishedContext,
|
||||
): void {
|
||||
const { db, emitEvent, notify } = deps;
|
||||
const { db, emitEvent, notify, logger } = deps;
|
||||
const { run, task, runOutcome } = rfCtx;
|
||||
|
||||
emitEvent({ type: "run-updated", runId: run.id, action: "completed" });
|
||||
@@ -143,7 +149,10 @@ export function emitAndNotifyCompletion(
|
||||
let updatedAgent: ReturnType<HandlerDeps["db"]["getAgent"]> | null = null;
|
||||
try {
|
||||
updatedAgent = db.getAgent(run.agentId);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[emitAndNotifyCompletion] getAgent(${run.agentId}) failed; skipping budget-paused notification: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
updatedAgent = null;
|
||||
}
|
||||
|
||||
@@ -177,14 +186,11 @@ export function runPostCompletionAutomation(
|
||||
const { destroyed, MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, pendingRetryTimers } = context;
|
||||
const { run, task } = rfCtx;
|
||||
|
||||
// Policy-based incident check after run completion (Phase 5)
|
||||
// Policy-based incident check after run completion (Phase 5). Reuse the
|
||||
// `task` already resolved in finalizeRun instead of double-fetching with
|
||||
// its own catch-and-swallow; if task is null the project-scope is null.
|
||||
if (budgetService && typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) {
|
||||
try {
|
||||
const taskForBudget = db.getTask(run.taskId);
|
||||
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, taskForBudget?.projectId ?? null);
|
||||
} catch (err) {
|
||||
logger.error(`[budget] Incident check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
budgetService.checkAndCreateIncidents(run.companyId, run.agentId, task?.projectId ?? null);
|
||||
}
|
||||
|
||||
if ((event.status === "failed" || event.status === "timed_out") && !isQuitting() && !destroyed) {
|
||||
@@ -252,7 +258,7 @@ export function runPostCompletionAutomation(
|
||||
|
||||
// Reset retry count on successful completion
|
||||
if (event.status === "succeeded") {
|
||||
handleSuccessReset(db, run.taskId);
|
||||
handleSuccessReset(db, run.taskId, logger);
|
||||
}
|
||||
|
||||
return { hadDeferredWakes };
|
||||
@@ -294,13 +300,17 @@ export function triggerSelfWake(
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "report_stuck");
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[self-wake] chain-of-command lookup failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
wakeAgentIfPossible(run.agentId, run.companyId, "task_continuation");
|
||||
}
|
||||
} catch {
|
||||
// Best-effort self-wake; agent will pick up work on the next heartbeat cycle.
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[self-wake] self-wake evaluation failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,12 @@ export function handleWorkerExit(
|
||||
const recoverySnapshot = db.listSnapshot();
|
||||
for (const candidate of requeueCandidates) {
|
||||
try {
|
||||
// The crashed run may still own a workspace lock acquired during the
|
||||
// original dispatch; release it before re-dispatch so the replay
|
||||
// does not self-collide with "Workspace is locked by another run".
|
||||
if (candidate.workspaceId) {
|
||||
db.releaseWorkspaceLock(candidate.workspaceId, candidate.id);
|
||||
}
|
||||
const task = db.getTask(candidate.taskId);
|
||||
const agent = db.getAgent(candidate.agentId);
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
|
||||
@@ -37,33 +37,39 @@ function enqueue(job: RunJob) {
|
||||
status: "queued",
|
||||
message: "Run queued in orchestration worker.",
|
||||
});
|
||||
void maybeStartQueuedRuns();
|
||||
maybeStartQueuedRuns();
|
||||
}
|
||||
|
||||
async function maybeStartQueuedRuns() {
|
||||
const readyJobs = [...queue];
|
||||
for (const job of readyJobs) {
|
||||
if (activeByAgent.has(job.agentId)) continue;
|
||||
/**
|
||||
* Walks the queue in-place and dispatches every job whose agent + workspace
|
||||
* are not currently busy. Claims (activeByAgent / activeByWorkspace) are
|
||||
* written BEFORE the job is removed from the queue and BEFORE `startRun`
|
||||
* fires — this is the atomic point of commit for orchestrator ordering.
|
||||
* Hardens against a future refactor that adds an `await` before the claim
|
||||
* inside `startRun`, which would open a re-entrancy window.
|
||||
*/
|
||||
function maybeStartQueuedRuns() {
|
||||
let i = 0;
|
||||
while (i < queue.length) {
|
||||
const job = queue[i];
|
||||
if (activeByAgent.has(job.agentId)) { i += 1; continue; }
|
||||
const lockKey = workspaceKey(job);
|
||||
if (lockKey && activeByWorkspace.has(lockKey)) continue;
|
||||
const index = queue.findIndex((entry) => entry.runId === job.runId);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
void startRun(job);
|
||||
if (lockKey && activeByWorkspace.has(lockKey)) { i += 1; continue; }
|
||||
activeByAgent.set(job.agentId, job.runId);
|
||||
if (lockKey) activeByWorkspace.set(lockKey, job.runId);
|
||||
queue.splice(i, 1);
|
||||
void startRun(job, lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRun(job: RunJob) {
|
||||
const lockKey = workspaceKey(job);
|
||||
activeByAgent.set(job.agentId, job.runId);
|
||||
if (lockKey) activeByWorkspace.set(lockKey, job.runId);
|
||||
|
||||
async function startRun(job: RunJob, lockKey: string | null) {
|
||||
try {
|
||||
await runner.run(job);
|
||||
} finally {
|
||||
cancelledRuns.delete(job.runId);
|
||||
activeByAgent.delete(job.agentId);
|
||||
if (lockKey) activeByWorkspace.delete(lockKey);
|
||||
void maybeStartQueuedRuns();
|
||||
maybeStartQueuedRuns();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +115,17 @@ parentPort.on("message", (event) => {
|
||||
return;
|
||||
}
|
||||
if (message.type === "shutdown") {
|
||||
runner.cancelAll();
|
||||
rpc.cancelAll("Worker shutting down.");
|
||||
process.exit(0);
|
||||
void (async () => {
|
||||
// Graceful drain: abort every active run and await its promise
|
||||
// settlement so that final `run-finished` messages and
|
||||
// `persistMessage` RPCs reach the parent before we exit. Bounded by
|
||||
// a 5s budget so a stuck run does not wedge the shutdown handshake
|
||||
// (the parent has its own kill-timer as a safety net).
|
||||
const drainBudgetMs = 5_000;
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, drainBudgetMs));
|
||||
await Promise.race([runner.cancelAll(), timeout]);
|
||||
rpc.cancelAll("Worker shutting down.");
|
||||
process.exit(0);
|
||||
})();
|
||||
}
|
||||
});
|
||||
@@ -19,7 +19,6 @@ export interface RunJob {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
systemPrompt: string | null;
|
||||
trigger: string | null;
|
||||
managedSkills: Array<{ runtimeName: string; sourcePath: string }>;
|
||||
taskSessionId: string;
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
}
|
||||
@@ -80,7 +79,16 @@ export function buildRedactor(env: Record<string, string>): (text: string) => st
|
||||
};
|
||||
}
|
||||
|
||||
/** Pushes extra env vars onto process.env and returns a restore function. */
|
||||
/**
|
||||
* Pushes extra env vars onto `process.env` and returns a restore function
|
||||
* that is **infallible** — each individual restore is guarded so a single
|
||||
* malformed key cannot leave a partial restore that keeps a provider
|
||||
* secret in the worker's env for the next run. Intended for short-lived
|
||||
* turn-scope env (`AGENT_COMPANY_*`, connector secrets). The worker runs
|
||||
* one agent at a time, so this is safe for the current scheduler; do not
|
||||
* convert it to concurrent run isolation — pass per-spawn env at the
|
||||
* child-process level instead.
|
||||
*/
|
||||
export function applyEnvOverrides(overrides: Record<string, string>): () => void {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
@@ -89,8 +97,15 @@ export function applyEnvOverrides(overrides: Record<string, string>): () => void
|
||||
}
|
||||
return () => {
|
||||
for (const [key, previous] of Object.entries(saved)) {
|
||||
if (previous === undefined) delete process.env[key];
|
||||
else process.env[key] = previous;
|
||||
try {
|
||||
if (previous === undefined) delete process.env[key];
|
||||
else process.env[key] = previous;
|
||||
} catch {
|
||||
// Individual restore failure is never fatal — move on so the
|
||||
// remaining keys still revert. Should never happen in practice
|
||||
// (the Node env-var assignment is synchronous and infallible for
|
||||
// string values), but the guard lets the outer finally complete.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -42,9 +42,14 @@ export async function handleWorkerMessage(
|
||||
case "run-status":
|
||||
handleRunStatus(event, deps, context);
|
||||
return;
|
||||
case "run-finished":
|
||||
handleRunFinished(event, deps, context);
|
||||
case "run-finished": {
|
||||
try {
|
||||
handleRunFinished(event, deps, context);
|
||||
} finally {
|
||||
deps.getRuntimeBridge?.()?.releaseRunResources(event.runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "agent-event": {
|
||||
const bridge = deps.getRuntimeBridge?.();
|
||||
if (bridge) {
|
||||
|
||||
@@ -3,17 +3,33 @@ import type { ChildMessage } from "./worker-helpers";
|
||||
|
||||
interface PendingRpc {
|
||||
resolve: (value: { ok: true; result: unknown } | { ok: false; error: string }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class WorkerRpcClient {
|
||||
private readonly pending = new Map<string, PendingRpc>();
|
||||
|
||||
constructor(private readonly post: (message: ChildMessage) => void) {}
|
||||
constructor(
|
||||
private readonly post: (message: ChildMessage) => void,
|
||||
private readonly timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
invoke(runId: string, method: string, payload: unknown): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
|
||||
invoke(
|
||||
runId: string,
|
||||
method: string,
|
||||
payload: unknown,
|
||||
): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
|
||||
const rpcId = randomUUID();
|
||||
return new Promise((resolve) => {
|
||||
this.pending.set(rpcId, { resolve });
|
||||
const timer = setTimeout(() => {
|
||||
const pending = this.pending.get(rpcId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(rpcId);
|
||||
pending.resolve({ ok: false, error: `RPC ${method} timed out after ${this.timeoutMs}ms` });
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(rpcId, { resolve, timer });
|
||||
this.post({ type: "rpc-request", rpcId, runId, method, payload });
|
||||
});
|
||||
}
|
||||
@@ -21,12 +37,14 @@ export class WorkerRpcClient {
|
||||
resolve(rpcId: string, response: { ok: true; result: unknown } | { ok: false; error: string }): void {
|
||||
const pending = this.pending.get(rpcId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(rpcId);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
cancelAll(reason: string): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ ok: false, error: reason });
|
||||
this.pending.delete(id);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface RunnerDeps {
|
||||
|
||||
export class WorkerRunner {
|
||||
private readonly abortControllers = new Map<string, AbortController>();
|
||||
private readonly runPromises = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly deps: RunnerDeps) {}
|
||||
|
||||
@@ -31,21 +32,37 @@ export class WorkerRunner {
|
||||
if (controller) controller.abort();
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
/**
|
||||
* Aborts every in-flight run and returns a promise that resolves once
|
||||
* each `run()` has settled (success, failure, or abort). Callers (the
|
||||
* worker shutdown handler) must `await` this before `process.exit` so
|
||||
* pending `persistMessage` RPCs and the final `run-finished` post reach
|
||||
* the parent before the worker dies.
|
||||
*/
|
||||
async cancelAll(): Promise<void> {
|
||||
for (const controller of this.abortControllers.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
const pending = [...this.runPromises.values()];
|
||||
this.abortControllers.clear();
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
|
||||
async run(job: RunJob): Promise<void> {
|
||||
run(job: RunJob): Promise<void> {
|
||||
const promise = this.runImpl(job).finally(() => {
|
||||
this.runPromises.delete(job.runId);
|
||||
});
|
||||
this.runPromises.set(job.runId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async runImpl(job: RunJob): Promise<void> {
|
||||
const { post, rpc, isCancelled } = this.deps;
|
||||
const redact = buildRedactor(job.env);
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(job.runId, abortController);
|
||||
|
||||
post({ type: "run-status", runId: job.runId, status: "running", message: `Running agent ${job.agentName}.` });
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] starting run for ${job.agentName} (${job.connectorId}/${job.model ?? "?"})\n` });
|
||||
|
||||
let resolvedModelId: string | null = job.model;
|
||||
let finalUsage: { inputTokens: number | null; outputTokens: number | null; costUsd: number | null } = {
|
||||
@@ -55,6 +72,14 @@ export class WorkerRunner {
|
||||
};
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
// Inject the AgentCompany job env (AGENT_COMPANY_API_URL / API_KEY /
|
||||
// AGENT_ID / COMPANY_ID / RUN_ID / TASK_ID / WAKE_REASON + provider
|
||||
// secret bindings) into the worker's process.env BEFORE Agent
|
||||
// construction, so the agent's bash-tool children can call back into
|
||||
// our HTTP API via fetch(process.env.AGENT_COMPANY_API_URL). Restored
|
||||
// in the outer finally regardless of success / abort / throw.
|
||||
const restoreJobEnv = applyEnvOverrides(job.env);
|
||||
|
||||
try {
|
||||
if (!job.model) {
|
||||
throw new Error(`Run ${job.runId} has no model selected.`);
|
||||
@@ -73,8 +98,7 @@ export class WorkerRunner {
|
||||
}
|
||||
const prepared = prepareResponse.result as PrepareRunResult;
|
||||
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] prepareRun ok, resolving model\n` });
|
||||
const restoreEnv = applyEnvOverrides(prepared.extraEnv);
|
||||
const restoreProviderEnv = applyEnvOverrides(prepared.extraEnv);
|
||||
try {
|
||||
const model = resolveModel(prepared.provider, job.model);
|
||||
if (!model) {
|
||||
@@ -82,7 +106,6 @@ export class WorkerRunner {
|
||||
}
|
||||
resolvedModelId = model.id;
|
||||
const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey);
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] model=${model.id} provider=${prepared.provider} — constructing Agent\n` });
|
||||
|
||||
const cwd = job.workspacePath ?? process.cwd();
|
||||
const tools = createCodingTools(cwd) as never;
|
||||
@@ -100,36 +123,10 @@ export class WorkerRunner {
|
||||
apiKey: prepared.apiKey,
|
||||
headers: { ...(options?.headers ?? {}), ...(extraHeaders ?? {}) },
|
||||
}),
|
||||
beforeToolCall: async (context) => {
|
||||
const verdict = await rpc.invoke(job.runId, "beforeToolCall", {
|
||||
agentId: job.agentId,
|
||||
companyId: job.companyId,
|
||||
taskId: job.taskId,
|
||||
toolCallId: context.toolCall.id,
|
||||
toolName: context.toolCall.name,
|
||||
args: context.args,
|
||||
});
|
||||
if (verdict.ok) {
|
||||
return verdict.result as { block?: boolean; reason?: string } | undefined;
|
||||
}
|
||||
return { block: true, reason: verdict.error };
|
||||
},
|
||||
afterToolCall: async (context) => {
|
||||
await rpc.invoke(job.runId, "afterToolCall", {
|
||||
toolCallId: context.toolCall.id,
|
||||
toolName: context.toolCall.name,
|
||||
isError: context.isError,
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const unsubscribe = agent.subscribe(async (event) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
// Surface every pi AgentEvent type as a raw Console probe so the
|
||||
// operator can see the live event stream even when projection
|
||||
// misses a type or the agent is stuck inside a particular phase.
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[pi-event] ${event.type}\n` });
|
||||
post({
|
||||
type: "agent-event",
|
||||
runId: job.runId,
|
||||
@@ -166,22 +163,21 @@ export class WorkerRunner {
|
||||
content: [{ type: "text", text: promptContent }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] calling agent.prompt (${promptContent.length} chars)\n` });
|
||||
const runPromise = agent.prompt(promptMessage);
|
||||
abortController.signal.addEventListener("abort", () => agent.abort(), { once: true });
|
||||
await runPromise;
|
||||
post({ type: "run-log", runId: job.runId, stream: "stdout", chunk: `[runner] agent.prompt resolved\n` });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
restoreEnv();
|
||||
restoreProviderEnv();
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) });
|
||||
} finally {
|
||||
this.abortControllers.delete(job.runId);
|
||||
restoreJobEnv();
|
||||
}
|
||||
|
||||
const status: RunStatus = isCancelled(job.runId)
|
||||
|
||||
@@ -50,7 +50,7 @@ export const workerMessageSchema = z.union([
|
||||
type: z.literal("rpc-request"),
|
||||
rpcId: z.string(),
|
||||
runId: z.string().uuid(),
|
||||
method: z.enum(["beforeToolCall", "afterToolCall", "persistMessage", "prepareRun"]),
|
||||
method: z.enum(["persistMessage", "prepareRun"]),
|
||||
payload: z.unknown(),
|
||||
}),
|
||||
]);
|
||||
|
||||
+18
-19
@@ -107,29 +107,19 @@ const api: DesktopApi = {
|
||||
getBudgetStatus: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getBudgetStatus, { companyId }),
|
||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.recoveryRetry, { taskId, agentId, companyId }),
|
||||
recoveryDismiss: (taskId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId }),
|
||||
recoveryFail: (taskId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId }),
|
||||
recoveryDismiss: (taskId: string, companyId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.recoveryDismiss, { taskId, companyId }),
|
||||
recoveryFail: (taskId: string, companyId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.recoveryFail, { taskId, companyId }),
|
||||
getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input),
|
||||
getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input),
|
||||
getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input),
|
||||
getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input),
|
||||
listConfigRevisions: (companyId: string, agentId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.listConfigRevisions, { companyId, agentId }),
|
||||
getConfigRevision: (companyId: string, revisionId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getConfigRevision, { companyId, revisionId }),
|
||||
rollbackConfigRevision: (companyId: string, agentId: string, revisionId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.rollbackConfigRevision, { companyId, agentId, revisionId }),
|
||||
listRunEvents: (companyId: string, runId: string, opts?: { offset?: number; limit?: number }) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.listRunEvents, { companyId, runId, ...opts }),
|
||||
getAgentPermissions: (agentId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getAgentPermissions, { agentId }),
|
||||
setAgentPermission: (companyId: string, agentId: string, permissionKey: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.setAgentPermission, { companyId, agentId, permissionKey, enabled }),
|
||||
getHeartbeatPolicy: (agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getHeartbeatPolicy, { companyId, agentId }),
|
||||
setHeartbeatPolicy: (companyId: string, agentId: string, policy: Record<string, unknown>) =>
|
||||
setHeartbeatPolicy: (companyId, agentId, policy) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.setHeartbeatPolicy, { companyId, agentId, ...policy }),
|
||||
getRuntimeState: (agentId: string, companyId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getRuntimeState, { companyId, agentId }),
|
||||
@@ -159,10 +149,19 @@ const api: DesktopApi = {
|
||||
};
|
||||
},
|
||||
platform: process.platform as "win32" | "darwin" | "linux",
|
||||
windowMinimize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMinimize),
|
||||
windowMaximize: () => ipcRenderer.invoke(IPC_CHANNELS.windowMaximize),
|
||||
windowClose: () => ipcRenderer.invoke(IPC_CHANNELS.windowClose),
|
||||
windowIsMaximized: () => ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized) as Promise<boolean>,
|
||||
windowMinimize: async () => {
|
||||
await ipcRenderer.invoke(IPC_CHANNELS.windowMinimize);
|
||||
},
|
||||
windowMaximize: async () => {
|
||||
await ipcRenderer.invoke(IPC_CHANNELS.windowMaximize);
|
||||
},
|
||||
windowClose: async () => {
|
||||
await ipcRenderer.invoke(IPC_CHANNELS.windowClose);
|
||||
},
|
||||
windowIsMaximized: async () => {
|
||||
const result = (await ipcRenderer.invoke(IPC_CHANNELS.windowIsMaximized)) as { ok: true; data: boolean } | { ok: false };
|
||||
return result.ok ? result.data : false;
|
||||
},
|
||||
// Credentials (pi-mono)
|
||||
listCredentials: () => ipcRenderer.invoke(IPC_CHANNELS.credentialsList),
|
||||
saveCredential: (input) => ipcRenderer.invoke(IPC_CHANNELS.credentialsSave, input),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { CircleNotch, TerminalWindow } from "./components/ui/icons";
|
||||
import { LocaleContext, TranslationContext, type TranslationFn } from "./i18n";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { CompanyOnboarding } from "./components/CompanyOnboarding";
|
||||
import { CompanyOnboarding } from "./components/company-onboarding/OnboardingWizard";
|
||||
import TitleBar from "./components/TitleBar";
|
||||
import ToastContainer from "./components/Toast";
|
||||
import { SidebarView } from "./views/SidebarView";
|
||||
@@ -14,6 +14,7 @@ import { useUIController } from "./hooks/useUIController";
|
||||
import { useCompanyController } from "./hooks/useCompanyController";
|
||||
import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys";
|
||||
import { AppActionsContext, type AppActions } from "./hooks/useAppActions";
|
||||
import { SectionContext } from "./hooks/useSectionContext";
|
||||
import { unwrap } from "./lib/desktop";
|
||||
|
||||
function Shell({
|
||||
@@ -157,6 +158,7 @@ export function App() {
|
||||
<LocaleContext.Provider value={ui.locale}>
|
||||
<TranslationContext.Provider value={ui.t}>
|
||||
<AppActionsContext.Provider value={appActions}>
|
||||
<SectionContext.Provider value={company.sectionContextValue}>
|
||||
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
|
||||
{showCustomTitleBar && <TitleBar />}
|
||||
<div className={`grid min-h-0 flex-1 overflow-hidden transition-[grid-template-columns] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${ui.sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}>
|
||||
@@ -247,6 +249,7 @@ export function App() {
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</div>
|
||||
</SectionContext.Provider>
|
||||
</AppActionsContext.Provider>
|
||||
</TranslationContext.Provider>
|
||||
</LocaleContext.Provider>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Backward-compatible re-exports from decomposed onboarding components
|
||||
export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard";
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Copy, Minus, Square, X } from "./ui/icons";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export default function TitleBar() {
|
||||
const t = useT();
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const refreshMaximized = useCallback(() => {
|
||||
@@ -26,7 +28,7 @@ export default function TitleBar() {
|
||||
<div className="flex h-full">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Minimize"
|
||||
aria-label={t("titlebar.minimize")}
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={() => void window.agentCompany.windowMinimize()}
|
||||
>
|
||||
@@ -34,7 +36,7 @@ export default function TitleBar() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
aria-label={maximized ? t("titlebar.restore") : t("titlebar.maximize")}
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
void window.agentCompany.windowMaximize().then(refreshMaximized);
|
||||
@@ -48,8 +50,8 @@ export default function TitleBar() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-red-500 hover:text-white"
|
||||
aria-label={t("titlebar.close")}
|
||||
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-destructive hover:text-primary-foreground"
|
||||
onClick={() => void window.agentCompany.windowClose()}
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AutomationPanel } from "./automation/AutomationPanel"
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, X } from "../ui/icons"
|
||||
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
|
||||
import { ActionButton, ConfirmDialog } from "../ui"
|
||||
import { useT } from "../../i18n"
|
||||
import { ActionButton, ConfirmDialog, Card, Eyebrow } from "../ui"
|
||||
import { useT, type TranslationFn } from "../../i18n"
|
||||
import { triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { RuleCard } from "./RuleCard"
|
||||
@@ -11,9 +11,9 @@ import { RuleFormModal } from "./RuleEditor"
|
||||
import { WorkflowCard } from "./WorkflowList"
|
||||
import { WorkflowFormModal } from "./WorkflowEditor"
|
||||
|
||||
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
||||
function AutomationActivityLog({ entries, t }: { entries: AutomationLogEntry[]; t: TranslationFn }) {
|
||||
if (entries.length === 0) {
|
||||
return <div className="py-6 text-center text-[13px] text-muted-foreground">No activity yet</div>
|
||||
return <div className="py-6 text-center text-[13px] text-muted-foreground">{t("auto.activity.noActivity")}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -48,7 +48,7 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void }) {
|
||||
function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
|
||||
const [showCreate, setShowCreate] = useState(false); const [editRule, setEditRule] = useState<AutomationRuleRecord | null>(null); const [delTarget, setDelTarget] = useState<AutomationRuleRecord | null>(null)
|
||||
const [actLog, setActLog] = useState<AutomationLogEntry[]>([]); const [activeTab, setActiveTab] = useState<"rules"|"activity">("rules"); const [filterStatus, setFilterStatus] = useState<AutomationStatus|"all">("all")
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
@@ -56,8 +56,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
|
||||
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
|
||||
const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
|
||||
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
|
||||
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } }, [companyId, onRefresh, t])
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -75,25 +75,25 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
<Lightning size={20} weight="fill" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2>
|
||||
<h2 className="text-[16px] font-bold text-foreground">{t("auto.rules.title")}</h2>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}
|
||||
{t("auto.rules.summaryActive").replace("{n}", String(counts.active))},{t("auto.rules.summaryPaused").replace("{n}", String(counts.paused))}{counts.error > 0 ? `,${t("auto.rules.summaryErrored").replace("{n}", String(counts.error))}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
|
||||
<ActionButton label={t("auto.rules.newRule")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
|
||||
</div>
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ l: "Total Rules", v: counts.total, accent: false },
|
||||
{ l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
|
||||
{ l: "Recent Activity", v: actLog.length, accent: true },
|
||||
{ l: t("auto.rules.totalRules"), v: counts.total, accent: false },
|
||||
{ l: t("auto.rules.totalExecutions"), v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
|
||||
{ l: t("auto.rules.recentActivity"), v: actLog.length, accent: true },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-lg border border-border bg-card px-4 py-3.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div>
|
||||
<Card key={s.l} padding="sm" className="!py-3.5">
|
||||
<Eyebrow>{s.l}</Eyebrow>
|
||||
<div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -103,45 +103,51 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
onClick={() => setActiveTab("rules")}
|
||||
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Rules ({rules.length})
|
||||
{t("auto.rules.tabRules").replace("{n}", String(rules.length))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("activity")}
|
||||
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Activity Log ({actLog.length})
|
||||
{t("auto.rules.tabActivity").replace("{n}", String(actLog.length))}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "rules" ? (
|
||||
<>
|
||||
{rules.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "active", "paused", "error"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setFilterStatus(s)}
|
||||
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
|
||||
>
|
||||
{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{(["all", "active", "paused", "error"] as const).map((s) => {
|
||||
const filterLabel = s === "all" ? t("auto.rules.filterAll")
|
||||
: s === "active" ? t("auto.rules.filterActive")
|
||||
: s === "paused" ? t("auto.rules.filterPaused")
|
||||
: t("auto.rules.filterError");
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setFilterStatus(s)}
|
||||
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
|
||||
>
|
||||
{filterLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.length === 0 && rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
||||
<Lightning size={36} className="mb-3 text-muted-foreground" />
|
||||
<div className="text-[14px] font-medium text-muted-foreground">No automation rules</div>
|
||||
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.rules.noRules")}</div>
|
||||
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
|
||||
Create your first rule to make the company autonomous.
|
||||
{t("auto.rules.noRulesDetail")}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
|
||||
<ActionButton label={t("auto.rules.createFirstRule")} onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
|
||||
</div>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-[13px] text-muted-foreground">No rules match the current filter.</div>
|
||||
<div className="py-8 text-center text-[13px] text-muted-foreground">{t("auto.rules.noRulesFilter")}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
@@ -159,7 +165,7 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<AutomationActivityLog entries={actLog} />
|
||||
<AutomationActivityLog entries={actLog} t={t} />
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{showCreate ? (
|
||||
@@ -171,8 +177,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
</AnimatePresence>
|
||||
<ConfirmDialog
|
||||
open={!!delTarget}
|
||||
title="Delete rule"
|
||||
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
|
||||
title={t("auto.rules.deleteRule")}
|
||||
message={delTarget ? t("auto.rules.deleteRuleConfirm").replace("{name}", delTarget.name) : ""}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDelTarget(null)}
|
||||
/>
|
||||
@@ -180,11 +186,11 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
|
||||
function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
|
||||
const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState<WorkflowPipelineRecord | null>(null); const [delTarget, setDelTarget] = useState<WorkflowPipelineRecord | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null)
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
|
||||
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } finally { setRunningId(null) } }, [companyId, onRefresh])
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
|
||||
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } finally { setRunningId(null) } }, [companyId, onRefresh, t])
|
||||
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -202,24 +208,24 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
|
||||
<ArrowsClockwise size={20} weight="fill" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2>
|
||||
<h2 className="text-[16px] font-bold text-foreground">{t("auto.workflows.title")}</h2>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{counts.active} active, {counts.draft} draft
|
||||
{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}
|
||||
{t("auto.workflows.summaryActive").replace("{n}", String(counts.active))},{t("auto.workflows.summaryDraft").replace("{n}", String(counts.draft))}
|
||||
{counts.total > counts.active + counts.draft ? `,${t("auto.workflows.summaryOther").replace("{n}", String(counts.total - counts.active - counts.draft))}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
|
||||
<ActionButton label={t("auto.workflows.newWorkflow")} onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
|
||||
</div>
|
||||
{workflows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
||||
<ArrowsClockwise size={36} className="mb-3 text-muted-foreground" />
|
||||
<div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div>
|
||||
<div className="text-[14px] font-medium text-muted-foreground">{t("auto.workflows.noWorkflows")}</div>
|
||||
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
|
||||
Workflows chain actions into multi-step pipelines.
|
||||
{t("auto.workflows.noWorkflowsDetail")}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
|
||||
<ActionButton label={t("auto.workflows.createFirstWorkflow")} onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -248,8 +254,8 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
|
||||
</AnimatePresence>
|
||||
<ConfirmDialog
|
||||
open={!!delTarget}
|
||||
title="Delete workflow"
|
||||
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
|
||||
title={t("auto.workflows.deleteWorkflow")}
|
||||
message={delTarget ? t("auto.workflows.deleteWorkflowConfirm").replace("{name}", delTarget.name) : ""}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDelTarget(null)}
|
||||
/>
|
||||
@@ -269,7 +275,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-foreground">{t("auto.automationEngine")}</div>
|
||||
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||
Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously.
|
||||
{t("auto.engineDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +287,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} className={activeSection === "rules" ? "text-primary" : ""} />
|
||||
Automation Rules ({rules.length})
|
||||
{t("auto.tabRules").replace("{n}", String(rules.length))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -289,7 +295,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} className={activeSection === "workflows" ? "text-primary" : ""} />
|
||||
Workflows ({workflows.length})
|
||||
{t("auto.tabWorkflows").replace("{n}", String(workflows.length))}
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -301,9 +307,9 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{activeSection === "rules" ? (
|
||||
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} />
|
||||
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} t={t} />
|
||||
) : (
|
||||
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />
|
||||
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} t={t} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { CommunicationHub } from "./communication/CommunicationHub"
|
||||
@@ -5,13 +5,16 @@ import type { AgentRecord, MessageChannel } from "@shared/types"
|
||||
import { AgentAvatar } from "./MessageItem"
|
||||
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
|
||||
import { useT } from "../../i18n"
|
||||
import { Eyebrow } from "../ui"
|
||||
|
||||
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType }> = {
|
||||
direct: { label: "Direct Messages", icon: EnvelopeSimple },
|
||||
department: { label: "Department", icon: Buildings },
|
||||
company: { label: "Company-wide", icon: Megaphone },
|
||||
project: { label: "Project", icon: Hash },
|
||||
incident: { label: "Incident", icon: Fire },
|
||||
type ChannelConfig = { labelKey: string; icon: React.ElementType }
|
||||
|
||||
const channelConfig: Record<MessageChannel, ChannelConfig> = {
|
||||
direct: { labelKey: "comm.channelLabel.direct", icon: EnvelopeSimple },
|
||||
department: { labelKey: "comm.channelLabel.department", icon: Buildings },
|
||||
company: { labelKey: "comm.channelLabel.company", icon: Megaphone },
|
||||
project: { labelKey: "comm.channelLabel.project", icon: Hash },
|
||||
incident: { labelKey: "comm.channelLabel.incident", icon: Fire },
|
||||
}
|
||||
|
||||
function ChannelItem({
|
||||
@@ -111,9 +114,7 @@ export function ChannelSidebar({
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{channelGroups.map((group) => (
|
||||
<div key={group.type} className="mb-3">
|
||||
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
<Eyebrow className="mb-1 px-2">{group.label}</Eyebrow>
|
||||
{group.items.map((ch) => {
|
||||
const stats = unreadByChannel.get(ch.key)
|
||||
return (
|
||||
@@ -133,9 +134,7 @@ export function ChannelSidebar({
|
||||
|
||||
{taskDiscussionChannels.length > 0 ? (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t("comm.taskDiscussions")}
|
||||
</div>
|
||||
<Eyebrow className="mb-1 px-2">{t("comm.taskDiscussions")}</Eyebrow>
|
||||
{taskDiscussionChannels.map((discussion) => (
|
||||
<button
|
||||
key={discussion.key}
|
||||
@@ -165,8 +164,8 @@ export function ChannelSidebar({
|
||||
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>{totalChannels} channels</span>
|
||||
<span>{agents.length} agents</span>
|
||||
<span>{t("comm.channelsTotal").replace("{count}", String(totalChannels))}</span>
|
||||
<span>{t("comm.agentsTotal").replace("{count}", String(agents.length))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./Me
|
||||
import { SearchResults } from "./SearchResults"
|
||||
import ThreadPanel from "./ThreadPanel"
|
||||
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
|
||||
import { useT, type TranslationFn } from "../../i18n"
|
||||
|
||||
|
||||
interface CommunicationHubProps {
|
||||
@@ -19,12 +20,12 @@ interface CommunicationHubProps {
|
||||
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
|
||||
}
|
||||
|
||||
function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void }) {
|
||||
function SearchScopeToggle({ scope, onChangeScope, t }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void; t: TranslationFn }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-card p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="Search scope"
|
||||
aria-label={t("comm.searchScope")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -38,7 +39,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
All Channels
|
||||
{t("comm.allChannels")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -52,13 +53,14 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
This Channel
|
||||
{t("comm.thisChannel")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const t = useT()
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
@@ -156,7 +158,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
} else {
|
||||
setSearchResults([], currentRequestId)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[communication] searchMessages failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
setSearchResults([], currentRequestId)
|
||||
}
|
||||
}, 300)
|
||||
@@ -203,7 +208,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
void (async () => {
|
||||
for (const id of ids) pendingReadIdsRef.current.add(id)
|
||||
setOptimisticReadIds((c) => { const n = new Set(c); for (const id of ids) n.add(id); return n })
|
||||
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch { return { id, ok: false as const } } }))
|
||||
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch (error) { console.warn(`[communication] markMessageRead(${id}) failed: ${error instanceof Error ? error.message : String(error)}`); return { id, ok: false as const } } }))
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.id)
|
||||
for (const id of ids) pendingReadIdsRef.current.delete(id)
|
||||
if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n })
|
||||
@@ -220,16 +225,22 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
|
||||
const channelGroups = useMemo(() => {
|
||||
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
|
||||
const cfgs: Record<MessageChannel, string> = { company: "Company-wide", department: "Department", project: "Project", incident: "Incident", direct: "Direct Messages" }
|
||||
const cfgs: Record<MessageChannel, string> = {
|
||||
company: t("comm.channelGroup.company"),
|
||||
department: t("comm.channelGroup.department"),
|
||||
project: t("comm.channelGroup.project"),
|
||||
incident: t("comm.channelGroup.incident"),
|
||||
direct: t("comm.channelGroup.direct"),
|
||||
}
|
||||
for (const type of (["company", "department", "project", "incident", "direct"] as MessageChannel[])) {
|
||||
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
|
||||
if (items.length > 0) groups.push({ type, label: cfgs[type], items })
|
||||
}
|
||||
return groups
|
||||
}, [channels])
|
||||
}, [channels, t])
|
||||
|
||||
const scopeToggleElement = isSearchActive ? (
|
||||
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} />
|
||||
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} t={t} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
|
||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user