fix: address PR #1300 requested changes
This commit is contained in:
parent
79c7ef85a6
commit
bcdda4b9ba
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, and adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels.
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import type * as CoreModule from "@aoagents/ao-core";
|
||||
|
||||
const { mockConfigRef, mockSessionManager, mockApplyAgentReport, mockGetSessionsDir } = vi.hoisted(
|
||||
() => ({
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
mockApplyAgentReport: vi.fn(),
|
||||
mockGetSessionsDir: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof CoreModule;
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
getSessionsDir: (...args: unknown[]) => mockGetSessionsDir(...args),
|
||||
applyAgentReport: (...args: unknown[]) => mockApplyAgentReport(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
}));
|
||||
|
||||
import { registerAcknowledge, registerReport } from "../../src/commands/report.js";
|
||||
|
||||
describe("report commands", () => {
|
||||
let program: Command;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerAcknowledge(program);
|
||||
registerReport(program);
|
||||
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env["AO_SESSION_ID"];
|
||||
process.env["USER"] = "codex";
|
||||
|
||||
mockConfigRef.current = {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
path: "/tmp/app",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockSessionManager.get.mockReset();
|
||||
mockApplyAgentReport.mockReset();
|
||||
mockGetSessionsDir.mockReset();
|
||||
mockGetSessionsDir.mockReturnValue("/tmp/sessions");
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
});
|
||||
mockApplyAgentReport.mockReturnValue({
|
||||
previousState: "working",
|
||||
nextState: "started",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses AO_SESSION_ID for acknowledge when no explicit session is provided", async () => {
|
||||
process.env["AO_SESSION_ID"] = "app-1";
|
||||
|
||||
await program.parseAsync(["node", "test", "acknowledge", "--note", "picked up"]);
|
||||
|
||||
expect(mockSessionManager.get).toHaveBeenCalledWith("app-1");
|
||||
expect(mockApplyAgentReport).toHaveBeenCalledWith(
|
||||
"/tmp/sessions",
|
||||
"app-1",
|
||||
expect.objectContaining({
|
||||
state: "started",
|
||||
note: "picked up",
|
||||
source: "acknowledge",
|
||||
actor: "codex",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --session over AO_SESSION_ID", async () => {
|
||||
process.env["AO_SESSION_ID"] = "wrong-session";
|
||||
|
||||
await program.parseAsync(["node", "test", "report", "working", "--session", "app-2"]);
|
||||
|
||||
expect(mockSessionManager.get).toHaveBeenCalledWith("app-2");
|
||||
});
|
||||
|
||||
it("rejects unknown states before touching the session manager", async () => {
|
||||
await expect(program.parseAsync(["node", "test", "report", "bogus-state"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(mockSessionManager.get).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown state"));
|
||||
});
|
||||
|
||||
it("rejects invalid PR numbers", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "report", "pr-created", "--pr-number", "abc"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid PR number"));
|
||||
});
|
||||
|
||||
it("rejects PR metadata flags for non-PR workflow states", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "report", "working", "--pr-url", "https://example.com"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("PR metadata flags are only valid"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces session-not-found errors", async () => {
|
||||
mockSessionManager.get.mockResolvedValue(null);
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
|
||||
.rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Session not found"));
|
||||
});
|
||||
|
||||
it("surfaces project-not-found errors", async () => {
|
||||
mockConfigRef.current = {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
projects: {},
|
||||
};
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
|
||||
.rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Project not found for session"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces rejected reports from applyAgentReport", async () => {
|
||||
mockApplyAgentReport.mockImplementation(() => {
|
||||
throw new Error("PR number 7 does not match PR URL");
|
||||
});
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"report",
|
||||
"pr-created",
|
||||
"--session",
|
||||
"app-1",
|
||||
"--pr-number",
|
||||
"7",
|
||||
"--pr-url",
|
||||
"https://github.com/acme/app/pull/9",
|
||||
]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Report rejected: PR number 7 does not match PR URL"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTIVITY_STRONG_WINDOW_MS,
|
||||
ACTIVITY_WEAK_WINDOW_MS,
|
||||
classifyActivitySignal,
|
||||
createActivitySignal,
|
||||
hasPositiveIdleEvidence,
|
||||
isWeakActivityEvidence,
|
||||
summarizeActivityFreshness,
|
||||
supportsRecentLiveness,
|
||||
} from "../activity-signal.js";
|
||||
|
||||
describe("summarizeActivityFreshness", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("returns none without a timestamp", () => {
|
||||
expect(summarizeActivityFreshness(undefined, now)).toBe("none");
|
||||
});
|
||||
|
||||
it("classifies exact strong and weak boundaries correctly", () => {
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_STRONG_WINDOW_MS), now),
|
||||
).toBe("strong");
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS), now),
|
||||
).toBe("weak");
|
||||
});
|
||||
|
||||
it("treats future timestamps as strong instead of going negative", () => {
|
||||
expect(summarizeActivityFreshness(new Date("2025-01-01T12:01:00.000Z"), now)).toBe("strong");
|
||||
});
|
||||
|
||||
it("returns stale when the timestamp is older than the weak window", () => {
|
||||
expect(
|
||||
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1), now),
|
||||
).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyActivitySignal", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("keeps fresh active activity as valid", () => {
|
||||
expect(
|
||||
classifyActivitySignal(
|
||||
{
|
||||
state: "active",
|
||||
timestamp: new Date("2025-01-01T11:59:30.000Z"),
|
||||
},
|
||||
"native",
|
||||
now,
|
||||
),
|
||||
).toEqual({
|
||||
state: "valid",
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-01-01T11:59:30.000Z"),
|
||||
source: "native",
|
||||
detail: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks idle-without-timestamp as stale missing_timestamp evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "idle" }, "native", now)).toEqual({
|
||||
state: "stale",
|
||||
activity: "idle",
|
||||
timestamp: undefined,
|
||||
source: "native",
|
||||
detail: "missing_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks blocked-without-timestamp as stale missing_timestamp evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "blocked" }, "terminal", now)).toEqual({
|
||||
state: "stale",
|
||||
activity: "blocked",
|
||||
timestamp: undefined,
|
||||
source: "terminal",
|
||||
detail: "missing_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps active-without-timestamp valid because it is positive liveness evidence", () => {
|
||||
expect(classifyActivitySignal({ state: "active" }, "native", now).state).toBe("valid");
|
||||
});
|
||||
|
||||
it("marks stale active evidence as stale_timestamp", () => {
|
||||
expect(
|
||||
classifyActivitySignal(
|
||||
{
|
||||
state: "active",
|
||||
timestamp: new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1),
|
||||
},
|
||||
"native",
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "stale",
|
||||
activity: "active",
|
||||
detail: "stale_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exited activity valid even without timestamp", () => {
|
||||
expect(classifyActivitySignal({ state: "exited" }, "runtime", now)).toEqual({
|
||||
state: "valid",
|
||||
activity: "exited",
|
||||
timestamp: undefined,
|
||||
source: "runtime",
|
||||
detail: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity signal helpers", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
|
||||
it("detects positive idle evidence only for valid idle states with timestamps", () => {
|
||||
expect(
|
||||
hasPositiveIdleEvidence(
|
||||
createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2025-01-01T11:59:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasPositiveIdleEvidence(createActivitySignal("valid", { activity: "idle", source: "native" })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasPositiveIdleEvidence(
|
||||
createActivitySignal("stale", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2025-01-01T11:59:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("supports recent liveness only for valid active/ready timestamps within the weak window", () => {
|
||||
expect(
|
||||
supportsRecentLiveness(
|
||||
createActivitySignal("valid", {
|
||||
activity: "ready",
|
||||
timestamp: new Date("2025-01-01T11:56:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
now,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsRecentLiveness(
|
||||
createActivitySignal("valid", {
|
||||
activity: "active",
|
||||
timestamp: new Date("2025-01-01T11:54:59.999Z"),
|
||||
source: "native",
|
||||
}),
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats all non-valid signals as weak evidence", () => {
|
||||
expect(isWeakActivityEvidence(createActivitySignal("valid", { activity: "active" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isWeakActivityEvidence(createActivitySignal("stale", { activity: "active" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWeakActivityEvidence(createActivitySignal("unavailable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||
AGENT_REPORTED_STATES,
|
||||
AGENT_REPORT_FRESHNESS_MS,
|
||||
AGENT_REPORT_METADATA_KEYS,
|
||||
|
|
@ -265,6 +266,24 @@ describe("applyAgentReport", () => {
|
|||
expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42");
|
||||
});
|
||||
|
||||
it("does not invent an open PR without a URL or number", () => {
|
||||
const now = new Date("2025-01-02T09:35:00.000Z");
|
||||
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("idle");
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.pr.state).toBe("none");
|
||||
expect(payload.pr.reason).toBe("not_created");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBeUndefined();
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps draft PR creation in working and marks the report as draft", () => {
|
||||
const now = new Date("2025-01-02T10:00:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
|
|
@ -502,12 +521,24 @@ describe("readAgentReport + isAgentReportFresh", () => {
|
|||
expect(isAgentReportFresh(stale, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future timestamps (clock skew must not appear forever-fresh)", () => {
|
||||
it("accepts small future skew inside the tolerance window", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
const slightlyFuture = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
|
||||
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS - 1,
|
||||
).toISOString(),
|
||||
})!;
|
||||
expect(isAgentReportFresh(slightlyFuture, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future timestamps outside the skew tolerance", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future
|
||||
const future = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: futureAt,
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
|
||||
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS + 1,
|
||||
).toISOString(),
|
||||
})!;
|
||||
expect(isAgentReportFresh(future, now)).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -311,6 +311,99 @@ describe("recovery validator", () => {
|
|||
expect(assessment.reason).toContain("Signal disagreement");
|
||||
});
|
||||
|
||||
it("uses agent activity as liveness fallback when process probing says not running", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(mockAgent.getActivityState).toHaveBeenCalled();
|
||||
expect(assessment.agentActivity).toBe("active");
|
||||
expect(assessment.agentProcessRunning).toBe(true);
|
||||
expect(assessment.classification).toBe("live");
|
||||
});
|
||||
|
||||
it("keeps terminal metadata unrecoverable even when probes are uncertain", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ describe("checkBlockedAgent", () => {
|
|||
expect(result?.message).toContain("Need API key");
|
||||
});
|
||||
|
||||
it("ignores stale reports", () => {
|
||||
it("keeps needs_input visible even after the freshness window", () => {
|
||||
const now = new Date();
|
||||
const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const session = createMockSession();
|
||||
|
|
@ -209,7 +209,8 @@ describe("checkBlockedAgent", () => {
|
|||
};
|
||||
|
||||
const result = checkBlockedAgent(session, report, now, config);
|
||||
expect(result).toBeNull();
|
||||
expect(result?.trigger).toBe("agent_needs_input");
|
||||
expect(result?.timeSinceReportMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export const AGENT_REPORT_METADATA_KEYS = {
|
|||
|
||||
/** Freshness window — agent reports older than this are ignored. */
|
||||
export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
|
||||
export const AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* CLI surface accepts these hyphen/underscore aliases for convenience.
|
||||
|
|
@ -432,9 +433,16 @@ export function applyAgentReport(
|
|||
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
||||
const effectivePrNumber =
|
||||
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
||||
current.pr.state = "open";
|
||||
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
const canAdvancePrState =
|
||||
effectivePrUrl !== undefined ||
|
||||
effectivePrNumber !== undefined ||
|
||||
current.pr.state !== "none";
|
||||
if (canAdvancePrState) {
|
||||
current.pr.state = "open";
|
||||
current.pr.reason =
|
||||
input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
}
|
||||
if (effectivePrUrl) {
|
||||
current.pr.url = effectivePrUrl;
|
||||
}
|
||||
|
|
@ -550,10 +558,11 @@ export function isAgentReportFresh(
|
|||
report: AgentReport,
|
||||
now: Date = new Date(),
|
||||
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
|
||||
clockSkewToleranceMs: number = AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||
): boolean {
|
||||
const reportedAt = Date.parse(report.timestamp);
|
||||
if (Number.isNaN(reportedAt)) return false;
|
||||
const currentTime = now.getTime();
|
||||
if (reportedAt > currentTime) return false;
|
||||
if (reportedAt > currentTime + clockSkewToleranceMs) return false;
|
||||
return currentTime - reportedAt <= windowMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import { updateMetadata } from "./metadata.js";
|
||||
import { getSessionsDir } from "./paths.js";
|
||||
import { applyDecisionToLifecycle as commitLifecycleDecisionInPlace } from "./lifecycle-transition.js";
|
||||
import {
|
||||
classifyActivitySignal,
|
||||
createActivitySignal,
|
||||
|
|
@ -235,31 +236,6 @@ interface ProbeResult {
|
|||
state: "alive" | "dead" | "unknown";
|
||||
failed: boolean;
|
||||
}
|
||||
function applyLifecycleDecision(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
decision: LifecycleDecision,
|
||||
nowIso: string,
|
||||
): void {
|
||||
if (decision.prState && decision.prReason) {
|
||||
lifecycle.pr.state = decision.prState;
|
||||
lifecycle.pr.reason = decision.prReason;
|
||||
}
|
||||
|
||||
if (decision.sessionState && decision.sessionReason) {
|
||||
lifecycle.session.state = decision.sessionState;
|
||||
lifecycle.session.reason = decision.sessionReason;
|
||||
lifecycle.session.lastTransitionAt = nowIso;
|
||||
if (decision.sessionState === "working" && lifecycle.session.startedAt === null) {
|
||||
lifecycle.session.startedAt = nowIso;
|
||||
}
|
||||
if (decision.sessionState === "done") {
|
||||
lifecycle.session.completedAt = nowIso;
|
||||
}
|
||||
if (decision.sessionState === "terminated") {
|
||||
lifecycle.session.terminatedAt = nowIso;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitEvidenceSignals(evidence: string): string[] {
|
||||
return evidence
|
||||
|
|
@ -536,7 +512,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
detectingAttempts: currentDetectingAttempts,
|
||||
},
|
||||
): DeterminedStatus => {
|
||||
applyLifecycleDecision(lifecycle, decision, nowIso);
|
||||
commitLifecycleDecisionInPlace(lifecycle, decision, nowIso);
|
||||
session.lifecycle = lifecycle;
|
||||
session.status = decision.status;
|
||||
session.activitySignal = activitySignal;
|
||||
|
|
@ -1848,7 +1824,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
});
|
||||
|
||||
// Execute reaction if configured
|
||||
if (reactionConfig && reactionConfig.auto !== false) {
|
||||
if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) {
|
||||
await executeReaction(session.id, session.projectId, reactionKey, reactionConfig);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type RuntimeHandle,
|
||||
type SessionStatus,
|
||||
type ActivityState,
|
||||
type Session,
|
||||
} from "../types.js";
|
||||
import { safeJsonParse, validateStatus } from "../utils/validation.js";
|
||||
import type { ScannedSession } from "./scanner.js";
|
||||
|
|
@ -20,6 +21,18 @@ import {
|
|||
type RecoveryConfig,
|
||||
} from "./types.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
|
||||
function indicatesLiveAgentActivity(activity: ActivityState | null): boolean {
|
||||
return (
|
||||
activity === "active" ||
|
||||
activity === "ready" ||
|
||||
activity === "idle" ||
|
||||
activity === "waiting_input" ||
|
||||
activity === "blocked"
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateSession(
|
||||
scanned: ScannedSession,
|
||||
|
|
@ -86,7 +99,7 @@ export async function validateSession(
|
|||
|
||||
let agentProcessRunning = false;
|
||||
let processProbeSucceeded = false;
|
||||
const agentActivity: ActivityState | null = null;
|
||||
let agentActivity: ActivityState | null = null;
|
||||
if (agent && runtimeHandle) {
|
||||
try {
|
||||
agentProcessRunning = await agent.isProcessRunning(runtimeHandle);
|
||||
|
|
@ -95,6 +108,37 @@ export async function validateSession(
|
|||
agentProcessRunning = false;
|
||||
processProbeSucceeded = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
const probeSession: Session = {
|
||||
id: sessionId,
|
||||
projectId,
|
||||
status: metadataStatus,
|
||||
activity: null,
|
||||
activitySignal: createActivitySignal("unavailable"),
|
||||
lifecycle,
|
||||
branch: rawMetadata["branch"] ?? null,
|
||||
issueId: rawMetadata["issue"] ?? null,
|
||||
pr: null,
|
||||
workspacePath,
|
||||
runtimeHandle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()),
|
||||
lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()),
|
||||
metadata: rawMetadata,
|
||||
};
|
||||
const detection = await agent.getActivityState(probeSession, config.readyThresholdMs);
|
||||
agentActivity = detection?.state ?? null;
|
||||
if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) {
|
||||
agentProcessRunning = true;
|
||||
}
|
||||
if (agentActivity === "exited") {
|
||||
agentProcessRunning = false;
|
||||
}
|
||||
} catch {
|
||||
agentActivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
const metadataValid = Object.keys(rawMetadata).length > 0;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import type { Session, SessionStatus } from "./types.js";
|
||||
import { readAgentReport, isAgentReportFresh, type AgentReport } from "./agent-report.js";
|
||||
import { readAgentReport, type AgentReport } from "./agent-report.js";
|
||||
|
||||
/**
|
||||
* Report watcher trigger types.
|
||||
|
|
@ -174,15 +174,15 @@ export function checkBlockedAgent(
|
|||
// If no report, nothing to check
|
||||
if (!report) return null;
|
||||
|
||||
// Only check fresh reports (don't re-trigger on old blocked states)
|
||||
if (!isAgentReportFresh(report, now)) return null;
|
||||
|
||||
if (report.state === "needs_input") {
|
||||
const reportTime = Date.parse(report.timestamp);
|
||||
const timeSinceReportMs = Number.isNaN(reportTime) ? undefined : now.getTime() - reportTime;
|
||||
return {
|
||||
trigger: "agent_needs_input",
|
||||
message: `Agent needs input: ${report.note ?? "waiting for user decision"}`,
|
||||
checkedAt: now.toISOString(),
|
||||
report,
|
||||
timeSinceReportMs,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Geist, JetBrains_Mono } from "next/font/google";
|
||||
import { getProjectName } from "@/lib/project-name";
|
||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||
import { Providers } from "@/app/providers";
|
||||
import "./globals.css";
|
||||
|
||||
const rootFontVariables = {
|
||||
"--font-geist-sans": '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
"--font-jetbrains-mono":
|
||||
'"SF Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", monospace',
|
||||
} as CSSProperties;
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
|
|
@ -41,8 +49,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className="dark"
|
||||
style={rootFontVariables}
|
||||
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
|
||||
|
|
|
|||
|
|
@ -4,23 +4,13 @@ import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } fro
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||
import {
|
||||
type DashboardAgentReportAuditEntry,
|
||||
type DashboardSession,
|
||||
type DashboardPR,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
getActivitySignalLabel,
|
||||
isPRMergeReady,
|
||||
isPRRateLimited,
|
||||
isPRUnenriched,
|
||||
getSessionTruthLabel,
|
||||
getSessionTruthReasonLabel,
|
||||
getPRTruthLabel,
|
||||
getPRTruthReasonLabel,
|
||||
getRuntimeTruthLabel,
|
||||
getRuntimeTruthReasonLabel,
|
||||
getLifecycleGuidance,
|
||||
getLifecycleEvidence,
|
||||
getActivitySignalLabel,
|
||||
getActivitySignalReasonLabel,
|
||||
isDashboardRuntimeEnded,
|
||||
isDashboardSessionRestorable,
|
||||
} from "@/lib/types";
|
||||
|
|
@ -32,6 +22,8 @@ import type { ProjectInfo } from "@/lib/project-name";
|
|||
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { ProjectSidebar } from "./ProjectSidebar";
|
||||
import { SessionTruthPanel } from "./SessionTruthPanel";
|
||||
import { SessionReportAuditPanel } from "./SessionReportAuditPanel";
|
||||
|
||||
const DirectTerminal = dynamic(
|
||||
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
|
||||
|
|
@ -64,18 +56,6 @@ interface SessionDetailProps {
|
|||
onRetrySidebar?: () => void;
|
||||
}
|
||||
|
||||
function truthBadgeTone(label: string): string {
|
||||
const normalized = label.toLowerCase();
|
||||
if (normalized.includes("detect")) return "var(--color-status-attention)";
|
||||
if (normalized.includes("terminated") || normalized.includes("missing")) {
|
||||
return "var(--color-status-error)";
|
||||
}
|
||||
if (normalized.includes("merged") || normalized.includes("alive")) {
|
||||
return "var(--color-status-ready)";
|
||||
}
|
||||
return "var(--color-accent)";
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimeCompact(isoDate: string | null): string {
|
||||
|
|
@ -152,209 +132,6 @@ function activityStateClass(activityLabel: string): string {
|
|||
return "session-detail-status-pill--neutral";
|
||||
}
|
||||
|
||||
function SessionTruthPanel({ session }: { session: DashboardSession }) {
|
||||
if (!session.lifecycle) return null;
|
||||
|
||||
const facts = [
|
||||
{
|
||||
heading: "Activity",
|
||||
label: getActivitySignalLabel(session),
|
||||
reason: getActivitySignalReasonLabel(session),
|
||||
},
|
||||
{
|
||||
heading: "Session",
|
||||
label: getSessionTruthLabel(session),
|
||||
reason: getSessionTruthReasonLabel(session),
|
||||
},
|
||||
{
|
||||
heading: "PR",
|
||||
label: getPRTruthLabel(session),
|
||||
reason: getPRTruthReasonLabel(session),
|
||||
},
|
||||
{
|
||||
heading: "Runtime",
|
||||
label: getRuntimeTruthLabel(session),
|
||||
reason: getRuntimeTruthReasonLabel(session),
|
||||
},
|
||||
];
|
||||
const guidance = getLifecycleGuidance(session);
|
||||
const evidence = getLifecycleEvidence(session);
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Lifecycle Truth
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
|
||||
{session.lifecycle.summary}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{facts.map((fact) => (
|
||||
<div
|
||||
key={fact.heading}
|
||||
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
{fact.heading}
|
||||
</div>
|
||||
<div
|
||||
className="mt-0.5 text-[12px] font-medium"
|
||||
style={{ color: truthBadgeTone(fact.label) }}
|
||||
>
|
||||
{fact.label}
|
||||
</div>
|
||||
{fact.reason ? (
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{fact.reason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{guidance ? (
|
||||
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{evidence ? (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
Evidence: {evidence}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAuditTimestamp(isoDate: string): string {
|
||||
const parsed = Date.parse(isoDate);
|
||||
if (Number.isNaN(parsed)) return isoDate;
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
|
||||
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
|
||||
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
|
||||
}
|
||||
|
||||
function SessionReportAuditPanel({
|
||||
sessionId,
|
||||
entries,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entries: DashboardAgentReportAuditEntry[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="session-report-audit-panel"
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
<span className="block">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Agent Reports
|
||||
</span>
|
||||
<span className="mt-1 block text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div id="session-report-audit-panel" className="mt-3 space-y-3">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
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">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
background: entry.accepted
|
||||
? "color-mix(in srgb, var(--color-status-ready) 14%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-status-error) 14%, transparent)",
|
||||
color: entry.accepted
|
||||
? "var(--color-status-ready)"
|
||||
: "var(--color-status-error)",
|
||||
}}
|
||||
>
|
||||
{entry.accepted ? "Accepted" : "Rejected"}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{formatAuditTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Session
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{sessionId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Actor
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Source
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{getAuditCommandLabel(entry)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entry.before.legacyStatus} / {entry.before.sessionState}
|
||||
{" -> "}
|
||||
{entry.after.legacyStatus} / {entry.after.sessionState}
|
||||
</div>
|
||||
{entry.note ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
|
||||
Note: {entry.note}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.rejectionReason ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
|
||||
Rejection: {entry.rejectionReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionTopStrip({
|
||||
headline,
|
||||
crumbId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DashboardAgentReportAuditEntry } from "@/lib/types";
|
||||
|
||||
const RECENT_AUDIT_LIMIT = 10;
|
||||
|
||||
function formatAuditTimestamp(isoDate: string): string {
|
||||
const parsed = Date.parse(isoDate);
|
||||
if (Number.isNaN(parsed)) return isoDate;
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
|
||||
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
|
||||
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
|
||||
}
|
||||
|
||||
export function SessionReportAuditPanel({
|
||||
sessionId,
|
||||
entries,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entries: DashboardAgentReportAuditEntry[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showOlderEntries, setShowOlderEntries] = useState(false);
|
||||
const panelId = `${sessionId}-report-audit-panel`;
|
||||
const hasOlderEntries = entries.length > RECENT_AUDIT_LIMIT;
|
||||
const visibleEntries =
|
||||
showOlderEntries || !hasOlderEntries ? entries : entries.slice(0, RECENT_AUDIT_LIMIT);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
<span className="block">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Agent Reports
|
||||
</span>
|
||||
<span className="mt-1 block text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id={panelId} hidden={!isExpanded} className="mt-3 space-y-3">
|
||||
{visibleEntries.map((entry, index) => (
|
||||
<div
|
||||
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">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
entry.accepted
|
||||
? "border-[var(--color-status-ready)]/25 bg-[var(--color-tint-green)] text-[var(--color-status-ready)]"
|
||||
: "border-[var(--color-status-error)]/25 bg-[var(--color-tint-red)] text-[var(--color-status-error)]",
|
||||
)}
|
||||
>
|
||||
{entry.accepted ? "Accepted" : "Rejected"}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{formatAuditTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Session
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{sessionId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Actor
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
|
||||
</div>
|
||||
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
Source
|
||||
</div>
|
||||
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
||||
{getAuditCommandLabel(entry)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
|
||||
{entry.before.legacyStatus} / {entry.before.sessionState}
|
||||
{" -> "}
|
||||
{entry.after.legacyStatus} / {entry.after.sessionState}
|
||||
</div>
|
||||
{entry.note ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
|
||||
Note: {entry.note}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.rejectionReason ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
|
||||
Rejection: {entry.rejectionReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{hasOlderEntries && !showOlderEntries ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] font-medium text-[var(--color-accent)] transition-colors hover:text-[var(--color-accent-hover)]"
|
||||
onClick={() => setShowOlderEntries(true)}
|
||||
>
|
||||
Show older reports ({entries.length - RECENT_AUDIT_LIMIT})
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
getActivitySignalLabel,
|
||||
getActivitySignalReasonLabel,
|
||||
getLifecycleEvidence,
|
||||
getLifecycleGuidance,
|
||||
getPRTruthLabel,
|
||||
getPRTruthReasonLabel,
|
||||
getRuntimeTruthLabel,
|
||||
getRuntimeTruthReasonLabel,
|
||||
getSessionTruthLabel,
|
||||
getSessionTruthReasonLabel,
|
||||
type DashboardSession,
|
||||
} from "@/lib/types";
|
||||
|
||||
function getActivityFactToneClass(session: DashboardSession): string {
|
||||
if (!session.activitySignal || session.activitySignal.state !== "valid") {
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
|
||||
switch (session.activitySignal.activity) {
|
||||
case "active":
|
||||
return "text-[var(--color-status-working)]";
|
||||
case "ready":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "idle":
|
||||
return "text-[var(--color-status-idle)]";
|
||||
case "waiting_input":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "blocked":
|
||||
case "exited":
|
||||
return "text-[var(--color-status-error)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.session.state) {
|
||||
case "detecting":
|
||||
case "needs_input":
|
||||
case "stuck":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "terminated":
|
||||
return "text-[var(--color-status-error)]";
|
||||
case "done":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "working":
|
||||
return "text-[var(--color-status-working)]";
|
||||
case "idle":
|
||||
return "text-[var(--color-status-pending)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getPrFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.pr.state) {
|
||||
case "merged":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "closed":
|
||||
return "text-[var(--color-status-error)]";
|
||||
case "open":
|
||||
return "text-[var(--color-accent)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeFactToneClass(session: DashboardSession): string {
|
||||
switch (session.lifecycle?.runtime.state) {
|
||||
case "alive":
|
||||
return "text-[var(--color-status-ready)]";
|
||||
case "probe_failed":
|
||||
return "text-[var(--color-status-attention)]";
|
||||
case "missing":
|
||||
case "exited":
|
||||
return "text-[var(--color-status-error)]";
|
||||
default:
|
||||
return "text-[var(--color-text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionTruthPanel({ session }: { session: DashboardSession }) {
|
||||
if (!session.lifecycle) return null;
|
||||
|
||||
const facts = [
|
||||
{
|
||||
heading: "Activity",
|
||||
label: getActivitySignalLabel(session),
|
||||
reason: getActivitySignalReasonLabel(session),
|
||||
toneClassName: getActivityFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "Session",
|
||||
label: getSessionTruthLabel(session),
|
||||
reason: getSessionTruthReasonLabel(session),
|
||||
toneClassName: getSessionFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "PR",
|
||||
label: getPRTruthLabel(session),
|
||||
reason: getPRTruthReasonLabel(session),
|
||||
toneClassName: getPrFactToneClass(session),
|
||||
},
|
||||
{
|
||||
heading: "Runtime",
|
||||
label: getRuntimeTruthLabel(session),
|
||||
reason: getRuntimeTruthReasonLabel(session),
|
||||
toneClassName: getRuntimeFactToneClass(session),
|
||||
},
|
||||
];
|
||||
const guidance = getLifecycleGuidance(session);
|
||||
const evidence = getLifecycleEvidence(session);
|
||||
|
||||
return (
|
||||
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
||||
Lifecycle Truth
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
|
||||
{session.lifecycle.summary}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{facts.map((fact) => (
|
||||
<div
|
||||
key={fact.heading}
|
||||
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
{fact.heading}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[12px] font-medium ${fact.toneClassName}`}>
|
||||
{fact.label}
|
||||
</div>
|
||||
{fact.reason ? (
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{fact.reason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{guidance ? (
|
||||
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{evidence ? (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
Evidence: {evidence}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -149,8 +149,9 @@ describe("SessionDetail desktop layout", () => {
|
|||
expect(screen.getByText("The empty state text needs to be shorter.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
"false",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Agent Reports/i }));
|
||||
expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("ao report working")).toBeInTheDocument();
|
||||
expect(screen.getByText("codex")).toBeInTheDocument();
|
||||
|
|
@ -189,15 +190,15 @@ describe("SessionDetail desktop layout", () => {
|
|||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Agent Reports/i });
|
||||
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText("ao acknowledge")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("sends unresolved comments back to the agent and shows sent state", async () => {
|
||||
|
|
|
|||
|
|
@ -611,6 +611,47 @@ describe("getAttentionLevel", () => {
|
|||
});
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
|
||||
it("should keep idle sessions with open PRs and in-flight CI in working", () => {
|
||||
const session = createSession({
|
||||
status: "working",
|
||||
lifecycle: {
|
||||
sessionState: "idle",
|
||||
sessionReason: "task_in_progress",
|
||||
prState: "open",
|
||||
prReason: "in_progress",
|
||||
runtimeState: "alive",
|
||||
runtimeReason: "process_running",
|
||||
session: {
|
||||
state: "idle",
|
||||
reason: "task_in_progress",
|
||||
label: "idle",
|
||||
reasonLabel: "task in progress",
|
||||
},
|
||||
pr: {
|
||||
state: "open",
|
||||
reason: "in_progress",
|
||||
label: "open",
|
||||
reasonLabel: "in progress",
|
||||
},
|
||||
runtime: {
|
||||
state: "alive",
|
||||
reason: "process_running",
|
||||
label: "alive",
|
||||
reasonLabel: "process running",
|
||||
},
|
||||
legacyStatus: "working",
|
||||
evidence: null,
|
||||
detectingAttempts: 0,
|
||||
detectingEscalatedAt: null,
|
||||
summary: "Session idle while CI is still running",
|
||||
guidance: null,
|
||||
},
|
||||
pr: null,
|
||||
});
|
||||
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("working state", () => {
|
||||
|
|
|
|||
|
|
@ -535,8 +535,8 @@ function getDetailedAttentionLevel(session: DashboardSession): AttentionLevel {
|
|||
|
||||
// ── Pending: waiting on external (reviewer, CI) ───────────────────
|
||||
if (
|
||||
session.lifecycle?.sessionState === "idle" ||
|
||||
session.lifecycle?.prReason === "review_pending" ||
|
||||
session.lifecycle?.prReason === "closed_unmerged" ||
|
||||
session.status === "review_pending"
|
||||
) {
|
||||
return "pending";
|
||||
|
|
|
|||
Loading…
Reference in New Issue