Commit c8f6b1df authored by edy's avatar edy

feat(desktop): run automation tasks in background

parent 143789ee
......@@ -5,6 +5,7 @@ import path from "node:path";
import {
IPC_CHANNELS,
type AppConfig,
type AutomationTask,
type AutomationTaskRun,
type CreateAutomationTaskInput,
type ChatAttachment,
......@@ -1733,6 +1734,47 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
};
const summarizeAutomationReply = (replyText: string): string => {
const normalized = replyText.replace(/\s+/g, " ").trim();
return normalized.length > 160 ? normalized.slice(0, 160) + "..." : normalized;
};
const toAutomationTaskPanelArtifacts = (
artifacts: TaskPanelArtifact[] | undefined,
replyText: string,
runId: string
): TaskPanelArtifact[] => {
if (artifacts?.length) {
return artifacts;
}
const summary = summarizeAutomationReply(replyText);
if (!summary) {
return [];
}
return [
{
id: `automation-reply:${runId}`,
name: "自动化执行结果",
kind: "回复",
summary
}
];
};
const resolveAutomationTaskPanelExpertName = async (task: AutomationTask, projectId: string): Promise<string> => {
const taskExpertName = task.expertName?.trim();
if (taskExpertName) {
return taskExpertName;
}
const project = await projectStore.getProjectSummary(projectId).catch(() => null);
return project?.displayName?.trim()
|| project?.name?.trim()
|| (projectId === BUILTIN_HOME_PROJECT_ID ? "通用助手" : projectId);
};
const sendHomeImagePrompt = async (
sessionId: string,
prompt: string,
......@@ -2013,6 +2055,114 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
}
};
const executeAutomationPrompt = async (sessionId: string, projectId: string, prompt: string) => {
const project = await projectStore.getProjectSummary(projectId);
const projectRoot = await projectStore.getProjectRoot(project.id);
const projectConfig = await projectStore.getProjectPackageConfig(project.id);
const snapshot = await projectContextService.getSnapshot(project.id);
const declaredWorkspaceEntryDecision = await projectExecutionRouter.decide({
sessionId,
projectId: project.id,
projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: null,
attachments: [],
projectConfig
});
const preferWorkspaceEntry = declaredWorkspaceEntryDecision.kind === "workspace-entry";
const autoSkillRoute = preferWorkspaceEntry ? null : await projectSkillRouter.resolve(project.id, prompt);
const defaultEntryRoute = (!autoSkillRoute && projectConfig?.defaultEntry?.type === "skill")
? ((await projectStore.getProjectSkillTarget(project.id, projectConfig.defaultEntry.id))
? {
skillId: projectConfig.defaultEntry.id,
reason: `project default entry ${projectConfig.defaultEntry.id}`,
score: 0
}
: null)
: null;
const resolvedSkillRoute = autoSkillRoute ?? defaultEntryRoute;
const decision = resolvedSkillRoute
? await projectExecutionRouter.decide({
sessionId,
projectId: project.id,
projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: resolvedSkillRoute.skillId,
attachments: [],
projectConfig
})
: declaredWorkspaceEntryDecision;
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined;
const executionPolicy = await resolveExecutionPolicy(project.id, executionSkillId, decision.kind);
const gatewayPrompt = await prepareGatewayPrompt(decision, project.id);
const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(decision);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, executionSkillId);
try {
if (decision.kind === "workspace-entry") {
const lobsterEnv = await prepareWorkspaceEntryLobsterEnv();
const projectModelEnv = await prepareProjectModelRuntime(project.id, projectRoot);
const result = await projectWorkspaceExecutor.execute({
sessionId,
projectRoot,
prompt: decision.preparedPrompt,
userPrompt: prompt,
attachments: [],
extraEnv: {
...projectModelEnv,
...lobsterEnv
}
});
if ("handoff" in result) {
const fallbackExecutionPolicy = await resolveExecutionPolicy(project.id, undefined, "chat-fallback");
const fallbackResult = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, {
reason: "automation-task",
execute: () => gatewayClient.sendPrompt(sessionId, result.handoff.content)
});
runtimeCloudSupervisor.noteMessageSent(
fallbackResult.sessionId,
fallbackResult.reply.content,
fallbackExecutionPolicy.modelId,
executionSkillId
);
return { ...fallbackResult, executionPolicy: fallbackExecutionPolicy, artifacts: result.artifacts };
}
runtimeCloudSupervisor.noteMessageSent(sessionId, result.reply.content, executionPolicy.modelId, executionSkillId);
return {
sessionId,
reply: result.reply,
executionPolicy,
artifacts: result.artifacts
};
}
const result = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, {
reason: "automation-task",
execute: () => gatewayClient.sendPrompt(sessionId, gatewayPrompt ?? prompt)
});
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, executionSkillId);
return { ...result, executionPolicy, artifacts: [] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeCloudSupervisor.noteError("automation_task_failed", message, {
modelId: executionPolicy.modelId,
sessionId
});
throw error;
} finally {
if (shouldScheduleContextRefresh) {
void refreshProjectContextAfterExecution({
projectId: project.id,
projectContextService,
projectStore
});
}
}
};
const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[], sender?: WebContents) => {
const requestId = randomUUID();
const userMessageId = randomUUID();
......@@ -2818,15 +2968,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
automationTaskService.setExecutor(async ({ task, run }) => {
const projectId = task.expertId?.trim() || BUILTIN_HOME_PROJECT_ID;
const projects = await projectStore.listProjects().catch(() => []);
const project = projects.find((candidate) => candidate.id === projectId);
const expertName = task.expertName?.trim()
|| project?.displayName?.trim()
|| project?.name?.trim()
|| (projectId === BUILTIN_HOME_PROJECT_ID ? "千匠问天" : projectId);
const session = await projectStore.createSession(`[自动化] ${task.title}`, projectId);
runtimeCloudSupervisor.noteSessions([session.id]);
const result = await sendPrompt(session.id, task.prompt);
const sessionId = `automation:${projectId}:${task.id}:${run.id}`;
runtimeCloudSupervisor.noteSessions([sessionId]);
const result = await executeAutomationPrompt(sessionId, projectId, task.prompt);
const expertName = await resolveAutomationTaskPanelExpertName(task, projectId);
await taskPanelService.recordWorkspaceExecution({
sessionId: result.sessionId,
......@@ -2835,7 +2980,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
taskTitle: task.title,
completedAt: new Date().toISOString(),
messageCount: 2,
artifacts: []
artifacts: toAutomationTaskPanelArtifacts(result.artifacts, result.reply.content, run.id)
}).catch((error) => startupLogger.warn("diagnostics", "automation-task.archive-failed", "Failed to archive automation task output.", {
taskId: task.id,
runId: run.id,
......@@ -2846,7 +2991,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
sessionId: result.sessionId,
runId: result.reply.id,
replyText: result.reply.content,
artifacts: []
artifacts: result.artifacts
};
});
......
......@@ -3,7 +3,7 @@ import type { ProjectContextService } from "./project-context.js";
import type { ProjectStoreService } from "./project-store.js";
interface ProjectContextRefreshOptions {
sessionId: string;
sessionId?: string;
projectId: string;
projectContextService: ProjectContextService;
projectStore: ProjectStoreService;
......@@ -31,6 +31,9 @@ export async function refreshProjectContextAfterExecution({
projectContextService.invalidateSnapshot(projectId);
try {
const snapshot = await projectContextService.refreshSnapshot(projectId);
if (!sessionId) {
return;
}
const latestSessionState = await projectStore.getSessionState(sessionId);
if (latestSessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(sessionId, snapshot.snapshotId);
......
......@@ -7,6 +7,18 @@ const ipcSource = readFileSync(new URL("../src/main/ipc.ts", import.meta.url), "
const indexSource = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8")
const preloadSource = readFileSync(new URL("../src/preload/index.ts", import.meta.url), "utf8")
function getAutomationExecutorBody(): string {
const executorMatch = ipcSource.match(/automationTaskService\.setExecutor\(async \(\{ task, run \}\) => \{(?<body>[\s\S]*?)\n \}\);/)
assert.ok(executorMatch?.groups?.body)
return executorMatch.groups.body
}
function getExecuteAutomationPromptBody(): string {
const bodyMatch = ipcSource.match(/const executeAutomationPrompt = async \(sessionId: string, projectId: string, prompt: string\) => \{(?<body>[\s\S]*?)\n \};\n\n const streamPrompt = async/)
assert.ok(bodyMatch?.groups?.body)
return bodyMatch.groups.body
}
test("shared automation task channels and desktop API are declared", () => {
assert.match(sharedTypesSource, /automationTasksList:\s*"automation-tasks:list"/)
assert.match(sharedTypesSource, /automationTasksCreate:\s*"automation-tasks:create"/)
......@@ -26,3 +38,53 @@ test("desktop IPC wires automation tasks to AutomationTaskService", () => {
assert.match(preloadSource, /automationTasks:\s*\{/)
assert.match(preloadSource, /IPC_CHANNELS\.automationTasksCreate/)
})
test("automation executor runs in the background without creating visible chat sessions", () => {
const executorBody = getAutomationExecutorBody()
assert.match(executorBody, /const sessionId = `automation:\$\{projectId\}:\$\{task\.id\}:\$\{run\.id\}`/)
assert.doesNotMatch(executorBody, /projectStore\.createSession/)
assert.doesNotMatch(executorBody, /sendPrompt\(/)
assert.doesNotMatch(executorBody, /\[自动化\]/)
})
test("automation executor archives completed runs to the task panel", () => {
const executorBody = getAutomationExecutorBody()
assert.match(executorBody, /const expertName = await resolveAutomationTaskPanelExpertName\(task, projectId\)/)
assert.match(executorBody, /taskPanelService\.recordWorkspaceExecution\(\{/)
assert.match(executorBody, /sessionId: result\.sessionId/)
assert.match(executorBody, /runId: run\.id/)
assert.match(executorBody, /expertName/)
assert.match(executorBody, /taskTitle: task\.title/)
assert.match(executorBody, /messageCount: 2/)
assert.match(executorBody, /artifacts: toAutomationTaskPanelArtifacts\(result\.artifacts, result\.reply\.content, run\.id\)/)
})
test("automation task panel archive uses general assistant fallback and reply artifacts", () => {
const artifactHelperMatch = ipcSource.match(/const toAutomationTaskPanelArtifacts = \([\s\S]*?\n \};/)
assert.ok(artifactHelperMatch?.[0])
const artifactHelper = artifactHelperMatch[0]
assert.match(ipcSource, /task\.expertName\?\.trim\(\)/)
assert.match(ipcSource, /project\?\.displayName\?\.trim\(\)/)
assert.match(ipcSource, /project\?\.name\?\.trim\(\)/)
assert.match(ipcSource, /projectId === BUILTIN_HOME_PROJECT_ID \? "通用助手" : projectId/)
assert.match(artifactHelper, /name: "自动化执行结果"/)
assert.match(artifactHelper, /kind: "回复"/)
assert.doesNotMatch(artifactHelper, /path:/)
assert.doesNotMatch(artifactHelper, /url:/)
})
test("automation prompt execution refreshes project context after completion without binding a visible session", () => {
const automationPromptBody = getExecuteAutomationPromptBody()
const refreshCallMatch = automationPromptBody.match(/refreshProjectContextAfterExecution\(\{(?<args>[\s\S]*?)\}\)/)
assert.ok(refreshCallMatch?.groups?.args)
assert.match(automationPromptBody, /const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution\(decision\)/)
assert.match(automationPromptBody, /finally\s*\{/)
assert.match(refreshCallMatch.groups.args, /projectId: project\.id/)
assert.match(refreshCallMatch.groups.args, /projectContextService/)
assert.match(refreshCallMatch.groups.args, /projectStore/)
assert.doesNotMatch(refreshCallMatch.groups.args, /sessionId/)
})
......@@ -145,6 +145,12 @@ test("marks missed app-runtime schedules without auto backfill", async () => {
test("manual runNow executes the configured executor and records output", async () => {
await withService(async (service) => {
service.setExecutor(async ({ task, run: taskRun }) => ({
sessionId: `automation:home-chat:${task.id}:${taskRun.id}`,
runId: taskRun.id,
replyText: `done:${task.prompt}`,
artifacts: []
}))
const task = await service.create({
title: "线索整理",
prompt: "整理线索",
......@@ -155,7 +161,7 @@ test("manual runNow executes the configured executor and records output", async
const run = await service.runNow(task.id)
assert.equal(run.status, "completed")
assert.equal(run.replyText, "done:整理线索")
assert.equal(run.sessionId, `session-${task.id}`)
assert.equal(run.sessionId, `automation:home-chat:${task.id}:${run.id}`)
const runs = await service.listRuns(task.id)
assert.equal(runs[0]?.id, run.id)
......
import test from "node:test"
import assert from "node:assert/strict"
import { refreshProjectContextAfterExecution } from "../src/main/services/project-context-lifecycle.ts"
function createHarness(options: {
snapshotId?: string
existingSnapshotId?: string | null
refreshError?: Error
} = {}) {
const calls: string[] = []
const snapshotId = options.snapshotId ?? "snapshot-new"
const existingSnapshotId = options.existingSnapshotId ?? null
const refreshError = options.refreshError
const projectContextService = {
invalidateSnapshot(projectId: string) {
calls.push(`invalidate:${projectId}`)
},
async refreshSnapshot(projectId: string) {
calls.push(`refresh:${projectId}`)
if (refreshError) {
throw refreshError
}
return { snapshotId }
}
}
const projectStore = {
async getSessionState(sessionId: string) {
calls.push(`get-session:${sessionId}`)
return { contextSnapshotId: existingSnapshotId }
},
async bindSessionContextSnapshot(sessionId: string, nextSnapshotId: string) {
calls.push(`bind-session:${sessionId}:${nextSnapshotId}`)
}
}
return { calls, projectContextService, projectStore }
}
test("refreshes project context without reading session state when sessionId is omitted", async () => {
const { calls, projectContextService, projectStore } = createHarness()
await refreshProjectContextAfterExecution({
projectId: "project-1",
projectContextService,
projectStore
})
assert.deepEqual(calls, [
"invalidate:project-1",
"refresh:project-1"
])
})
test("binds the refreshed snapshot to a provided session", async () => {
const { calls, projectContextService, projectStore } = createHarness({
snapshotId: "snapshot-new",
existingSnapshotId: "snapshot-old"
})
await refreshProjectContextAfterExecution({
sessionId: "project:project-1",
projectId: "project-1",
projectContextService,
projectStore
})
assert.deepEqual(calls, [
"invalidate:project-1",
"refresh:project-1",
"get-session:project:project-1",
"bind-session:project:project-1:snapshot-new"
])
})
test("invalidates again and does not throw when refresh fails", async () => {
const { calls, projectContextService, projectStore } = createHarness({
refreshError: new Error("refresh failed")
})
await refreshProjectContextAfterExecution({
projectId: "project-1",
projectContextService,
projectStore
})
assert.deepEqual(calls, [
"invalidate:project-1",
"refresh:project-1",
"invalidate:project-1"
])
})
......@@ -79,6 +79,31 @@ test("dedupes repeated runs and repeated artifact paths", async () => {
})
})
test("records pathless automation reply artifacts as visible outputs", async () => {
await withService(async (service) => {
await service.recordWorkspaceExecution({
sessionId: "automation:home-chat:task-1:run-1",
runId: "run-1",
date: "2026-05-15",
expertName: "通用助手",
taskTitle: "自动化回复",
completedAt: "2026-05-15T02:00:00.000Z",
artifacts: [
{ id: "reply-1", name: "自动化执行结果", kind: "回复", summary: "已整理完成" }
]
})
const items = await service.listByDate("2026-05-15")
assert.equal(items.length, 1)
assert.equal(items[0]?.artifacts.length, 1)
assert.equal(items[0]?.artifacts[0]?.name, "自动化执行结果")
assert.equal(items[0]?.artifacts[0]?.kind, "回复")
assert.equal(items[0]?.artifacts[0]?.summary, "已整理完成")
assert.equal(items[0]?.artifacts[0]?.path, undefined)
assert.equal(items[0]?.artifacts[0]?.url, undefined)
})
})
test("preserves all tasks when multiple executions are recorded concurrently", async () => {
await withService(async (service) => {
await Promise.all(Array.from({ length: 12 }, (_, index) => service.recordWorkspaceExecution({
......
......@@ -18,6 +18,13 @@ test("task panel output uses real artifact paths and produced timestamps", () =>
assert.match(source, /artifact\.producedAt\s*\?\?/)
})
test("task panel output renders pathless artifacts without copy controls", () => {
assert.match(source, /const artifactPath = artifact\.path \?\? artifact\.url/)
assert.match(source, /<strong title=\{artifact\.name\}>\{artifact\.name\}<\/strong>/)
assert.match(source, /<p>\{artifact\.summary \?\? task\.taskTitle\}<\/p>/)
assert.match(source, /\{artifactPath \? \(/)
})
test("task panel output copy feedback is scoped to the clicked output row", () => {
assert.match(source, /copiedOutputRowKey/)
assert.match(source, /const outputRowKey = getTaskPanelOutputRowKey\(task, artifact\)/)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment