feat: update desktop workflows and app center
This commit is contained in:
@@ -37,8 +37,11 @@ interface CronRunLogEntry {
|
||||
jobId?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
resolvedStatus?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
resolvedSummary?: string;
|
||||
recoveredError?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
ts?: number;
|
||||
@@ -56,12 +59,17 @@ interface CronSessionKeyParts {
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'system';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface CronTrajectoryArtifacts {
|
||||
finalStatus?: string;
|
||||
assistantText?: string;
|
||||
}
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
@@ -102,12 +110,22 @@ function formatDuration(durationMs: number | undefined): string | null {
|
||||
return `${Math.round(durationMs / 1000)}s`;
|
||||
}
|
||||
|
||||
function getCronRunStatus(entry: CronRunLogEntry): string {
|
||||
const status = entry.resolvedStatus || entry.status || '';
|
||||
return typeof status === 'string' ? status.toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getCronRunSummary(entry: CronRunLogEntry): string {
|
||||
const summary = entry.resolvedSummary || entry.summary || '';
|
||||
return typeof summary === 'string' ? summary.trim() : '';
|
||||
}
|
||||
|
||||
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
const status = getCronRunStatus(entry);
|
||||
const summary = getCronRunSummary(entry);
|
||||
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||
let content = summary || error;
|
||||
|
||||
@@ -135,7 +153,7 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
|
||||
|
||||
return {
|
||||
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
|
||||
role: status === 'error' ? 'system' : 'assistant',
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp,
|
||||
...(status === 'error' ? { isError: true } : {}),
|
||||
@@ -163,6 +181,65 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractTrajectoryArtifacts(raw: string): CronTrajectoryArtifacts | undefined {
|
||||
let latest: CronTrajectoryArtifacts | undefined;
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (entry.type !== 'trace.artifacts' || !entry.data || typeof entry.data !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = entry.data as Record<string, unknown>;
|
||||
const finalStatus = typeof data.finalStatus === 'string'
|
||||
? data.finalStatus
|
||||
: undefined;
|
||||
const assistantTexts = Array.isArray(data.assistantTexts)
|
||||
? data.assistantTexts.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
||||
: [];
|
||||
latest = {
|
||||
...(finalStatus ? { finalStatus } : {}),
|
||||
...(assistantTexts.length > 0 ? { assistantText: assistantTexts[assistantTexts.length - 1].trim() } : {}),
|
||||
};
|
||||
} catch {
|
||||
// Ignore malformed trajectory lines; cron logs are still useful without them.
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function readCronTrajectoryArtifacts(agentId: string, sessionId: string | undefined): Promise<CronTrajectoryArtifacts | undefined> {
|
||||
if (!sessionId) return undefined;
|
||||
const trajectoryPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.trajectory.jsonl`);
|
||||
const raw = await readFile(trajectoryPath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return undefined;
|
||||
return extractTrajectoryArtifacts(raw);
|
||||
}
|
||||
|
||||
async function resolveCronRunEntry(agentId: string, entry: CronRunLogEntry): Promise<CronRunLogEntry> {
|
||||
if (getCronRunStatus(entry) !== 'error') return entry;
|
||||
|
||||
const artifacts = await readCronTrajectoryArtifacts(agentId, entry.sessionId);
|
||||
if (artifacts?.finalStatus?.toLowerCase() !== 'success') return entry;
|
||||
|
||||
const recoveredError = entry.error || entry.summary;
|
||||
return {
|
||||
...entry,
|
||||
resolvedStatus: 'ok',
|
||||
...(artifacts.assistantText ? { resolvedSummary: artifacts.assistantText } : {}),
|
||||
...(recoveredError ? { recoveredError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCronRunEntries(agentId: string, entries: CronRunLogEntry[]): Promise<CronRunLogEntry[]> {
|
||||
return Promise.all(entries.map((entry) => resolveCronRunEntry(agentId, entry)));
|
||||
}
|
||||
|
||||
async function readSessionStoreEntry(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
@@ -227,13 +304,12 @@ export function buildCronSessionFallbackMessages(params: {
|
||||
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
|
||||
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
|
||||
|
||||
if (taskName || prompt) {
|
||||
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
|
||||
if (prompt) lines.push(`Prompt: ${prompt}`);
|
||||
if (prompt || taskName) {
|
||||
const content = prompt || `Scheduled task: ${taskName}`;
|
||||
messages.push({
|
||||
id: `cron-meta-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: lines.join('\n'),
|
||||
id: `cron-query-${parsed.jobId}-${firstRelevantTimestamp ?? 'pending'}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||
});
|
||||
}
|
||||
@@ -248,15 +324,15 @@ export function buildCronSessionFallbackMessages(params: {
|
||||
if (runningAt) {
|
||||
messages.push({
|
||||
id: `cron-running-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||
role: 'assistant',
|
||||
content: '任务正在执行,完成后会自动在这里显示结果。',
|
||||
timestamp: runningAt,
|
||||
});
|
||||
} else if (messages.length === 0) {
|
||||
messages.push({
|
||||
id: `cron-empty-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'No chat transcript is available for this scheduled task yet.',
|
||||
role: 'assistant',
|
||||
content: '这个任务还没有可显示的执行记录。',
|
||||
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -364,7 +440,53 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
|
||||
return patch;
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
function findLatestCronRunEntry(runs: CronRunLogEntry[], lastRunAtMs: number | undefined): CronRunLogEntry | undefined {
|
||||
const withTime = runs
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
timestamp: normalizeTimestampMs(entry.runAtMs) ?? normalizeTimestampMs(entry.ts) ?? 0,
|
||||
}))
|
||||
.filter((item) => item.timestamp > 0);
|
||||
if (withTime.length === 0) return runs[runs.length - 1];
|
||||
|
||||
if (lastRunAtMs) {
|
||||
const exact = withTime.find((item) => item.timestamp === lastRunAtMs);
|
||||
if (exact) return exact.entry;
|
||||
}
|
||||
|
||||
return withTime.sort((a, b) => b.timestamp - a.timestamp)[0]?.entry;
|
||||
}
|
||||
|
||||
async function resolveLatestCronRun(job: GatewayCronJob, agentId: string): Promise<CronRunLogEntry | undefined> {
|
||||
if (!job.state?.lastRunAtMs || job.state.lastStatus === 'ok') return undefined;
|
||||
const runs = await readCronRunLog(job.id);
|
||||
const latest = findLatestCronRunEntry(runs, job.state.lastRunAtMs);
|
||||
return latest ? resolveCronRunEntry(agentId, latest) : undefined;
|
||||
}
|
||||
|
||||
async function resolveRecoveredCronRunAfterError(ctx: HostApiContext, jobId: string): Promise<Record<string, unknown> | undefined> {
|
||||
const jobsResult = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] }));
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === jobId);
|
||||
const agentId = (job as unknown as { agentId?: string } | undefined)?.agentId || 'main';
|
||||
const runs = await readCronRunLog(jobId);
|
||||
const latest = findLatestCronRunEntry(runs, job?.state?.lastRunAtMs);
|
||||
if (!latest) return undefined;
|
||||
|
||||
const resolvedRun = await resolveCronRunEntry(agentId, latest);
|
||||
if (getCronRunStatus(resolvedRun) !== 'ok') return undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recovered: true,
|
||||
...(getCronRunSummary(resolvedRun) ? { summary: getCronRunSummary(resolvedRun) } : {}),
|
||||
...(resolvedRun.recoveredError ? { warning: resolvedRun.recoveredError } : {}),
|
||||
...(job ? { job: transformCronJob(job, resolvedRun) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob, resolvedLastRun?: CronRunLogEntry) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const gatewayDelivery = normalizeCronDelivery(job.delivery);
|
||||
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
|
||||
@@ -379,12 +501,15 @@ function transformCronJob(job: GatewayCronJob) {
|
||||
recipient: delivery.to,
|
||||
}
|
||||
: undefined;
|
||||
const resolvedRunStatus = resolvedLastRun ? getCronRunStatus(resolvedLastRun) : '';
|
||||
const effectiveLastStatus = resolvedRunStatus || job.state?.lastStatus;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
success: effectiveLastStatus === 'ok',
|
||||
...(effectiveLastStatus === 'ok' ? {} : { error: job.state.lastError }),
|
||||
duration: job.state.lastDurationMs,
|
||||
...(resolvedLastRun?.recoveredError ? { warning: resolvedLastRun.recoveredError, reconciled: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs
|
||||
@@ -430,12 +555,13 @@ export async function handleCronRoutes(
|
||||
: 200;
|
||||
|
||||
try {
|
||||
const [jobsResult, runs, sessionEntry] = await Promise.all([
|
||||
const [jobsResult, rawRuns, sessionEntry] = await Promise.all([
|
||||
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
|
||||
readCronRunLog(parsedSession.jobId),
|
||||
readSessionStoreEntry(parsedSession.agentId, sessionKey),
|
||||
]);
|
||||
const runs = await resolveCronRunEntries(parsedSession.agentId, rawRuns);
|
||||
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||
@@ -589,7 +715,13 @@ export async function handleCronRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
|
||||
const responseJobs = await Promise.all(jobs.map(async (job) => {
|
||||
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
|
||||
const resolvedLastRun = await resolveLatestCronRun(job, agentId);
|
||||
return { ...transformCronJob(job, resolvedLastRun), ...(usedFallback ? { _fromFallback: true } : {}) };
|
||||
}));
|
||||
|
||||
sendJson(res, 200, responseJobs);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
@@ -684,7 +816,16 @@ export async function handleCronRoutes(
|
||||
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
try {
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
} catch (runError) {
|
||||
const recovered = await resolveRecoveredCronRunAfterError(ctx, body.id);
|
||||
if (recovered) {
|
||||
sendJson(res, 200, { ...recovered, triggerWarning: String(runError) });
|
||||
return true;
|
||||
}
|
||||
throw runError;
|
||||
}
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
|
||||
28
electron/api/routes/local-preferences.ts
Normal file
28
electron/api/routes/local-preferences.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getAllLocalPreferences, patchLocalPreferences, type AppLocalPreferences } from '../../utils/local-preferences';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleLocalPreferenceRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/local-preferences' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllLocalPreferences());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/local-preferences' && req.method === 'PUT') {
|
||||
try {
|
||||
const patch = await parseJsonBody<AppLocalPreferences>(req);
|
||||
sendJson(res, 200, await patchLocalPreferences(patch));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user