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 { tmpdir } from "node:os";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||||
AGENT_REPORTED_STATES,
|
AGENT_REPORTED_STATES,
|
||||||
AGENT_REPORT_FRESHNESS_MS,
|
AGENT_REPORT_FRESHNESS_MS,
|
||||||
AGENT_REPORT_METADATA_KEYS,
|
AGENT_REPORT_METADATA_KEYS,
|
||||||
|
|
@ -265,6 +266,24 @@ describe("applyAgentReport", () => {
|
||||||
expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42");
|
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", () => {
|
it("keeps draft PR creation in working and marks the report as draft", () => {
|
||||||
const now = new Date("2025-01-02T10:00:00.000Z");
|
const now = new Date("2025-01-02T10:00:00.000Z");
|
||||||
const result = applyAgentReport(dataDir, sessionId, {
|
const result = applyAgentReport(dataDir, sessionId, {
|
||||||
|
|
@ -502,12 +521,24 @@ describe("readAgentReport + isAgentReportFresh", () => {
|
||||||
expect(isAgentReportFresh(stale, now)).toBe(false);
|
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 now = new Date("2025-01-01T12:00:00.000Z");
|
||||||
const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future
|
|
||||||
const future = readAgentReport({
|
const future = readAgentReport({
|
||||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
|
[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);
|
expect(isAgentReportFresh(future, now)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,99 @@ describe("recovery validator", () => {
|
||||||
expect(assessment.reason).toContain("Signal disagreement");
|
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 () => {
|
it("keeps terminal metadata unrecoverable even when probes are uncertain", async () => {
|
||||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||||
mkdirSync(rootDir, { recursive: true });
|
mkdirSync(rootDir, { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ describe("checkBlockedAgent", () => {
|
||||||
expect(result?.message).toContain("Need API key");
|
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 now = new Date();
|
||||||
const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||||
const session = createMockSession();
|
const session = createMockSession();
|
||||||
|
|
@ -209,7 +209,8 @@ describe("checkBlockedAgent", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkBlockedAgent(session, report, now, config);
|
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. */
|
/** Freshness window — agent reports older than this are ignored. */
|
||||||
export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
|
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.
|
* CLI surface accepts these hyphen/underscore aliases for convenience.
|
||||||
|
|
@ -432,9 +433,16 @@ export function applyAgentReport(
|
||||||
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
||||||
const effectivePrNumber =
|
const effectivePrNumber =
|
||||||
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
||||||
current.pr.state = "open";
|
const canAdvancePrState =
|
||||||
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
effectivePrUrl !== undefined ||
|
||||||
current.pr.lastObservedAt = now;
|
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) {
|
if (effectivePrUrl) {
|
||||||
current.pr.url = effectivePrUrl;
|
current.pr.url = effectivePrUrl;
|
||||||
}
|
}
|
||||||
|
|
@ -550,10 +558,11 @@ export function isAgentReportFresh(
|
||||||
report: AgentReport,
|
report: AgentReport,
|
||||||
now: Date = new Date(),
|
now: Date = new Date(),
|
||||||
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
|
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
|
||||||
|
clockSkewToleranceMs: number = AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
|
||||||
): boolean {
|
): boolean {
|
||||||
const reportedAt = Date.parse(report.timestamp);
|
const reportedAt = Date.parse(report.timestamp);
|
||||||
if (Number.isNaN(reportedAt)) return false;
|
if (Number.isNaN(reportedAt)) return false;
|
||||||
const currentTime = now.getTime();
|
const currentTime = now.getTime();
|
||||||
if (reportedAt > currentTime) return false;
|
if (reportedAt > currentTime + clockSkewToleranceMs) return false;
|
||||||
return currentTime - reportedAt <= windowMs;
|
return currentTime - reportedAt <= windowMs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||||
import { updateMetadata } from "./metadata.js";
|
import { updateMetadata } from "./metadata.js";
|
||||||
import { getSessionsDir } from "./paths.js";
|
import { getSessionsDir } from "./paths.js";
|
||||||
|
import { applyDecisionToLifecycle as commitLifecycleDecisionInPlace } from "./lifecycle-transition.js";
|
||||||
import {
|
import {
|
||||||
classifyActivitySignal,
|
classifyActivitySignal,
|
||||||
createActivitySignal,
|
createActivitySignal,
|
||||||
|
|
@ -235,31 +236,6 @@ interface ProbeResult {
|
||||||
state: "alive" | "dead" | "unknown";
|
state: "alive" | "dead" | "unknown";
|
||||||
failed: boolean;
|
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[] {
|
function splitEvidenceSignals(evidence: string): string[] {
|
||||||
return evidence
|
return evidence
|
||||||
|
|
@ -536,7 +512,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
||||||
detectingAttempts: currentDetectingAttempts,
|
detectingAttempts: currentDetectingAttempts,
|
||||||
},
|
},
|
||||||
): DeterminedStatus => {
|
): DeterminedStatus => {
|
||||||
applyLifecycleDecision(lifecycle, decision, nowIso);
|
commitLifecycleDecisionInPlace(lifecycle, decision, nowIso);
|
||||||
session.lifecycle = lifecycle;
|
session.lifecycle = lifecycle;
|
||||||
session.status = decision.status;
|
session.status = decision.status;
|
||||||
session.activitySignal = activitySignal;
|
session.activitySignal = activitySignal;
|
||||||
|
|
@ -1848,7 +1824,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
||||||
});
|
});
|
||||||
|
|
||||||
// Execute reaction if configured
|
// Execute reaction if configured
|
||||||
if (reactionConfig && reactionConfig.auto !== false) {
|
if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) {
|
||||||
await executeReaction(session.id, session.projectId, reactionKey, reactionConfig);
|
await executeReaction(session.id, session.projectId, reactionKey, reactionConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type RuntimeHandle,
|
type RuntimeHandle,
|
||||||
type SessionStatus,
|
type SessionStatus,
|
||||||
type ActivityState,
|
type ActivityState,
|
||||||
|
type Session,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { safeJsonParse, validateStatus } from "../utils/validation.js";
|
import { safeJsonParse, validateStatus } from "../utils/validation.js";
|
||||||
import type { ScannedSession } from "./scanner.js";
|
import type { ScannedSession } from "./scanner.js";
|
||||||
|
|
@ -20,6 +21,18 @@ import {
|
||||||
type RecoveryConfig,
|
type RecoveryConfig,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.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(
|
export async function validateSession(
|
||||||
scanned: ScannedSession,
|
scanned: ScannedSession,
|
||||||
|
|
@ -86,7 +99,7 @@ export async function validateSession(
|
||||||
|
|
||||||
let agentProcessRunning = false;
|
let agentProcessRunning = false;
|
||||||
let processProbeSucceeded = false;
|
let processProbeSucceeded = false;
|
||||||
const agentActivity: ActivityState | null = null;
|
let agentActivity: ActivityState | null = null;
|
||||||
if (agent && runtimeHandle) {
|
if (agent && runtimeHandle) {
|
||||||
try {
|
try {
|
||||||
agentProcessRunning = await agent.isProcessRunning(runtimeHandle);
|
agentProcessRunning = await agent.isProcessRunning(runtimeHandle);
|
||||||
|
|
@ -95,6 +108,37 @@ export async function validateSession(
|
||||||
agentProcessRunning = false;
|
agentProcessRunning = false;
|
||||||
processProbeSucceeded = 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;
|
const metadataValid = Object.keys(rawMetadata).length > 0;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Session, SessionStatus } from "./types.js";
|
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.
|
* Report watcher trigger types.
|
||||||
|
|
@ -174,15 +174,15 @@ export function checkBlockedAgent(
|
||||||
// If no report, nothing to check
|
// If no report, nothing to check
|
||||||
if (!report) return null;
|
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") {
|
if (report.state === "needs_input") {
|
||||||
|
const reportTime = Date.parse(report.timestamp);
|
||||||
|
const timeSinceReportMs = Number.isNaN(reportTime) ? undefined : now.getTime() - reportTime;
|
||||||
return {
|
return {
|
||||||
trigger: "agent_needs_input",
|
trigger: "agent_needs_input",
|
||||||
message: `Agent needs input: ${report.note ?? "waiting for user decision"}`,
|
message: `Agent needs input: ${report.note ?? "waiting for user decision"}`,
|
||||||
checkedAt: now.toISOString(),
|
checkedAt: now.toISOString(),
|
||||||
report,
|
report,
|
||||||
|
timeSinceReportMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import type { Metadata, Viewport } from "next";
|
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 { getProjectName } from "@/lib/project-name";
|
||||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||||
import { Providers } from "@/app/providers";
|
import { Providers } from "@/app/providers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const rootFontVariables = {
|
const geistSans = Geist({
|
||||||
"--font-geist-sans": '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
subsets: ["latin"],
|
||||||
"--font-jetbrains-mono":
|
variable: "--font-geist-sans",
|
||||||
'"SF Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", monospace',
|
display: "swap",
|
||||||
} as CSSProperties;
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
|
|
@ -41,8 +49,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className="dark"
|
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
|
||||||
style={rootFontVariables}
|
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
|
<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 { useSearchParams } from "next/navigation";
|
||||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||||
import {
|
import {
|
||||||
type DashboardAgentReportAuditEntry,
|
|
||||||
type DashboardSession,
|
type DashboardSession,
|
||||||
type DashboardPR,
|
type DashboardPR,
|
||||||
NON_RESTORABLE_STATUSES,
|
NON_RESTORABLE_STATUSES,
|
||||||
|
getActivitySignalLabel,
|
||||||
isPRMergeReady,
|
isPRMergeReady,
|
||||||
isPRRateLimited,
|
isPRRateLimited,
|
||||||
isPRUnenriched,
|
isPRUnenriched,
|
||||||
getSessionTruthLabel,
|
|
||||||
getSessionTruthReasonLabel,
|
|
||||||
getPRTruthLabel,
|
|
||||||
getPRTruthReasonLabel,
|
|
||||||
getRuntimeTruthLabel,
|
|
||||||
getRuntimeTruthReasonLabel,
|
|
||||||
getLifecycleGuidance,
|
|
||||||
getLifecycleEvidence,
|
|
||||||
getActivitySignalLabel,
|
|
||||||
getActivitySignalReasonLabel,
|
|
||||||
isDashboardRuntimeEnded,
|
isDashboardRuntimeEnded,
|
||||||
isDashboardSessionRestorable,
|
isDashboardSessionRestorable,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
@ -32,6 +22,8 @@ import type { ProjectInfo } from "@/lib/project-name";
|
||||||
|
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { ProjectSidebar } from "./ProjectSidebar";
|
import { ProjectSidebar } from "./ProjectSidebar";
|
||||||
|
import { SessionTruthPanel } from "./SessionTruthPanel";
|
||||||
|
import { SessionReportAuditPanel } from "./SessionReportAuditPanel";
|
||||||
|
|
||||||
const DirectTerminal = dynamic(
|
const DirectTerminal = dynamic(
|
||||||
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
|
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
|
||||||
|
|
@ -64,18 +56,6 @@ interface SessionDetailProps {
|
||||||
onRetrySidebar?: () => void;
|
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 ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatTimeCompact(isoDate: string | null): string {
|
function formatTimeCompact(isoDate: string | null): string {
|
||||||
|
|
@ -152,209 +132,6 @@ function activityStateClass(activityLabel: string): string {
|
||||||
return "session-detail-status-pill--neutral";
|
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({
|
function SessionTopStrip({
|
||||||
headline,
|
headline,
|
||||||
crumbId,
|
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.getByText("The empty state text needs to be shorter.")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute(
|
expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute(
|
||||||
"aria-expanded",
|
"aria-expanded",
|
||||||
"true",
|
"false",
|
||||||
);
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Agent Reports/i }));
|
||||||
expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("ao report working")).toBeInTheDocument();
|
expect(screen.getByText("ao report working")).toBeInTheDocument();
|
||||||
expect(screen.getByText("codex")).toBeInTheDocument();
|
expect(screen.getByText("codex")).toBeInTheDocument();
|
||||||
|
|
@ -189,15 +190,15 @@ describe("SessionDetail desktop layout", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggle = screen.getByRole("button", { name: /Agent Reports/i });
|
const toggle = screen.getByRole("button", { name: /Agent Reports/i });
|
||||||
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
|
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
|
||||||
|
|
||||||
fireEvent.click(toggle);
|
|
||||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
||||||
expect(screen.queryByText("ao acknowledge")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(toggle);
|
fireEvent.click(toggle);
|
||||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
|
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 () => {
|
it("sends unresolved comments back to the agent and shows sent state", async () => {
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,47 @@ describe("getAttentionLevel", () => {
|
||||||
});
|
});
|
||||||
expect(getAttentionLevel(session)).toBe("working");
|
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", () => {
|
describe("working state", () => {
|
||||||
|
|
|
||||||
|
|
@ -535,8 +535,8 @@ function getDetailedAttentionLevel(session: DashboardSession): AttentionLevel {
|
||||||
|
|
||||||
// ── Pending: waiting on external (reviewer, CI) ───────────────────
|
// ── Pending: waiting on external (reviewer, CI) ───────────────────
|
||||||
if (
|
if (
|
||||||
session.lifecycle?.sessionState === "idle" ||
|
|
||||||
session.lifecycle?.prReason === "review_pending" ||
|
session.lifecycle?.prReason === "review_pending" ||
|
||||||
|
session.lifecycle?.prReason === "closed_unmerged" ||
|
||||||
session.status === "review_pending"
|
session.status === "review_pending"
|
||||||
) {
|
) {
|
||||||
return "pending";
|
return "pending";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue