mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
4273ca9dbd
* refactor(sessions): keep helper transcripts in memory * refactor(sessions): remove file-era transcript storage * test(sessions): use SQLite identity in attempt persistence * test(codex): isolate legacy transcript fixtures * fix(sessions): preserve SQLite transcript identity * fix(sessions): harden transcript lifecycle invariants * fix(sessions): validate transcript identities * fix(sessions): close identity compatibility gaps * fix(sessions): preserve leaf and plugin identities * fix(sessions): retain dispatch transcript targets * fix(sessions): preserve active transcript context * fix(sessions): isolate artifact accounting * fix(sessions): bound SQLite usage accounting * fix(sessions): retain bounded latest usage * fix(sessions): align rebased transcript targets * test(sessions): align accessor scope fixture * fix(telegram): derive SQLite transcript identity * refactor(sessions): remove file-era compaction residue * chore(sessions): lower max-lines baseline * fix(sessions): preserve structured transcript identity * test(sessions): align doctor identity assertions * fix(sessions): isolate default SDK database * refactor(sessions): remove dead file-era exports * fix(sessions): reconcile SQLite transcript identity * fix(sessions): pass checkpoint identity explicitly * test(sessions): make entry field probe explicit * test(sessions): satisfy transcript cleanup lint * test(sessions): align diagnostics identity proof * fix(sessions): finish transcript runtime teardown * fix(sessions): preserve transcript identity invariants * fix(sessions): harden transcript compatibility edges * fix(sessions): preserve checkpoint transcript anchors * fix(sessions): preserve SQLite lifecycle invariants * fix(sessions): retarget compaction successors * test(sessions): preserve transcript fixture semantics * feat(plugin-sdk): add command transcript targets * fix(sessions): serialize transcript rewrites * fix(sessions): validate legacy successor identity * fix(sessions): normalize compaction ownership * fix(sessions): validate successor identity before adoption * fix(sessions): preserve plugin transcript ownership * fix(sessions): carry transcript identity through commands * fix(sessions): import legacy checkpoint artifacts into SQLite * fix(sessions): preserve successor transcript ownership * fix(sessions): align transcript consumers with target identity * fix(sessions): scope transcript token estimates * fix(sessions): retain agent identity across lifecycle hooks * fix(sessions): resolve scoped SQLite targets * fix(sessions): isolate lifecycle transcript targets * fix(sessions): validate compaction agent ownership * fix(sessions): preserve reset and cleanup lifecycle * fix(sessions): serialize prompt cleanup lifecycle * fix(sessions): remove stale lock import * fix(sessions): preserve reset target context * fix(sessions): fence prompt reload takeover * fix(sessions): unblock abort and default lifecycle reads * fix(sessions): validate legacy successor scope * fix(sessions): reject metadata-only runtime rows * fix(sessions): propagate custom transcript stores * fix(sessions): preserve adopted retry targets * fix(sessions): allow unkeyed usage reads * fix(sessions): harden runtime target boundaries * fix(sessions): serialize retry transcript writes * fix(sessions): bound prompt reload disposal * fix(sessions): complete retry marker identity * fix(sessions): keep legacy marker identity minimal * test(sessions): tighten teardown fixture types * fix(sessions): preserve compatibility target identity * test(sessions): persist post-checkpoint boundary turn * test(sessions): align runtime store mock contracts * style(sessions): simplify persisted identity guard * fix(sessions): prefer complete typed targets * fix(sessions): recover legacy marker targets * test(sessions): align marker lookup fixture scope * fix(sessions): validate partial transcript targets * fix(sessions): reconcile partial transcript identities * fix(sessions): canonicalize compatibility identities * test(sessions): cover compatibility aliases * fix(sessions): adopt legacy successor identity * fix(sessions): preserve usage read identity * fix(sessions): preserve partial marker compatibility * fix(sessions): validate legacy successor mappings * fix(sessions): reconcile marker store mappings * fix(sessions): preserve legacy fallback identity * fix(sessions): harden marker alias resolution * fix(sessions): prefer verified successor aliases * fix(sessions): resolve preferred marker aliases * fix(sessions): serialize cleanup admission * fix(sessions): align marker lookup scopes * fix(codex): type marker alias summaries * style(sessions): satisfy changed lint * test(sessions): align structured target assertions * fix(sessions): reconcile latest identity contracts * fix(sessions): validate transcript identity boundaries * docs(sessions): explain stable registry keys * fix(sessions): harden compatibility target round trips * fix(sessions): port usage identity to split modules * test(sessions): align subagent transcript identity * fix(sessions): finish transcript identity migration * fix(agents): route subagent completion capture through transcript targets * fix(agents): settle SQLite prompt handoff during cleanup * chore: shrink max-lines baseline after teardown * fix(sessions): port teardown across split runtime owners * fix(sessions): carry transcript targets through split owners * test(agents): use SQLite compaction target in abort coverage * chore: retain unrelated max-lines suppressions * chore: shrink max-lines baseline after main splits * style(agents): const compaction checkpoint locals * fix(sessions): harden SQLite teardown boundaries * test(sessions): use typed metadata in predicate isolation fixture * test(agents): cover malformed settlement rejections lint-safely * fix(sessions): close remaining SQLite identity races * fix(agents): fail closed on incomplete successor targets * fix(sessions): preserve transcript identity fallbacks * fix(agents): preserve session-key abort admission * fix(trajectory): validate incomplete export targets * test(sessions): drop retired pricing cache imports * fix(sessions): validate partial transcript identities * fix(sessions): close transcript identity edge cases * fix(plugins): reserve retired transcript locator slot * fix(sessions): scope transcript locks by target * style(sessions): simplify SDK initialization error * fix(sessions): preserve initialized transcript state * fix(codex): verify mirrored history session keys * fix(sessions): reject stale transcript ownership * fix(sessions): anchor asynchronous transcript ownership * fix(sessions): measure active transcript state * fix(sessions): preserve scoped transcript compaction * fix(sessions): harden transcript identity and lifecycle * fix(sessions): resolve scoped command transcript stores * fix(sessions): make transcript appends failure-atomic * fix(sessions): enforce scoped transcript ownership * fix(sessions): reject cross-owner transcript handoffs * fix(sessions): fence cleanup transcript ownership * fix(sessions): retire stale write ownership contexts * fix(sessions): preserve pending session migration state * fix(sessions): validate migrated transcript ownership * fix(sessions): validate usage transcript targets * fix(sessions): clear predecessor transcript metadata * fix(sessions): align durable session event targets * fix(sessions): fence late prompt handoffs * fix(sessions): fence lifecycle transcript fallbacks * fix(sessions): bound zero-length memory capture * fix(sessions): preserve transcript teardown ownership * fix(sessions): reject duplicate cleanup ownership * fix(sessions): serialize runtime writes with sqlite leases * fix(sessions): close sqlite teardown concurrency gaps * fix(sessions): preserve nested lifecycle failures * fix(sessions): canonicalize sqlite transcript ownership * fix(sessions): settle disposed prompt handoffs * fix(sessions): resolve canonical attempt lock targets * test(sessions): align canonical target fixtures * test(sessions): retire redundant jsonl parser coverage * refactor(sessions): split active transcript cursors * test(memory): retire legacy marker fixture * fix(sessions): preserve canonical transcript access after rebase * fix(sessions): fence prompt lease and return transcript targets * fix(sessions): colocate transcript leases with target store * fix(sessions): canonicalize transcript lease and worker targets * fix(sessions): preserve plugin and fork identity markers * fix(sessions): complete sqlite transcript target migration * fix(sessions): integrate canonical followup identity * fix(sessions): preserve bounded transcript topology * fix(sessions): validate transcript identity boundaries * fix(context): separate caller and successor targets * test(sessions): split persistence compatibility coverage * test(sessions): preserve fixture topology efficiently * chore(sdk): refresh plugin api baseline * test(agents): align compaction lock target mocks * test(sessions): seed malformed transcript fixtures directly * fix(agents): canonicalize transcript compatibility inputs * fix(agents): type optional tool result ids * test(ci): stabilize loaded process timing * test(tui): wait for collect queue admission
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
/**
|
|
* Session trajectory tail command.
|
|
*
|
|
* It selects active or requested sessions, renders recent trajectory events,
|
|
* and can follow newly appended SQLite trajectory rows.
|
|
*/
|
|
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
|
|
import { getRuntimeConfig } from "../config/config.js";
|
|
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
|
|
import type { SessionEntry } from "../config/sessions/types.js";
|
|
import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
|
|
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { loadSqliteTrajectoryRuntimeEventRowsSync } from "../trajectory/runtime-store.sqlite.js";
|
|
import type { TrajectoryEvent } from "../trajectory/types.js";
|
|
import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js";
|
|
import { shortenText } from "./text-format.js";
|
|
|
|
type SessionsTailOptions = {
|
|
store?: string;
|
|
agent?: string;
|
|
allAgents?: boolean;
|
|
sessionKey?: string;
|
|
follow?: boolean;
|
|
tail?: string | number;
|
|
};
|
|
|
|
type TailSelection = {
|
|
agentId: string;
|
|
key: string;
|
|
entry: SessionEntry;
|
|
storePath: string;
|
|
source: TailTrajectorySource;
|
|
};
|
|
|
|
type TailTrajectorySource = {
|
|
agentId: string;
|
|
sessionId: string;
|
|
storePath: string;
|
|
};
|
|
|
|
type SqliteFollowState = {
|
|
lastStorageSeq: number;
|
|
selection: TailSelection;
|
|
};
|
|
|
|
type TrajectorySnapshot = {
|
|
events: TrajectoryEvent[];
|
|
maxStorageSeq: number;
|
|
};
|
|
|
|
const DEFAULT_TAIL_COUNT = 80;
|
|
const SESSION_KEY_PAD = 30;
|
|
const EVENT_TYPE_PAD = 16;
|
|
const FOLLOW_INTERVAL_MS = 1_000;
|
|
let followIntervalMsForTests: number | undefined;
|
|
|
|
/** Overrides the follow polling interval for tests. */
|
|
function setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void {
|
|
followIntervalMsForTests = intervalMs;
|
|
}
|
|
|
|
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.sessionsTailTestApi")] = {
|
|
setSessionsTailFollowIntervalMsForTests,
|
|
};
|
|
}
|
|
|
|
function resolveFollowIntervalMs(): number {
|
|
return followIntervalMsForTests ?? FOLLOW_INTERVAL_MS;
|
|
}
|
|
|
|
function parseTailCount(value: string | number | undefined): number | null {
|
|
if (value === undefined) {
|
|
return DEFAULT_TAIL_COUNT;
|
|
}
|
|
return parseStrictNonNegativeInteger(value) ?? null;
|
|
}
|
|
|
|
function toOptionalString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function formatTimestamp(ts: string): string {
|
|
const date = new Date(ts);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return "--:--:--";
|
|
}
|
|
return date.toISOString().slice(11, 19);
|
|
}
|
|
|
|
function modelLabel(event: TrajectoryEvent): string | undefined {
|
|
const provider = event.provider?.trim();
|
|
const model = event.modelId?.trim();
|
|
if (provider && model) {
|
|
return `${provider}/${model}`;
|
|
}
|
|
return model || provider || undefined;
|
|
}
|
|
|
|
function toolName(data: Record<string, unknown> | undefined): string {
|
|
return toOptionalString(data?.name) ?? toOptionalString(data?.toolName) ?? "tool";
|
|
}
|
|
|
|
function resultStatus(data: Record<string, unknown> | undefined): string {
|
|
if (data?.success === true) {
|
|
return "ok";
|
|
}
|
|
if (data?.success === false || data?.isError === true) {
|
|
return "error";
|
|
}
|
|
return toOptionalString(data?.status) ?? "done";
|
|
}
|
|
|
|
function modelCompletionStatus(data: Record<string, unknown> | undefined): string {
|
|
if (data?.timedOut === true) {
|
|
return "timeout";
|
|
}
|
|
if (data?.aborted === true) {
|
|
return "aborted";
|
|
}
|
|
if (toOptionalString(data?.promptError)) {
|
|
return "error";
|
|
}
|
|
return "done";
|
|
}
|
|
|
|
function safePreview(event: TrajectoryEvent): string {
|
|
const data = event.data;
|
|
switch (event.type) {
|
|
case "session.started":
|
|
return "session started";
|
|
case "context.compiled": {
|
|
const tools = Array.isArray(data?.tools) ? data.tools.length : undefined;
|
|
return tools === undefined ? "context compiled" : `context compiled (${tools} tools)`;
|
|
}
|
|
case "prompt.submitted":
|
|
return "prompt submitted";
|
|
case "prompt.skipped": {
|
|
const reason = toOptionalString(data?.reason);
|
|
return `prompt skipped${reason ? `: ${reason}` : ""}`;
|
|
}
|
|
case "tool.call":
|
|
// Tool arguments may contain secrets or user text; tail output shows only
|
|
// the tool name and a redacted placeholder.
|
|
return `${toolName(data)} {...redacted...}`;
|
|
case "tool.timeout":
|
|
return `${toolName(data)} timeout`;
|
|
case "tool.result":
|
|
return `${toolName(data)} ${resultStatus(data)}`;
|
|
case "model.completed": {
|
|
const model = modelLabel(event);
|
|
const status = modelCompletionStatus(data);
|
|
return model ? `${model} ${status}` : status;
|
|
}
|
|
case "session.ended":
|
|
return toOptionalString(data?.status) ?? "ended";
|
|
case "trace.truncated":
|
|
return "trajectory truncated";
|
|
default:
|
|
return toOptionalString(data?.status) ?? toOptionalString(data?.name) ?? "";
|
|
}
|
|
}
|
|
|
|
function formatProgressLine(event: TrajectoryEvent): string {
|
|
const sessionLabel = shortenText(event.sessionKey ?? event.sessionId, SESSION_KEY_PAD).padEnd(
|
|
SESSION_KEY_PAD,
|
|
);
|
|
const typeLabel = shortenText(event.type, EVENT_TYPE_PAD).padEnd(EVENT_TYPE_PAD);
|
|
const preview = safePreview(event);
|
|
return [formatTimestamp(event.ts), typeLabel, sessionLabel, preview].join(" ").trimEnd();
|
|
}
|
|
|
|
function readSqliteTrajectorySnapshot(
|
|
source: TailTrajectorySource,
|
|
tailEvents: number,
|
|
): TrajectorySnapshot {
|
|
const rows = loadSqliteTrajectoryRuntimeEventRowsSync({
|
|
agentId: source.agentId,
|
|
sessionId: source.sessionId,
|
|
storePath: source.storePath,
|
|
tailEvents,
|
|
});
|
|
return {
|
|
events: rows.map((row) => row.event),
|
|
maxStorageSeq: rows.at(-1)?.seq ?? -1,
|
|
};
|
|
}
|
|
|
|
function readTailSnapshot(selection: TailSelection, tailEvents: number): TrajectorySnapshot {
|
|
return readSqliteTrajectorySnapshot(selection.source, tailEvents);
|
|
}
|
|
|
|
function renderEvents(events: TrajectoryEvent[], runtime: RuntimeEnv): void {
|
|
for (const event of events) {
|
|
runtime.log(formatProgressLine(event));
|
|
}
|
|
}
|
|
|
|
function isRunningSession(selection: TailSelection): boolean {
|
|
const cfg = getRuntimeConfig();
|
|
const acpMeta = readAcpSessionMeta({
|
|
sessionKey: resolveStoredSessionKeyForAgentStore({
|
|
cfg,
|
|
agentId: selection.agentId,
|
|
sessionKey: selection.key,
|
|
}),
|
|
});
|
|
return selection.entry.status === "running" || acpMeta?.state === "running";
|
|
}
|
|
|
|
function compareSelectionsByUpdatedAt(a: TailSelection, b: TailSelection): number {
|
|
return (b.entry.updatedAt ?? 0) - (a.entry.updatedAt ?? 0);
|
|
}
|
|
|
|
function buildTailSelection(params: {
|
|
agentId: string;
|
|
entry: SessionEntry;
|
|
key: string;
|
|
storePath: string;
|
|
}): TailSelection | null {
|
|
const sessionId = params.entry.sessionId?.trim();
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
return {
|
|
agentId: params.agentId,
|
|
entry: params.entry,
|
|
key: params.key,
|
|
source: {
|
|
agentId: params.agentId,
|
|
sessionId,
|
|
storePath: params.storePath,
|
|
},
|
|
storePath: params.storePath,
|
|
};
|
|
}
|
|
|
|
function selectSessionsToTail(selections: TailSelection[], sessionKey?: string): TailSelection[] {
|
|
const requested = sessionKey?.trim();
|
|
if (requested) {
|
|
return selections.filter((selection) => selection.key === requested);
|
|
}
|
|
|
|
const running = selections.filter((selection) => isRunningSession(selection));
|
|
if (running.length > 0) {
|
|
// Without an explicit key, prefer all running sessions so follow mode shows
|
|
// concurrent active work instead of only the newest store entry.
|
|
return running.toSorted(compareSelectionsByUpdatedAt);
|
|
}
|
|
|
|
const latest = selections.toSorted(compareSelectionsByUpdatedAt)[0];
|
|
return latest ? [latest] : [];
|
|
}
|
|
|
|
function readNewSqliteFollowEvents(state: SqliteFollowState): TrajectoryEvent[] {
|
|
const rows = loadSqliteTrajectoryRuntimeEventRowsSync({
|
|
agentId: state.selection.source.agentId,
|
|
afterSeq: state.lastStorageSeq,
|
|
sessionId: state.selection.source.sessionId,
|
|
storePath: state.selection.source.storePath,
|
|
});
|
|
if (rows.length === 0) {
|
|
return [];
|
|
}
|
|
state.lastStorageSeq = rows.at(-1)?.seq ?? state.lastStorageSeq;
|
|
return rows.map((row) => row.event);
|
|
}
|
|
|
|
async function followSelections(
|
|
selections: TailSelection[],
|
|
runtime: RuntimeEnv,
|
|
initialSnapshots: Map<TailSelection, TrajectorySnapshot>,
|
|
): Promise<void> {
|
|
const states = selections.map((selection): SqliteFollowState => {
|
|
const snapshot = initialSnapshots.get(selection);
|
|
return {
|
|
lastStorageSeq: snapshot?.maxStorageSeq ?? -1,
|
|
selection,
|
|
};
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const interval = setInterval(() => {
|
|
for (const state of states) {
|
|
try {
|
|
renderEvents(readNewSqliteFollowEvents(state), runtime);
|
|
} catch (error) {
|
|
runtime.error(
|
|
`Failed to read trajectory progress for ${state.selection.key}: ${formatErrorMessage(
|
|
error,
|
|
)}`,
|
|
);
|
|
}
|
|
}
|
|
}, resolveFollowIntervalMs());
|
|
|
|
const stop = () => {
|
|
clearInterval(interval);
|
|
process.off("SIGINT", stop);
|
|
process.off("SIGTERM", stop);
|
|
resolve();
|
|
};
|
|
process.once("SIGINT", stop);
|
|
process.once("SIGTERM", stop);
|
|
});
|
|
}
|
|
|
|
function resolveTailTargetAgent(opts: SessionsTailOptions): string | undefined {
|
|
if (opts.agent?.trim() || opts.store?.trim() || opts.allAgents === true) {
|
|
return opts.agent;
|
|
}
|
|
return opts.sessionKey?.trim() ? resolveAgentIdFromSessionKey(opts.sessionKey) : undefined;
|
|
}
|
|
|
|
/** Tails recent trajectory events for the selected session(s). */
|
|
export async function sessionsTailCommand(
|
|
opts: SessionsTailOptions,
|
|
runtime: RuntimeEnv,
|
|
): Promise<void> {
|
|
const tailCount = parseTailCount(opts.tail);
|
|
if (tailCount === null) {
|
|
runtime.error("--tail must be a non-negative integer, for example --tail 25.");
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
const cfg = getRuntimeConfig();
|
|
const targets = resolveSessionStoreTargetsOrExit({
|
|
cfg,
|
|
opts: {
|
|
store: opts.store,
|
|
agent: resolveTailTargetAgent(opts),
|
|
allAgents: opts.allAgents,
|
|
},
|
|
runtime,
|
|
});
|
|
if (!targets) {
|
|
return;
|
|
}
|
|
|
|
const selections: TailSelection[] = [];
|
|
for (const target of targets) {
|
|
for (const { sessionKey, entry } of listSessionEntriesReadOnly({
|
|
agentId: target.agentId,
|
|
storePath: target.storePath,
|
|
})) {
|
|
const selection = buildTailSelection({
|
|
agentId: target.agentId,
|
|
entry,
|
|
key: sessionKey,
|
|
storePath: target.storePath,
|
|
});
|
|
if (selection) {
|
|
selections.push(selection);
|
|
}
|
|
}
|
|
}
|
|
const selected = selectSessionsToTail(selections, opts.sessionKey);
|
|
if (selected.length === 0) {
|
|
const suffix = opts.sessionKey ? ` for ${opts.sessionKey}` : "";
|
|
runtime.log(`No sessions found${suffix}.`);
|
|
return;
|
|
}
|
|
|
|
const followSnapshots = new Map<TailSelection, TrajectorySnapshot>();
|
|
for (const selection of selected) {
|
|
const snapshot = readTailSnapshot(selection, Math.max(tailCount, opts.follow ? 1 : 0));
|
|
followSnapshots.set(selection, snapshot);
|
|
renderEvents(tailCount > 0 ? snapshot.events.slice(-tailCount) : [], runtime);
|
|
}
|
|
|
|
if (opts.follow) {
|
|
await followSelections(selected, runtime, followSnapshots);
|
|
}
|
|
}
|