feat: add cron tracking to agent activity

This commit is contained in:
xmanrui
2026-03-12 02:49:35 +08:00
parent 85c7704634
commit 2479ce6094
3 changed files with 359 additions and 1 deletions
+270 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { promises as fs, existsSync } from 'fs'
import path from 'path'
import { parseJsonText } from '@/lib/json'
import { OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH } from '@/lib/openclaw-paths'
import { OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from '@/lib/openclaw-paths'
export const dynamic = 'force-dynamic'
export const revalidate = 0
@@ -14,6 +14,22 @@ const SUBAGENT_ACTIVITY_EVENT_LIMIT = 6
const SUBAGENT_ACTIVITY_TEXT_MAX_LEN = 80
type SessionsIndex = Record<string, { sessionId?: string; updatedAt?: number }>
type CronStoreJob = {
id: string
agentId?: string
sessionKey?: string
name?: string
enabled?: boolean
payload?: { kind?: string; message?: string; text?: string }
state?: {
nextRunAtMs?: number
lastRunAtMs?: number
lastDurationMs?: number
lastStatus?: string
lastError?: string
consecutiveErrors?: number
}
}
export interface SubagentActivityEvent {
key: string
@@ -29,6 +45,19 @@ export interface SubagentInfo {
activityEvents?: SubagentActivityEvent[]
}
export interface CronJobInfo {
key: string
jobId: string
label: string
isRunning: boolean
lastRunAt: number
nextRunAt?: number
durationMs?: number
lastStatus: 'success' | 'running' | 'failed'
lastSummary?: string
consecutiveFailures: number
}
export interface AgentActivity {
agentId: string
name: string
@@ -38,6 +67,7 @@ export interface AgentActivity {
toolStatus?: string
lastActive: number
subagents?: SubagentInfo[]
cronJobs?: CronJobInfo[]
}
type AgentConfigEntry = {
@@ -127,6 +157,185 @@ function normalizeActivityText(raw: unknown): string | null {
: compact
}
function normalizeCronLabel(raw: unknown, fallbackKey: string): string {
if (typeof raw === 'string' && raw.trim()) {
return raw.replace(/^Cron:\s*/i, '').trim() || fallbackKey
}
return fallbackKey
}
function truncateSummary(raw: string, maxLen = 120): string {
const compact = raw.replace(/\s+/g, ' ').trim()
if (!compact) return ''
return compact.length > maxLen ? `${compact.slice(0, maxLen - 1)}` : compact
}
function resolveCronStorePath(config: any): string {
const raw = typeof config?.cron?.store === 'string' ? config.cron.store.trim() : ''
if (!raw) return path.join(OPENCLAW_HOME, 'cron', 'jobs.json')
if (raw.startsWith('~')) return path.join(process.env.HOME || '', raw.slice(1))
return path.resolve(raw)
}
async function loadCronJobs(config: any): Promise<CronStoreJob[]> {
const storePath = resolveCronStorePath(config)
if (!existsSync(storePath)) return []
try {
const raw = await fs.readFile(storePath, 'utf8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed?.jobs) ? parsed.jobs.filter(Boolean) : []
} catch {
return []
}
}
function inferCronOwnerAgentId(job: CronStoreJob): string {
if (typeof job.agentId === 'string' && job.agentId.trim()) return job.agentId.trim()
if (typeof job.sessionKey === 'string' && job.sessionKey.startsWith('agent:')) {
const parts = job.sessionKey.split(':')
if (parts[1]?.trim()) return parts[1].trim()
}
return 'main'
}
function deriveCronSummaryFromJob(job: CronStoreJob): string | undefined {
const lastError = typeof job.state?.lastError === 'string' ? truncateSummary(job.state.lastError) : ''
if (lastError) return lastError
const payloadText =
typeof job.payload?.message === 'string'
? job.payload.message
: typeof job.payload?.text === 'string'
? job.payload.text
: ''
return payloadText ? truncateSummary(payloadText) : undefined
}
function mapCronStatus(status: string | undefined): 'success' | 'running' | 'failed' {
const normalized = (status || '').trim().toLowerCase()
if (normalized === 'error' || normalized === 'failed') return 'failed'
if (normalized === 'running') return 'running'
return 'success'
}
function inferCronStatusFromTranscript(lines: string[], updatedAt: number): {
isRunning: boolean
lastStatus: 'success' | 'running' | 'failed'
lastSummary?: string
durationMs?: number
} {
let lastAssistantText: string | undefined
let lastAssistantError: string | undefined
let lastToolError: string | undefined
let lastToolText: string | undefined
let sawTerminalAssistant = false
let sawToolUseWithoutResult = false
let firstAt = 0
let lastAt = 0
for (const line of lines) {
let record: any
try {
record = JSON.parse(line)
} catch {
continue
}
const msg = record?.message
if (!msg || typeof msg !== 'object') continue
const at = parseRecordTimestamp(record)
if (at > 0 && (firstAt === 0 || at < firstAt)) firstAt = at
if (at > lastAt) lastAt = at
if (record.type === 'message') {
const role = typeof msg.role === 'string' ? msg.role : ''
const blocks = Array.isArray(msg.content) ? msg.content : []
if (role === 'assistant') {
for (const block of blocks) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
sawToolUseWithoutResult = true
}
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
lastAssistantText = truncateSummary(b.text)
}
}
if (typeof msg.errorMessage === 'string' && msg.errorMessage.trim()) {
lastAssistantError = truncateSummary(msg.errorMessage)
}
const stopReason = typeof msg.stopReason === 'string' ? msg.stopReason : ''
if (stopReason === 'stop' || stopReason === 'error') {
sawTerminalAssistant = true
}
}
if (role === 'toolResult') {
const details = isPlainObject(msg.details) ? msg.details : null
const status = typeof details?.status === 'string' ? details.status : ''
if (status.toLowerCase() === 'error' || msg.isError === true) {
const detailError =
typeof details?.error === 'string' && details.error.trim()
? details.error
: typeof details?.aggregated === 'string' && details.aggregated.trim()
? details.aggregated
: ''
if (detailError) lastToolError = truncateSummary(detailError)
} else {
const detailText =
typeof details?.aggregated === 'string' && details.aggregated.trim()
? details.aggregated
: Array.isArray(msg.content)
? msg.content
.map((block: any) => (block?.type === 'text' && typeof block.text === 'string') ? block.text : '')
.join(' ')
: ''
if (detailText.trim()) lastToolText = truncateSummary(detailText)
}
sawToolUseWithoutResult = false
}
}
}
if (lastAssistantError || lastToolError) {
return {
isRunning: false,
lastStatus: 'failed',
lastSummary: lastAssistantError || lastToolError,
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
}
}
if (sawTerminalAssistant && lastAssistantText) {
return {
isRunning: false,
lastStatus: 'success',
lastSummary: lastAssistantText,
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
}
}
const recentlyUpdated = updatedAt > 0 && Date.now() - updatedAt <= 2 * 60 * 1000
if (sawToolUseWithoutResult || recentlyUpdated) {
return {
isRunning: true,
lastStatus: 'running',
lastSummary: lastAssistantText || lastToolText,
durationMs: firstAt > 0 ? Math.max(0, Date.now() - firstAt) : undefined,
}
}
return {
isRunning: false,
lastStatus: lastToolText ? 'success' : 'running',
lastSummary: lastAssistantText || lastToolText,
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
}
}
function isPlainObject(value: unknown): value is Record<string, any> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function extractChildSessionKeyFromPayload(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null
const data = payload as Record<string, unknown>
@@ -507,6 +716,58 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis
return allSubagents
}
async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronStoreJob[]): Promise<CronJobInfo[]> {
if (cronJobsForAgent.length === 0) return []
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
const sessionsIndex = existsSync(sessionsIndexPath)
? JSON.parse(await fs.readFile(sessionsIndexPath, 'utf8')) as Record<string, any>
: {}
const cronJobs: CronJobInfo[] = []
for (const job of cronJobsForAgent) {
const entries = Object.entries(sessionsIndex)
.filter(([sessionKey, meta]) => sessionKey.includes(`:cron:${job.id}`) && meta && typeof (meta as any).sessionId === 'string')
.map(([sessionKey, meta]) => ({
sessionKey,
sessionId: (meta as any).sessionId as string,
updatedAt: typeof (meta as any).updatedAt === 'number' ? (meta as any).updatedAt : 0,
label: typeof (meta as any).label === 'string' ? (meta as any).label : undefined,
}))
.sort((a, b) => b.updatedAt - a.updatedAt)
const latest = entries[0]
let transcriptStatus: ReturnType<typeof inferCronStatusFromTranscript> | null = null
if (latest) {
const transcriptPath = path.join(agentSessionsDir, `${latest.sessionId}.jsonl`)
if (existsSync(transcriptPath)) {
const transcript = await fs.readFile(transcriptPath, 'utf8')
transcriptStatus = inferCronStatusFromTranscript(transcript.split('\n').filter((line) => line.trim()), latest.updatedAt)
}
}
const fallbackLabel = latest?.sessionKey?.split(':cron:')[1] || job.id
const state = job.state || {}
cronJobs.push({
key: latest?.sessionKey?.includes(':run:') ? latest.sessionKey.split(':run:')[0] : latest?.sessionKey || `agent:${inferCronOwnerAgentId(job)}:cron:${job.id}`,
jobId: job.id,
label: normalizeCronLabel(job.name || latest?.label, fallbackLabel),
isRunning: transcriptStatus?.isRunning || mapCronStatus(state.lastStatus) === 'running',
lastRunAt: typeof state.lastRunAtMs === 'number' ? state.lastRunAtMs : latest?.updatedAt || 0,
nextRunAt: typeof state.nextRunAtMs === 'number' ? state.nextRunAtMs : undefined,
durationMs: typeof state.lastDurationMs === 'number' ? state.lastDurationMs : transcriptStatus?.durationMs,
lastStatus: transcriptStatus?.lastStatus || mapCronStatus(state.lastStatus),
lastSummary: transcriptStatus?.lastSummary || deriveCronSummaryFromJob(job),
consecutiveFailures: typeof state.consecutiveErrors === 'number' ? state.consecutiveErrors : 0,
})
}
cronJobs.sort((a, b) => b.lastRunAt - a.lastRunAt)
return cronJobs
}
export async function GET() {
const configPath = OPENCLAW_CONFIG_PATH
const agentsDir = OPENCLAW_AGENTS_DIR
@@ -521,6 +782,7 @@ export async function GET() {
const agentList = await loadAgentList(config, agentsDir)
if (agentList.length > 0) {
const now = Date.now()
const liveCronJobs = await loadCronJobs(config)
for (const agent of agentList) {
let lastActive = 0
@@ -556,9 +818,15 @@ export async function GET() {
// Parse subagents for online agents
let subagents: SubagentInfo[] | undefined
let cronJobs: CronJobInfo[] | undefined
if (state !== 'offline' && agentSessionsDir && existsSync(agentSessionsDir)) {
subagents = await parseSubagents(agentSessionsDir, agent.id)
if (subagents.length === 0) subagents = undefined
cronJobs = await parseCronJobs(
agentSessionsDir,
liveCronJobs.filter((job) => inferCronOwnerAgentId(job) === agent.id),
)
if (cronJobs.length === 0) cronJobs = undefined
}
agents.push({
@@ -568,6 +836,7 @@ export async function GET() {
state,
lastActive,
subagents,
cronJobs,
})
}
}
+59
View File
@@ -92,6 +92,18 @@ interface AgentActivityData {
state: "idle" | "working" | "waiting" | "offline";
lastActive: number;
subagents?: SubagentInfo[];
cronJobs?: Array<{
key: string;
jobId: string;
label: string;
isRunning: boolean;
lastRunAt: number;
nextRunAt?: number;
durationMs?: number;
lastStatus: "success" | "running" | "failed";
lastSummary?: string;
consecutiveFailures: number;
}>;
}
type TFunc = (key: string) => string;
@@ -684,6 +696,7 @@ export default function Home() {
</div>
{agent.subagents && agent.subagents.length > 0 ? (
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-wide opacity-60">{t("home.agentTaskSubtasks")}</div>
{agent.subagents.map((sub, i) => (
<div key={i} className="text-xs text-[var(--text-muted)]">
<span className="text-[var(--accent)] mr-1"></span>
@@ -697,6 +710,52 @@ export default function Home() {
) : (
<div className="text-xs text-[var(--text-muted)] opacity-60">{t("home.agentTaskNoSubtasks")}</div>
)}
{agent.cronJobs && agent.cronJobs.length > 0 ? (
<div className="space-y-1 mt-2 pt-2 border-t border-[var(--border)]">
<div className="text-[10px] uppercase tracking-wide opacity-60">{t("home.agentTaskCron")}</div>
{agent.cronJobs.map((cron) => (
<div key={cron.key} className="text-xs text-[var(--text-muted)]">
<div className="flex items-center gap-2">
<span className="text-yellow-400"></span>
<span className="font-medium text-[var(--text)]">{cron.label}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
cron.lastStatus === "running"
? "bg-sky-500/20 text-sky-400"
: cron.lastStatus === "failed"
? "bg-red-500/20 text-red-400"
: "bg-emerald-500/20 text-emerald-400"
}`}>
{cron.lastStatus === "running"
? t("home.agentTaskCronState.running")
: cron.lastStatus === "failed"
? t("home.agentTaskCronState.failed")
: t("home.agentTaskCronState.success")}
</span>
{cron.consecutiveFailures > 0 && (
<span className="text-[10px] text-red-400">
{t("home.agentTaskCronFailures")} {cron.consecutiveFailures}
</span>
)}
</div>
<div className="ml-5 opacity-70">
{cron.lastSummary || t("home.agentTaskCronNoSummary")}
</div>
<div className="ml-5 mt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-[10px] opacity-60">
{cron.durationMs !== undefined && (
<span>{t("home.agentTaskCronDuration")} {Math.max(1, Math.round(cron.durationMs / 1000))}s</span>
)}
{cron.nextRunAt ? (
<span>
{t("home.agentTaskCronNextRun")} {new Date(cron.nextRunAt).toLocaleTimeString(locale === "zh" ? "zh-CN" : locale, { hour: "2-digit", minute: "2-digit" })}
</span>
) : null}
</div>
</div>
))}
</div>
) : (
<div className="text-xs text-[var(--text-muted)] opacity-60 mt-2">{t("home.agentTaskNoCron")}</div>
)}
</div>
<div className="text-[10px] text-[var(--text-muted)] whitespace-nowrap">
{agent.lastActive ? new Date(agent.lastActive).toLocaleTimeString(locale === "zh" ? "zh-CN" : locale, { hour: "2-digit", minute: "2-digit" }) : ""}
+30
View File
@@ -91,10 +91,20 @@ const translations: Record<Locale, Record<string, string>> = {
"home.testDmSessions": "測試 DM Session",
"home.testingDmSessions": "測試中...",
"home.agentTaskTracking": "Agent 任務追蹤",
"home.agentTaskSubtasks": "子任務",
"home.agentTaskCron": "排程任務",
"home.agentTaskState.working": "執行中",
"home.agentTaskState.waiting": "等待中",
"home.agentTaskState.idle": "閒置",
"home.agentTaskNoSubtasks": "無進行中的子任務",
"home.agentTaskNoCron": "無排程任務活動",
"home.agentTaskCronState.running": "執行中",
"home.agentTaskCronState.success": "成功",
"home.agentTaskCronState.failed": "失敗",
"home.agentTaskCronFailures": "連續失敗",
"home.agentTaskCronNoSummary": "暫無摘要",
"home.agentTaskCronDuration": "耗時",
"home.agentTaskCronNextRun": "下次執行",
// agent card
"agent.model": "模型",
@@ -363,10 +373,20 @@ const translations: Record<Locale, Record<string, string>> = {
"home.testDmSessions": "测试 DM Session",
"home.testingDmSessions": "测试中...",
"home.agentTaskTracking": "Agent 任务追踪",
"home.agentTaskSubtasks": "子任务",
"home.agentTaskCron": "定时任务",
"home.agentTaskState.working": "执行中",
"home.agentTaskState.waiting": "等待中",
"home.agentTaskState.idle": "闲置",
"home.agentTaskNoSubtasks": "无进行中的子任务",
"home.agentTaskNoCron": "无定时任务活动",
"home.agentTaskCronState.running": "执行中",
"home.agentTaskCronState.success": "成功",
"home.agentTaskCronState.failed": "失败",
"home.agentTaskCronFailures": "连续失败",
"home.agentTaskCronNoSummary": "暂无摘要",
"home.agentTaskCronDuration": "耗时",
"home.agentTaskCronNextRun": "下次执行",
// agent card
"agent.model": "模型",
@@ -635,10 +655,20 @@ const translations: Record<Locale, Record<string, string>> = {
"home.testDmSessions": "Test DM Sessions",
"home.testingDmSessions": "Testing...",
"home.agentTaskTracking": "Agent Task Tracking",
"home.agentTaskSubtasks": "Subtasks",
"home.agentTaskCron": "Cron Jobs",
"home.agentTaskState.working": "Running",
"home.agentTaskState.waiting": "Waiting",
"home.agentTaskState.idle": "Idle",
"home.agentTaskNoSubtasks": "No active subtasks",
"home.agentTaskNoCron": "No cron activity",
"home.agentTaskCronState.running": "Running",
"home.agentTaskCronState.success": "Success",
"home.agentTaskCronState.failed": "Failed",
"home.agentTaskCronFailures": "Failures",
"home.agentTaskCronNoSummary": "No summary",
"home.agentTaskCronDuration": "Duration",
"home.agentTaskCronNextRun": "Next run",
// agent card
"agent.model": "Model",