diff --git a/.changeset/session-ui-audit-oscillation.md b/.changeset/session-ui-audit-oscillation.md new file mode 100644 index 000000000..0bcf242ab --- /dev/null +++ b/.changeset/session-ui-audit-oscillation.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao-core": patch +"@aoagents/ao-cli": patch +"@aoagents/ao-web": patch +--- + +Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence. diff --git a/changelog/session-ui-audit-oscillation.md b/changelog/session-ui-audit-oscillation.md deleted file mode 100644 index 973350836..000000000 --- a/changelog/session-ui-audit-oscillation.md +++ /dev/null @@ -1,9 +0,0 @@ -# Session UI, Report Audit, and Lifecycle Stability - -## What Changed - -- Split worker session detail from orchestrator detail so orchestrator pages no longer show worker-only PR and lifecycle detail panels. -- Added a persistent audit trail for `ao acknowledge` and `ao report`, including actor, command, note, acceptance/rejection, and before/after lifecycle state. -- Exposed that audit trail on worker session detail pages in the dashboard. -- Fixed lifecycle fallback so `needs_input` and `stuck` do not bounce back to `working` when the poll cycle only has weak or unchanged evidence. -- Removed the web app's runtime dependency on Google Fonts during `next build` by using local CSS font variables instead of `next/font/google`. diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 3358e8c73..28272bd3c 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -17,6 +17,7 @@ */ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { CanonicalSessionLifecycle, @@ -26,7 +27,7 @@ import type { SessionStatus, } from "./types.js"; import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js"; -import { deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js"; +import { deriveLegacyStatus } from "./lifecycle-state.js"; import { validateStatus } from "./utils/validation.js"; /** @@ -215,7 +216,16 @@ function buildAuditDir(dataDir: string): string { return join(dataDir, ".agent-report-audit"); } +const VALID_AUDIT_SESSION_ID = /^[a-zA-Z0-9_-]+$/; + +function validateAuditSessionId(sessionId: SessionId): void { + if (!VALID_AUDIT_SESSION_ID.test(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } +} + function buildAuditFilePath(dataDir: string, sessionId: SessionId): string { + validateAuditSessionId(sessionId); return join(buildAuditDir(dataDir), `${sessionId}.ndjson`); } @@ -256,7 +266,23 @@ export function readAgentReportAuditTrail( return []; } - return readFileSync(auditFilePath, "utf8") + return parseAgentReportAuditTrail(readFileSync(auditFilePath, "utf8")); +} + +export async function readAgentReportAuditTrailAsync( + dataDir: string, + sessionId: SessionId, +): Promise { + const auditFilePath = buildAuditFilePath(dataDir, sessionId); + if (!existsSync(auditFilePath)) { + return []; + } + + return parseAgentReportAuditTrail(await readFile(auditFilePath, "utf8")); +} + +function parseAgentReportAuditTrail(content: string): AgentReportAuditEntry[] { + return content .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) @@ -299,40 +325,38 @@ export function applyAgentReport( throw new Error(`Session not found: ${sessionId}`); } + validateAuditSessionId(sessionId); const now = (input.now ?? new Date()).toISOString(); const source = input.source ?? "report"; const actor = normalizeActor(input.actor); const trimmedNote = input.note?.trim() || undefined; - const currentLifecycle = parseCanonicalLifecycle(raw, { - sessionId, - status: validateStatus(raw["status"]), - }); - const previousLegacyStatus = validateStatus(raw["status"]); - const before = buildAuditSnapshot(currentLifecycle, previousLegacyStatus); - const validation = validateAgentReportTransition(currentLifecycle, input.state); - if (!validation.ok) { - appendAgentReportAuditEntry(dataDir, sessionId, { - timestamp: now, - actor, - source, - reportState: input.state, - note: trimmedNote, - accepted: false, - rejectionReason: validation.reason ?? "transition rejected", - before, - after: before, - }); - throw new Error(validation.reason ?? "transition rejected"); - } - + let before: AgentReportAuditSnapshot | null = null; let previousState: CanonicalSessionState | null = null; let nextState: CanonicalSessionState | null = null; let legacyStatus: SessionStatus | null = null; + let previousLegacyStatus: SessionStatus | null = null; const nextLifecycle = updateCanonicalLifecycle( dataDir, sessionId, (current) => { + previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"])); + before = buildAuditSnapshot(current, previousLegacyStatus); + const validation = validateAgentReportTransition(current, input.state); + if (!validation.ok) { + appendAgentReportAuditEntry(dataDir, sessionId, { + timestamp: now, + actor, + source, + reportState: input.state, + note: trimmedNote, + accepted: false, + rejectionReason: validation.reason ?? "transition rejected", + before, + after: before, + }); + throw new Error(validation.reason ?? "transition rejected"); + } const mapped = mapAgentReportToLifecycle(input.state); previousState = current.session.state; nextState = mapped.sessionState; @@ -347,7 +371,7 @@ export function applyAgentReport( }, ); - if (!nextLifecycle || !previousState || !nextState || !legacyStatus) { + if (!nextLifecycle || !before || !previousState || !nextState || !legacyStatus) { throw new Error(`Failed to apply agent report for session ${sessionId}`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1c5efa344..a02ea4365 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,7 @@ export { applyAgentReport, readAgentReport, readAgentReportAuditTrail, + readAgentReportAuditTrailAsync, isAgentReportFresh, mapAgentReportToLifecycle, normalizeAgentReportedState, diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts index 4bdbe547f..9349d66f4 100644 --- a/packages/web/src/app/api/sessions/[id]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { type NextRequest } from "next/server"; -import { getSessionsDir, readAgentReportAuditTrail } from "@aoagents/ao-core"; +import { getSessionsDir, readAgentReportAuditTrailAsync } from "@aoagents/ao-core"; import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, @@ -25,7 +25,10 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const project = resolveProject(coreSession, config.projects); if (project) { const sessionsDir = getSessionsDir(config.configPath, project.path); - dashboardSession.agentReportAudit = readAgentReportAuditTrail(sessionsDir, coreSession.id); + dashboardSession.agentReportAudit = await readAgentReportAuditTrailAsync( + sessionsDir, + coreSession.id, + ); } // Enrich metadata (issue labels, agent summaries, issue titles) diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 26dccc503..d675b8ae3 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -246,9 +246,9 @@ function SessionReportAuditPanel({ Agent Report Audit

- {entries.map((entry) => ( + {entries.map((entry, index) => (
diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index a03b56119..10ef7e9ce 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -47,6 +47,7 @@ import { type CanonicalRuntimeState, type CanonicalRuntimeReason, } from "@aoagents/ao-core/types"; +import type { AgentReportedState } from "@aoagents/ao-core"; // Re-export for use in client components export { CI_STATUS, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES }; @@ -106,7 +107,7 @@ export interface DashboardAgentReportAuditEntry { timestamp: string; actor: string; source: "acknowledge" | "report"; - reportState: string; + reportState: AgentReportedState; note?: string; accepted: boolean; rejectionReason?: string;