fix: address PR review feedback for session audit trail

This commit is contained in:
harshitsinghbhandari 2026-04-17 21:06:08 +05:30
parent 28ce462ef0
commit 1cbf6579c2
7 changed files with 66 additions and 39 deletions

View File

@ -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.

View File

@ -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`.

View File

@ -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<AgentReportAuditEntry[]> {
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}`);
}

View File

@ -49,6 +49,7 @@ export {
applyAgentReport,
readAgentReport,
readAgentReportAuditTrail,
readAgentReportAuditTrailAsync,
isAgentReportFresh,
mapAgentReportToLifecycle,
normalizeAgentReportedState,

View File

@ -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)

View File

@ -246,9 +246,9 @@ function SessionReportAuditPanel({
Agent Report Audit
</p>
<div className="mt-3 space-y-3">
{entries.map((entry) => (
{entries.map((entry, index) => (
<div
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}`}
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}-${entry.source}-${String(entry.accepted)}-${index}`}
className="rounded-[16px] border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-3"
>
<div className="flex flex-wrap items-center gap-2">

View File

@ -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;