Q158: run history and run output come from the database, not the snapshot window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a075f931d5
commit
9d166e3fc9
18 files changed
+222
-35
No files matched your search
@@ -48,6 +48,10 @@ export const organizationContracts = {
|
||||
setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType<C.SetTaskBlockedInput>,
|
||||
taskDeleteImpact: z.strictObject({ orgId: id, taskId: id }),
|
||||
cancelQueuedRun: z.strictObject({ orgId: id, runId: id }),
|
||||
runHistory: z.union([
|
||||
z.strictObject({ orgId: id, beforeRunId: id.nullable(), limit: z.number().int().min(1).max(500) }),
|
||||
z.strictObject({ orgId: id, beforeRunId: id.nullable(), from: z.strictObject({ createdAt: z.string(), seq: z.number().int() }) }),
|
||||
]),
|
||||
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
|
||||
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
|
||||
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { normalizeExternalBrowserUrl } from "./external-url";
|
||||
import { resumeGoal } from "../services/goal-review-service";
|
||||
import { stopAllRuns, stopRun } from "../services/run-control";
|
||||
import { cancelQueuedRun } from "../services/run-cancel";
|
||||
import { runHistory } from "../services/run-history";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -168,6 +169,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
handle("setTaskBlocked", (input) => mutate(() => organization.setTaskBlocked(input)));
|
||||
handle("taskDeleteImpact", (input) => ok(organization.taskDeleteImpact(input.orgId, input.taskId)));
|
||||
handle("cancelQueuedRun", (input) => mutate(() => cancelQueuedRun(deps.connection.db, input.orgId, input.runId)));
|
||||
handle("runHistory", (input) => ok(runHistory(deps.connection.db, input.orgId, input.beforeRunId, "limit" in input ? { limit: input.limit } : { from: input.from })));
|
||||
handle("deleteTask", (input) => mutate(() => organization.deleteTask(input)));
|
||||
handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
|
||||
handle("runTask", async (input) => {
|
||||
|
||||
@@ -40,7 +40,8 @@ export type RequestPurpose = "agent_turn" | "goal_audit" | "model_check" | "manu
|
||||
export function requestsForRun(db: DatabaseSync, orgId: string, runId: string): RunRequestRecord[] {
|
||||
return db.prepare(`
|
||||
select id, purpose, model, state, error_message as errorMessage, input_tokens as inputTokens, output_tokens as outputTokens,
|
||||
cost_cents as costCents, created_at as createdAt, completed_at as completedAt
|
||||
cost_cents as costCents, created_at as createdAt, completed_at as completedAt,
|
||||
input_char_count as inputCharCount, output_char_count as outputCharCount, response_text as responseText
|
||||
from provider_requests where org_id = ? and task_run_id = ? order by rowid
|
||||
`).all(orgId, runId) as unknown as RunRequestRecord[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunCursor, RunHistoryPage, TaskRunRecord } from "../../shared/domain";
|
||||
import { camel } from "./snapshot-service";
|
||||
|
||||
/** Which older runs to return: the next page before the cursor, or every run from a position (which survives its run's deletion) up to it. */
|
||||
export type RunHistoryScope = { limit: number } | { from: RunCursor };
|
||||
|
||||
/** Runs before a loaded one (or the newest when none is loaded), oldest first, in time then insertion order, and whether older ones exist. */
|
||||
export function runHistory(db: DatabaseSync, orgId: string, beforeRunId: string | null, scope: RunHistoryScope): RunHistoryPage {
|
||||
const conditions = ["org_id = ?"];
|
||||
const params: Array<string | number> = [orgId];
|
||||
let boundary: RunCursor | null = null;
|
||||
if (beforeRunId !== null) {
|
||||
const before = db.prepare("select created_at, rowid from task_runs where id = ? and org_id = ?").get(beforeRunId, orgId) as { created_at: string; rowid: number } | undefined;
|
||||
if (!before) throw new Error("The oldest loaded run was not found.");
|
||||
conditions.push("(created_at, rowid) < (?, ?)");
|
||||
params.push(before.created_at, before.rowid);
|
||||
boundary = { createdAt: before.created_at, seq: before.rowid };
|
||||
}
|
||||
if ("from" in scope) {
|
||||
conditions.push("(created_at, rowid) >= (?, ?)");
|
||||
params.push(scope.from.createdAt, scope.from.seq);
|
||||
}
|
||||
const limit = "limit" in scope ? scope.limit : -1;
|
||||
const rows = db.prepare(`select *, rowid as seq from task_runs where ${conditions.join(" and ")} order by created_at desc, rowid desc limit ?`)
|
||||
.all(...params, limit === -1 ? -1 : limit + 1) as Array<Record<string, unknown>>;
|
||||
const page = (limit === -1 ? rows : rows.slice(0, limit)).reverse();
|
||||
const first = page[0];
|
||||
const oldest = first ? { createdAt: first.created_at as string, seq: first.seq as number } : null;
|
||||
// A range is measured from its pinned position, which holds even when the runs at it were deleted.
|
||||
const hasMore = "limit" in scope ? rows.length > limit : db.prepare(`
|
||||
select 1 from task_runs where org_id = ? and (created_at, rowid) < (?, ?) limit 1
|
||||
`).get(orgId, scope.from.createdAt, scope.from.seq) !== undefined;
|
||||
return { runs: page.map(({ seq: _seq, ...row }) => camel(row) as unknown as TaskRunRecord), hasMore, oldest, boundary };
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export class SnapshotService {
|
||||
workspaces,
|
||||
goals: list(this.db, "goals", "created_at asc"),
|
||||
projects: list(this.db, "projects", "created_at asc"),
|
||||
taskRuns: recentList<AppSnapshot["taskRuns"][number]>(this.db, "task_runs", "created_at desc", 200).reverse(),
|
||||
taskRuns: recentList<AppSnapshot["taskRuns"][number]>(this.db, "task_runs", "created_at desc, rowid desc", 200).reverse(),
|
||||
approvals: list(this.db, "approvals", "created_at desc"),
|
||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
|
||||
|
||||
@@ -93,6 +93,7 @@ const api: DesktopApi = {
|
||||
setTaskBlocked: (input) => invoke(IPC_CHANNELS.setTaskBlocked, input),
|
||||
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
|
||||
cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input),
|
||||
runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input),
|
||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||
|
||||
@@ -96,7 +96,7 @@ export function App() {
|
||||
office: <OfficePage {...pageProps} />,
|
||||
settings: <SettingsPage {...pageProps} />,
|
||||
}[route];
|
||||
const pageKey = focus ? `${route}:${focus.kind}:${focus.id}` : route;
|
||||
const pageKey = `${snapshot.activeOrgId}:${route}${focus ? `:${focus.kind}:${focus.id}` : ""}`;
|
||||
|
||||
return (
|
||||
<AvatarDirectoryContext.Provider value={avatarDirectory}>
|
||||
|
||||
@@ -52,6 +52,8 @@ export const zh: Record<string, string> = {
|
||||
"Showing {shown} of {total}": "显示 {total} 项中的 {shown} 项",
|
||||
"Show earlier decisions": "显示更早的决定",
|
||||
"Cancel run": "取消运行",
|
||||
"Show older runs": "显示更早的运行",
|
||||
"These are all the runs.": "已显示全部运行。",
|
||||
"Running…": "运行中…",
|
||||
"A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。",
|
||||
"A new session on the task picks up from what earlier sessions did and where they stopped.": "任务的新会话会接着之前会话做过的工作和停下的位置继续。",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { RunRequestList } from "./runs/RunRequestList";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRunRequests } from "./runs/use-run-requests";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Avatar, Button, Pill, Segmented } from "../shared/ui";
|
||||
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
||||
import type { AgentRecord, ProviderRecord, RunCursor, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
@@ -17,11 +18,48 @@ const FILTERS: Array<{ id: StatusFilter; label: string }> = [
|
||||
|
||||
export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const taskRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]);
|
||||
const recentRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]);
|
||||
// Older runs page in from the database; their range, from a pinned position, refreshes with each reload.
|
||||
const [pinned, setPinned] = useState<RunCursor | null>(null);
|
||||
const [olderRuns, setOlderRuns] = useState<TaskRunRecord[]>([]);
|
||||
const [olderDone, setOlderDone] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const anchorId = recentRuns.length > 0 ? recentRuns[0].id : null;
|
||||
useEffect(() => {
|
||||
if (!pinned) return;
|
||||
let cancelled = false;
|
||||
window.agentCompany.runHistory({ orgId, beforeRunId: anchorId, from: pinned }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
setOlderRuns(result.data.runs);
|
||||
setOlderDone(!result.data.hasMore);
|
||||
setHistoryError(null);
|
||||
}).catch((error: unknown) => {
|
||||
if (!cancelled) setHistoryError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orgId, anchorId, pinned, recentRuns]);
|
||||
const taskRuns = useMemo(() => [...olderRuns, ...recentRuns], [olderRuns, recentRuns]);
|
||||
const oldestShownId = taskRuns.length > 0 ? taskRuns[0].id : null;
|
||||
const loadOlder = useCallback(() => {
|
||||
window.agentCompany.runHistory({ orgId, beforeRunId: oldestShownId, limit: 100 }).then((result) => {
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
setHistoryError(null);
|
||||
// With nothing older, the oldest shown run's position is pinned so later reloads keep the range.
|
||||
const position = result.data.oldest ?? result.data.boundary;
|
||||
if (position) setPinned(position);
|
||||
setOlderDone(!result.data.hasMore);
|
||||
}).catch((error: unknown) => setHistoryError(error instanceof Error ? error.message : String(error)));
|
||||
}, [orgId, oldestShownId]);
|
||||
// While the snapshot holds none of this company's runs and nothing is pinned, each reload asks the database for the newest page.
|
||||
useEffect(() => {
|
||||
if (recentRuns.length === 0 && pinned === null) loadOlder();
|
||||
}, [recentRuns, pinned, loadOlder]);
|
||||
const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
|
||||
const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const providers = useMemo(() => snapshot.providers.filter((p) => p.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(focus?.kind === "run" ? focus.id : null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -62,7 +100,8 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
<Segmented value={filter} onChange={setFilter} options={FILTERS} />
|
||||
</div>
|
||||
<div className="pane-scroll">
|
||||
{filtered.map((run) => {
|
||||
{/* Newest first, so "show older runs" continues below. */}
|
||||
{[...filtered].reverse().map((run) => {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
const active = run.id === selectedId;
|
||||
@@ -86,11 +125,17 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div style={{ padding: 16, fontSize: 14, color: "var(--text-3)" }}>{t("No runs")}</div>}
|
||||
{historyError && <div className="form-error" role="alert" style={{ padding: "0 16px" }}>{historyError}</div>}
|
||||
<div style={{ padding: 12, display: "flex", justifyContent: "center" }}>
|
||||
{olderDone
|
||||
? <span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("These are all the runs.")}</span>
|
||||
: <Button size="small" onClick={loadOlder}>{t("Show older runs")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: "auto", padding: 24 }}>
|
||||
{selected ? (
|
||||
<RunDetail key={selected.id} run={selected} tasks={tasks} agents={agents} providers={providers} providerRequests={providerRequests} actions={actions} onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })} />
|
||||
<RunDetail key={selected.id} run={selected} tasks={tasks} agents={agents} providers={providers} actions={actions} onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })} />
|
||||
) : (
|
||||
<div style={{ display: "grid", placeItems: "center", height: "100%", color: "var(--text-3)", fontSize: 14 }}>
|
||||
{t("Select a run")}
|
||||
@@ -101,19 +146,22 @@ export function RunsPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetail({ run, tasks, agents, providers, providerRequests, actions, onOpenTask }: {
|
||||
function RunDetail({ run, tasks, agents, providers, actions, onOpenTask }: {
|
||||
actions: PageProps["actions"];
|
||||
onOpenTask: (taskId: string) => void;
|
||||
run: TaskRunRecord;
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
providers: ProviderRecord[];
|
||||
providerRequests: Array<{ id: string; orgId: string; providerId: string; model: string; state: string; responseText: string | null; inputCharCount: number; outputCharCount: number; createdAt: string; inputTokens: number | null; outputTokens: number | null; costCents: number | null }>;
|
||||
}) {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
const provider = providers.find((p) => p.id === run.providerId);
|
||||
const request = run.providerRequestId ? providerRequests.find((r) => r.id === run.providerRequestId) : null;
|
||||
// The run's calls come from the database, so an old run still shows its output; refetched with each snapshot reload.
|
||||
const loadedRequests = useRunRequests(run.orgId, run.id, run);
|
||||
const request = loadedRequests && "requests" in loadedRequests && run.providerRequestId
|
||||
? loadedRequests.requests.find((item) => item.id === run.providerRequestId) ?? null
|
||||
: null;
|
||||
|
||||
const started = run.startedAt ? new Date(run.startedAt) : null;
|
||||
const completed = run.completedAt ? new Date(run.completedAt) : null;
|
||||
@@ -165,7 +213,7 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions, o
|
||||
<MetaLabel>{t("Context")}</MetaLabel><div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis" }}>{run.executionContextId}</div>
|
||||
</div>
|
||||
|
||||
<RunRequestList orgId={run.orgId} runId={run.id} state={run.state} />
|
||||
<RunRequestList loaded={loadedRequests} />
|
||||
|
||||
{run.state === "superseded" && run.errorMessage && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RunRequestRecord } from "../../../../shared/provider-runtime-domain";
|
||||
import type { LoadedRequests } from "./use-run-requests";
|
||||
import { Pill } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
@@ -12,25 +11,8 @@ function purposeLabel(purpose: string): string {
|
||||
}
|
||||
|
||||
/** Every model call the run made, in order, so a turn or an audit can be found by its task. */
|
||||
export function RunRequestList({ orgId, runId, state }: { orgId: string; runId: string; state: string }) {
|
||||
// Fetched calls are kept with the run they belong to, so switching runs never shows another run's chain.
|
||||
const [loaded, setLoaded] = useState<{ runId: string; requests: RunRequestRecord[] } | { runId: string; error: string } | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
window.agentCompany.runRequests({ orgId, runId })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setLoaded(result.ok ? { runId, requests: result.data } : { runId, error: result.error.message });
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setLoaded({ runId, error: reason instanceof Error ? reason.message : String(reason) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orgId, runId, state]);
|
||||
|
||||
if (!loaded || loaded.runId !== runId) return null;
|
||||
export function RunRequestList({ loaded }: { loaded: LoadedRequests | null }) {
|
||||
if (!loaded) return null;
|
||||
if ("error" in loaded) return <div className="form-error" role="alert">{loaded.error}</div>;
|
||||
const { requests } = loaded;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RunRequestRecord } from "../../../../shared/provider-runtime-domain";
|
||||
|
||||
export type LoadedRequests = { runId: string; requests: RunRequestRecord[] } | { runId: string; error: string };
|
||||
|
||||
/** Every model call of one run, read from the database (not the snapshot's window), refetched whenever `refreshKey` changes. */
|
||||
export function useRunRequests(orgId: string, runId: string, refreshKey: unknown): LoadedRequests | null {
|
||||
// Fetched calls are kept with the run they belong to, so switching runs never shows another run's chain.
|
||||
const [loaded, setLoaded] = useState<LoadedRequests | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
window.agentCompany.runRequests({ orgId, runId })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setLoaded(result.ok ? { runId, requests: result.data } : { runId, error: result.error.message });
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) setLoaded({ runId, error: reason instanceof Error ? reason.message : String(reason) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orgId, runId, refreshKey]);
|
||||
return loaded && loaded.runId === runId ? loaded : null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TaskDeleteImpact } from "./domain";
|
||||
import type { RunCursor, RunHistoryPage, TaskDeleteImpact } from "./domain";
|
||||
import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain";
|
||||
import type { RunRequestRecord } from "./provider-runtime-domain";
|
||||
import type { ApprovalDrift } from "./approval-pin";
|
||||
@@ -207,6 +207,8 @@ export interface DesktopApi {
|
||||
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
|
||||
/** Cancels a run that has not started yet. */
|
||||
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** Runs older than a loaded one, for history beyond the snapshot's recent runs. */
|
||||
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
|
||||
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
|
||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
|
||||
@@ -105,6 +105,20 @@ export interface TaskDeleteImpact {
|
||||
pendingApprovals: number;
|
||||
}
|
||||
|
||||
/** A position in run history (time, then insertion), which stays valid after its run is deleted. */
|
||||
export interface RunCursor {
|
||||
createdAt: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
/** A page of older runs, oldest first: whether older ones exist, the position of its oldest run, and of the run it was read before. */
|
||||
export interface RunHistoryPage {
|
||||
runs: TaskRunRecord[];
|
||||
hasMore: boolean;
|
||||
oldest: RunCursor | null;
|
||||
boundary: RunCursor | null;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
|
||||
@@ -86,6 +86,7 @@ export const IPC_CHANNELS = {
|
||||
setTaskBlocked: "agentcompany:set-task-blocked",
|
||||
taskDeleteImpact: "agentcompany:task-delete-impact",
|
||||
cancelQueuedRun: "agentcompany:cancel-queued-run",
|
||||
runHistory: "agentcompany:run-history",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
|
||||
@@ -56,4 +56,7 @@ export interface RunRequestRecord {
|
||||
costCents: number | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
inputCharCount: number;
|
||||
outputCharCount: number;
|
||||
responseText: string | null;
|
||||
}
|
||||
@@ -290,6 +290,20 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await expect(start).toBeVisible();
|
||||
await expect(chatLog.getByText("General line 0", { exact: true })).toBeVisible();
|
||||
|
||||
// Runs older than the loaded window page in from the database, down to the first one.
|
||||
await page.getByRole("button", { name: "Runs" }).click();
|
||||
const runRows = page.locator(".pane-scroll .nav-row");
|
||||
await expect(runRows).toHaveCount(200);
|
||||
await page.getByRole("button", { name: "Show older runs" }).click();
|
||||
await expect.poll(async () => runRows.count()).toBeGreaterThan(200);
|
||||
for (let click = 0; click < 10 && await page.getByRole("button", { name: "Show older runs" }).isVisible(); click += 1) {
|
||||
await page.getByRole("button", { name: "Show older runs" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await expect(page.getByText("These are all the runs.")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Messages" }).click();
|
||||
await page.locator(".convo-item", { hasText: "general" }).click();
|
||||
|
||||
// The header lists the real members, how each agent replies, and who can read.
|
||||
await page.getByRole("button", { name: /^Members \(/ }).click();
|
||||
const roster = page.getByRole("menu");
|
||||
|
||||
@@ -40,6 +40,9 @@ describe("a run's model calls", () => {
|
||||
});
|
||||
const runId = claimNextQueuedRun(connection)!.id;
|
||||
await expect(execution.executeRun(orgId, runId)).rejects.toThrow();
|
||||
// Each call carries its own output, so the run's detail does not depend on a snapshot window.
|
||||
const turnCall = requestsForRun(connection.db, orgId, runId).find((request) => request.purpose === "agent_turn" && request.state === "succeeded")!;
|
||||
expect([typeof turnCall.responseText, typeof turnCall.inputCharCount, typeof turnCall.outputCharCount]).toEqual(["string", "number", "number"]);
|
||||
expect(requestsForRun(connection.db, orgId, runId).map((request) => [request.purpose, request.state])).toEqual([
|
||||
["model_check", "succeeded"],
|
||||
["agent_turn", "succeeded"],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { runHistory } from "../../src/main/services/run-history";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||
|
||||
describe("run history", () => {
|
||||
test("pages back past the snapshot's recent runs to the very first", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-run-history-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let taskId = "";
|
||||
let agentId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
|
||||
taskId = organization.createTask({
|
||||
orgId, title: "Work", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: agentId, risk: RISK_LEVEL.LOW,
|
||||
projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
|
||||
});
|
||||
// Every run shares one instant, so only insertion order can page them correctly.
|
||||
const insert = connection.db.prepare("insert into task_runs(id, org_id, task_id, agent_id, trigger, state, created_at) values (?, ?, ?, ?, 'manual', 'completed', '2026-04-01T00:00:00.000Z')");
|
||||
for (let index = 0; index < 230; index += 1) insert.run(`run_${String(index).padStart(3, "0")}`, orgId, taskId, agentId);
|
||||
});
|
||||
const recent = new SnapshotService(connection.db).load().taskRuns;
|
||||
expect(recent).toHaveLength(200);
|
||||
expect(recent[0].id).toBe("run_030");
|
||||
const older = runHistory(connection.db, orgId, recent[0].id, { limit: 20 });
|
||||
expect(older.runs.map((run) => run.id)).toEqual(Array.from({ length: 20 }, (_, index) => `run_${String(index + 10).padStart(3, "0")}`));
|
||||
expect(older.hasMore).toBe(true);
|
||||
const oldest = runHistory(connection.db, orgId, older.runs[0].id, { limit: 20 });
|
||||
expect(oldest.runs.map((run) => run.id)).toEqual(Array.from({ length: 10 }, (_, index) => `run_${String(index).padStart(3, "0")}`));
|
||||
expect(oldest.hasMore).toBe(false);
|
||||
expect(() => runHistory(connection.db, orgId, "run_missing", { limit: 20 })).toThrow("The oldest loaded run was not found.");
|
||||
// The loaded range refreshes in one query from its oldest run up to the cursor.
|
||||
const range = runHistory(connection.db, orgId, recent[0].id, { from: oldest.oldest! });
|
||||
expect([range.runs.length, range.runs[0].id, range.runs.at(-1)!.id, range.hasMore]).toEqual([30, "run_000", "run_029", false]);
|
||||
// The pinned position holds after its run is deleted, and the range still says whether older runs exist.
|
||||
connection.db.prepare("delete from task_runs where id in ('run_000', 'run_001')").run();
|
||||
expect(runHistory(connection.db, orgId, recent[0].id, { from: older.oldest! })).toMatchObject({ hasMore: true });
|
||||
expect(runHistory(connection.db, orgId, recent[0].id, { from: oldest.oldest! }).runs[0].id).toBe("run_002");
|
||||
// With nothing loaded, the newest runs come first.
|
||||
expect(runHistory(connection.db, orgId, null, { limit: 2 }).runs.map((run) => run.id)).toEqual(["run_228", "run_229"]);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user