feat: complete audit trail on /activity
- bridge GET /tiger/activity/audit merges every durable action store at read time: executions (spawns), tasks lifecycle, outputs, OpenClaw cron run JSONL. Cursor pagination (before=ISO), type filters. Read-time merge = retroactively complete, no action without an audit row. - dashboard /api/activity merges in recent file-modification events - /activity page: type filter chips, status colors, Load older
This commit is contained in:
parent
0142b1bfe7
commit
197c43dfee
4 changed files with 461 additions and 109 deletions
|
|
@ -149,6 +149,8 @@ app.use("/tiger/notify", notifyRouter);
|
||||||
app.use("/tiger/dispatch", dispatchRouter);
|
app.use("/tiger/dispatch", dispatchRouter);
|
||||||
app.use("/tiger/agents", agentsRouter);
|
app.use("/tiger/agents", agentsRouter);
|
||||||
app.use("/tiger/agents/activity", agentsActivityRouter);
|
app.use("/tiger/agents/activity", agentsActivityRouter);
|
||||||
|
// Complete audit trail (executions + tasks + outputs + cron runs, paginated)
|
||||||
|
app.use("/tiger/activity/audit", (await import("./routes/activity-audit.js")).default);
|
||||||
app.use("/tiger/deploy-dashboard", deployRouter);
|
app.use("/tiger/deploy-dashboard", deployRouter);
|
||||||
app.use("/tiger/route-task", routeTaskRouter);
|
app.use("/tiger/route-task", routeTaskRouter);
|
||||||
app.use("/tiger/keys", keysRouter);
|
app.use("/tiger/keys", keysRouter);
|
||||||
|
|
|
||||||
232
bridge/src/routes/activity-audit.ts
Normal file
232
bridge/src/routes/activity-audit.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* activity-audit.ts — GET /tiger/activity/audit : the complete audit trail
|
||||||
|
*
|
||||||
|
* Purpose: ONE chronological, paginated record of everything the system DID,
|
||||||
|
* so nothing slips by unaudited. Merged sources, each a durable store (the
|
||||||
|
* old activity feed only showed recent in-memory file events):
|
||||||
|
*
|
||||||
|
* executions (sqlite) — every spawn / sub-agent run, with outcome
|
||||||
|
* tasks (sqlite) — task lifecycle (created / status changes)
|
||||||
|
* outputs (sqlite) — every artifact an agent wrote
|
||||||
|
* cron runs (volume) — OpenClaw's JSONL run history for every job
|
||||||
|
*
|
||||||
|
* Event shape (normalized):
|
||||||
|
* { id, ts (ISO), type, actor, summary, status?, ref? }
|
||||||
|
* type ∈ spawn | task | output | cron
|
||||||
|
*
|
||||||
|
* Pagination: ?limit=100&before=<ISO ts> walks backwards through history.
|
||||||
|
* Optional ?types=spawn,cron filters at the source.
|
||||||
|
*
|
||||||
|
* Design note: sources are merged at read time rather than double-written
|
||||||
|
* into a new audit table — no write-path changes, no risk of an action
|
||||||
|
* happening without its audit row, history is complete retroactively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { readFileSync, readdirSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import db from "../db.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const DATA_DIR =
|
||||||
|
process.env.OPENCLAW_DATA_DIR ||
|
||||||
|
"/var/lib/docker/volumes/tiger_tiger-config/_data";
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
ts: string; // ISO timestamp
|
||||||
|
type: "spawn" | "task" | "output" | "cron";
|
||||||
|
actor: string;
|
||||||
|
summary: string;
|
||||||
|
status?: string;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SQLite sources ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function executionEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, agent, command, exit_code, started_at, completed_at
|
||||||
|
FROM executions
|
||||||
|
${beforeIso ? "WHERE started_at < ?" : ""}
|
||||||
|
ORDER BY started_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
agent: string | null;
|
||||||
|
command: string | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `exec:${r.id}`,
|
||||||
|
ts: toIso(r.started_at),
|
||||||
|
type: "spawn" as const,
|
||||||
|
actor: r.agent ?? "unknown",
|
||||||
|
summary: (r.command ?? "").replace(/^spawn:\s*/, "").slice(0, 160),
|
||||||
|
status:
|
||||||
|
r.exit_code === null ? "running" : r.exit_code === 0 ? "done" : "error",
|
||||||
|
ref: r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, title, status, assigned_agent, updated_at
|
||||||
|
FROM tasks
|
||||||
|
${beforeIso ? "WHERE updated_at < ?" : ""}
|
||||||
|
ORDER BY updated_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
assigned_agent: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `task:${r.id}:${r.updated_at}`,
|
||||||
|
ts: toIso(r.updated_at),
|
||||||
|
type: "task" as const,
|
||||||
|
actor: r.assigned_agent ?? "tiger",
|
||||||
|
summary: r.title.slice(0, 160),
|
||||||
|
status: r.status,
|
||||||
|
ref: r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, filename, file_path, execution_id, created_at
|
||||||
|
FROM outputs
|
||||||
|
${beforeIso ? "WHERE created_at < ?" : ""}
|
||||||
|
ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
file_path: string;
|
||||||
|
execution_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `output:${r.id}`,
|
||||||
|
ts: toIso(r.created_at),
|
||||||
|
type: "output" as const,
|
||||||
|
actor: "agent",
|
||||||
|
summary: `wrote ${r.filename} (${r.file_path})`.slice(0, 160),
|
||||||
|
ref: r.execution_id ?? r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cron run history (OpenClaw JSONL on the volume) ────────────────────────
|
||||||
|
// Cached by directory listing + sizes; cron runs append-only files.
|
||||||
|
|
||||||
|
let cronCache: { stamp: string; events: AuditEvent[] } | null = null;
|
||||||
|
|
||||||
|
function cronEvents(): AuditEvent[] {
|
||||||
|
const runsDir = join(DATA_DIR, "cron", "runs");
|
||||||
|
if (!existsSync(runsDir)) return [];
|
||||||
|
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(runsDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap cache key: file list + sizes via a stat pass would be ideal; the
|
||||||
|
// run files are small, so name-count + latest mtime via re-read every 30s
|
||||||
|
// would also be fine. Keep it simple: rebuild when the listing changes.
|
||||||
|
const stamp = files.join("|");
|
||||||
|
if (cronCache && cronCache.stamp === stamp) return cronCache.events;
|
||||||
|
|
||||||
|
const events: AuditEvent[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = readFileSync(join(runsDir, file), "utf-8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let run: Record<string, any>;
|
||||||
|
try {
|
||||||
|
run = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ts = run.startedAt ?? run.ts ?? run.runAtMs ?? run.timestamp;
|
||||||
|
if (!ts) continue;
|
||||||
|
const iso = typeof ts === "number" ? new Date(ts).toISOString() : toIso(String(ts));
|
||||||
|
const name = run.jobName ?? run.name ?? file.replace(/\.jsonl$/, "");
|
||||||
|
const status = run.status ?? (run.error ? "error" : "ok");
|
||||||
|
events.push({
|
||||||
|
id: `cron:${file}:${iso}`,
|
||||||
|
ts: iso,
|
||||||
|
type: "cron",
|
||||||
|
actor: "cron",
|
||||||
|
summary: String(name).slice(0, 160),
|
||||||
|
status: String(status),
|
||||||
|
ref: run.jobId ?? file.replace(/\.jsonl$/, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cronCache = { stamp, events };
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** sqlite datetime('now') yields "YYYY-MM-DD HH:MM:SS" (UTC, no zone) — make it ISO. */
|
||||||
|
function toIso(s: string): string {
|
||||||
|
if (!s) return new Date(0).toISOString();
|
||||||
|
if (s.includes("T")) return s;
|
||||||
|
return s.replace(" ", "T") + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/", (req: Request, res: Response) => {
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(parseInt(String(req.query.limit ?? "100"), 10) || 100, 1),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
const before = req.query.before ? String(req.query.before) : null;
|
||||||
|
const typeFilter = req.query.types
|
||||||
|
? new Set(String(req.query.types).split(","))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const wants = (t: AuditEvent["type"]) => !typeFilter || typeFilter.has(t);
|
||||||
|
|
||||||
|
let events: AuditEvent[] = [];
|
||||||
|
if (wants("spawn")) events.push(...executionEvents(before, limit));
|
||||||
|
if (wants("task")) events.push(...taskEvents(before, limit));
|
||||||
|
if (wants("output")) events.push(...outputEvents(before, limit));
|
||||||
|
if (wants("cron")) {
|
||||||
|
let cron = cronEvents();
|
||||||
|
if (before) cron = cron.filter((e) => e.ts < before);
|
||||||
|
events.push(...cron);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => (a.ts < b.ts ? 1 : -1)); // newest first
|
||||||
|
const page = events.slice(0, limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
events: page,
|
||||||
|
hasMore: events.length > page.length,
|
||||||
|
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,112 +1,192 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
/**
|
||||||
import { ScrollText } from "lucide-react"
|
* /activity — the complete audit trail.
|
||||||
|
*
|
||||||
|
* Every durable action in one timeline: sub-agent spawns, task lifecycle,
|
||||||
|
* artifacts written, cron runs, file modifications. Type filters + "Load
|
||||||
|
* older" pagination walk the entire history so nothing escapes audit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { ScrollText, ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
id: string
|
id: string
|
||||||
|
ts: string
|
||||||
type: string
|
type: string
|
||||||
timestamp: string
|
actor: string
|
||||||
description: string
|
summary: string
|
||||||
source: string
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ id: "spawn", label: "Spawns" },
|
||||||
|
{ id: "cron", label: "Cron" },
|
||||||
|
{ id: "task", label: "Tasks" },
|
||||||
|
{ id: "output", label: "Outputs" },
|
||||||
|
{ id: "file", label: "Files" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
spawn: "text-violet-400 border-violet-400/30",
|
||||||
|
cron: "text-sky-400 border-sky-400/30",
|
||||||
|
task: "text-amber-400 border-amber-400/30",
|
||||||
|
output: "text-emerald-400 border-emerald-400/30",
|
||||||
|
file: "text-muted-foreground border-border",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
done: "text-emerald-400",
|
||||||
|
ok: "text-emerald-400",
|
||||||
|
running: "text-sky-400",
|
||||||
|
error: "text-red-400",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||||
|
const [active, setActive] = useState<Set<string>>(new Set(TYPES.map((t) => t.id)))
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchPage = useCallback(
|
||||||
|
async (before?: string) => {
|
||||||
|
const qs = new URLSearchParams({ limit: "100" })
|
||||||
|
if (before) qs.set("before", before)
|
||||||
|
if (active.size < TYPES.length) qs.set("types", Array.from(active).join(","))
|
||||||
|
const r = await fetch(`/api/activity?${qs.toString()}`)
|
||||||
|
return r.json() as Promise<{
|
||||||
|
entries?: ActivityEntry[]
|
||||||
|
hasMore?: boolean
|
||||||
|
oldestTs?: string | null
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
[active],
|
||||||
|
)
|
||||||
|
|
||||||
|
// (Re)load whenever filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/activity?limit=20")
|
setLoading(true)
|
||||||
.then(r => r.json())
|
setError(null)
|
||||||
.then(data => {
|
fetchPage()
|
||||||
if (data?.entries) {
|
.then((data) => {
|
||||||
|
if (data.entries) {
|
||||||
setEntries(data.entries)
|
setEntries(data.entries)
|
||||||
}
|
setHasMore(Boolean(data.hasMore))
|
||||||
setLoading(false)
|
} else setError(data.error || "No data")
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch((e: Error) => setError(e.message))
|
||||||
console.error("Failed to load:", e)
|
.finally(() => setLoading(false))
|
||||||
setError(e.message)
|
}, [fetchPage])
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formatDate = (ts: string) => {
|
const loadOlder = async () => {
|
||||||
if (!ts) return ""
|
if (loadingMore || entries.length === 0) return
|
||||||
return new Date(ts).toLocaleString()
|
setLoadingMore(true)
|
||||||
}
|
try {
|
||||||
|
const data = await fetchPage(entries[entries.length - 1].ts)
|
||||||
const getTypeColor = (type: string) => {
|
if (data.entries && data.entries.length > 0) {
|
||||||
switch (type) {
|
setEntries((prev) => {
|
||||||
case "heartbeat": return "text-green-500"
|
const known = new Set(prev.map((e) => e.id))
|
||||||
case "chat": return "text-blue-500"
|
return [...prev, ...data.entries!.filter((e) => !known.has(e.id))]
|
||||||
case "config": return "text-yellow-500"
|
})
|
||||||
case "memory": return "text-purple-500"
|
setHasMore(Boolean(data.hasMore))
|
||||||
case "system": return "text-orange-500"
|
} else setHasMore(false)
|
||||||
case "cron": return "text-cyan-500"
|
} finally {
|
||||||
default: return "text-muted-foreground"
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSourceLabel = (source: string) => {
|
const toggle = (id: string) =>
|
||||||
switch (source) {
|
setActive((prev) => {
|
||||||
case "main": return "🐅 Tiger"
|
const next = new Set(prev)
|
||||||
case "coder": return "📦 Cody"
|
if (next.has(id)) {
|
||||||
case "researcher": return "🔬 Ethan"
|
if (next.size > 1) next.delete(id) // never filter down to nothing
|
||||||
case "pm": return "📋 Elon"
|
} else next.add(id)
|
||||||
default: return source || "🤖"
|
return next
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
const fmt = (ts: string) =>
|
||||||
return (
|
new Date(ts).toLocaleString([], {
|
||||||
<div className="p-6">
|
day: "2-digit",
|
||||||
<div className="flex items-center gap-2 mb-4">
|
month: "short",
|
||||||
<ScrollText className="h-5 w-5" />
|
hour: "2-digit",
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
minute: "2-digit",
|
||||||
</div>
|
})
|
||||||
<div className="text-muted-foreground">Loading activity log...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<ScrollText className="h-5 w-5" />
|
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
|
||||||
</div>
|
|
||||||
<div className="text-red-500">Failed to load activity log</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-2">{error}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<ScrollText className="h-5 w-5" />
|
<ScrollText className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
<h1 className="text-xl font-semibold">Activity</h1>
|
||||||
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Complete audit trail — spawns, cron runs, tasks, outputs, file changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap gap-2 mb-5">
|
||||||
{entries.map((entry, i) => (
|
{TYPES.map((t) => (
|
||||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
|
<button
|
||||||
<div className="text-lg">{getSourceLabel(entry.source)}</div>
|
key={t.id}
|
||||||
<div className="flex-1 min-w-0">
|
onClick={() => toggle(t.id)}
|
||||||
<div className="text-sm truncate">{entry.description}</div>
|
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
active.has(t.id)
|
||||||
<span className={getTypeColor(entry.type)}>{entry.type}</span>
|
? TYPE_COLORS[t.id] + " bg-muted/30"
|
||||||
<span>•</span>
|
: "text-muted-foreground/40 border-border/40"
|
||||||
<span>{formatDate(entry.timestamp)}</span>
|
}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
{t.label}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="text-sm text-muted-foreground py-8">Loading…</div>}
|
||||||
|
{error && <div className="text-sm text-red-500 py-8">Error: {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground py-8">No events.</div>
|
||||||
|
)}
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-start gap-3 py-2.5 border-b border-border/40 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted-foreground/70 w-28 shrink-0 pt-0.5">
|
||||||
|
{fmt(e.ts)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${TYPE_COLORS[e.type] ?? TYPE_COLORS.file}`}
|
||||||
|
>
|
||||||
|
{e.type}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 break-words">
|
||||||
|
<span className="text-muted-foreground">{e.actor}</span>{" "}
|
||||||
|
{e.summary}
|
||||||
|
{e.status && (
|
||||||
|
<span className={`ml-2 text-[11px] ${STATUS_COLORS[e.status] ?? "text-muted-foreground"}`}>
|
||||||
|
{e.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadOlder}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="self-center mt-4 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 py-1.5 px-3 rounded hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
{loadingMore ? "Loading…" : "Load older"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,82 @@
|
||||||
|
/**
|
||||||
|
* /api/activity — unified audit feed for the Activity page.
|
||||||
|
*
|
||||||
|
* Merges two bridge sources:
|
||||||
|
* /tiger/activity/audit durable history: spawns, tasks, outputs, cron
|
||||||
|
* runs — paginated, complete
|
||||||
|
* /tiger/agents/activity recent file-modification events (in-memory,
|
||||||
|
* recent-only by nature; merged for first page)
|
||||||
|
*
|
||||||
|
* ?limit=100&before=<ISO>&types=spawn,cron,task,output,file
|
||||||
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { bridgeGet } from "@/lib/bridge"
|
import { bridgeGet } from "@/lib/bridge"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
interface AuditEvent {
|
||||||
|
id: string
|
||||||
|
ts: string
|
||||||
|
type: string
|
||||||
|
actor: string
|
||||||
|
summary: string
|
||||||
|
status?: string
|
||||||
|
ref?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10), 500)
|
||||||
|
const before = url.searchParams.get("before") || ""
|
||||||
|
const types = url.searchParams.get("types") || ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url)
|
const qs = new URLSearchParams({ limit: String(limit) })
|
||||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
|
if (before) qs.set("before", before)
|
||||||
|
if (types) qs.set("types", types.split(",").filter((t) => t !== "file").join(","))
|
||||||
|
|
||||||
// Get activity from bridge endpoint that already works
|
const audit = (await bridgeGet(`/tiger/activity/audit?${qs.toString()}`)) as {
|
||||||
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
|
|
||||||
ok: boolean
|
ok: boolean
|
||||||
events: Array<{
|
events?: AuditEvent[]
|
||||||
agentId: string
|
hasMore?: boolean
|
||||||
agentName: string
|
oldestTs?: string | null
|
||||||
agentEmoji: string
|
|
||||||
path: string
|
|
||||||
action: string
|
|
||||||
ts: number
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bridgeData?.ok || !bridgeData.events) {
|
let events: AuditEvent[] = audit?.events ?? []
|
||||||
return NextResponse.json({ entries: [], total: 0 })
|
|
||||||
|
// File events only exist for the recent window — merge them into the
|
||||||
|
// first page (no `before` cursor) when not filtered out.
|
||||||
|
const wantFiles = !types || types.split(",").includes("file")
|
||||||
|
if (!before && wantFiles) {
|
||||||
|
try {
|
||||||
|
const fileData = (await bridgeGet("/tiger/agents/activity")) as {
|
||||||
|
ok: boolean
|
||||||
|
events?: Array<{ agentId: string; agentName: string; path: string; action: string; ts: number }>
|
||||||
|
}
|
||||||
|
if (fileData?.ok && fileData.events) {
|
||||||
|
events = events.concat(
|
||||||
|
fileData.events.map((e) => ({
|
||||||
|
id: `file:${e.agentId}:${e.ts}`,
|
||||||
|
ts: new Date(e.ts).toISOString(),
|
||||||
|
type: "file",
|
||||||
|
actor: e.agentName,
|
||||||
|
summary: `${e.action || "modified"} ${e.path}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch { /* file source down — audit sources still serve */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform bridge format to activity format
|
events.sort((a, b) => (a.ts < b.ts ? 1 : -1))
|
||||||
const entries = bridgeData.events.slice(0, limit).map((e) => ({
|
const page = events.slice(0, limit)
|
||||||
id: `${e.agentId}-${e.ts}`,
|
|
||||||
type: "system",
|
|
||||||
timestamp: new Date(e.ts).toISOString(),
|
|
||||||
description: `${e.agentName} modified ${e.path}`,
|
|
||||||
source: e.agentId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
entries,
|
entries: page,
|
||||||
total: bridgeData.events.length,
|
hasMore: Boolean(audit?.hasMore) || events.length > page.length,
|
||||||
|
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch {
|
||||||
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue