mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat: add cron tracking to agent activity
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }) : ""}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user