Files
openclaw/src/commands/sessions-tail.ts
T
Peter Steinberger 4273ca9dbd refactor(sessions): remove file-era transcript runtime (#113233)
* 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
2026-07-27 22:33:24 -04:00

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);
}
}