mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
feat: add rich run timeline backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
|
||||
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
|
||||
export type SessionEventKind =
|
||||
@@ -5,6 +7,7 @@ export type SessionEventKind =
|
||||
| 'message-delta'
|
||||
| 'message-complete'
|
||||
| 'agent-activity'
|
||||
| 'run-updated'
|
||||
| 'error';
|
||||
|
||||
export interface SessionEventRecord {
|
||||
@@ -19,6 +22,9 @@ export interface SessionEventRecord {
|
||||
activityType?: SessionActivityType;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
sourceAgentId?: string;
|
||||
sourceAgentName?: string;
|
||||
toolName?: string;
|
||||
run?: SessionRunRecord;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import { createId } from '@shared/utils/ids';
|
||||
|
||||
export type SessionRunStatus = 'running' | 'completed' | 'error';
|
||||
export type SessionRunWorkspaceKind = 'project' | 'scratchpad';
|
||||
export type RunTimelineEventKind =
|
||||
| 'run-started'
|
||||
| 'thinking'
|
||||
| 'handoff'
|
||||
| 'tool-call'
|
||||
| 'message'
|
||||
| 'run-completed'
|
||||
| 'run-failed';
|
||||
export type RunTimelineEventStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
export interface RunTimelineAgentRecord {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
export interface RunTimelineEventRecord {
|
||||
id: string;
|
||||
kind: RunTimelineEventKind;
|
||||
occurredAt: string;
|
||||
updatedAt?: string;
|
||||
status: RunTimelineEventStatus;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
sourceAgentId?: string;
|
||||
sourceAgentName?: string;
|
||||
targetAgentId?: string;
|
||||
targetAgentName?: string;
|
||||
toolName?: string;
|
||||
messageId?: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionRunRecord {
|
||||
id: string;
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
patternId: string;
|
||||
patternName: string;
|
||||
patternMode: PatternDefinition['mode'];
|
||||
triggerMessageId: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
status: SessionRunStatus;
|
||||
agents: RunTimelineAgentRecord[];
|
||||
events: RunTimelineEventRecord[];
|
||||
}
|
||||
|
||||
export interface CreateSessionRunRecordInput {
|
||||
requestId: string;
|
||||
project: Pick<ProjectRecord, 'id' | 'path'>;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||
triggerMessageId: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface AppendRunActivityEventInput {
|
||||
activityType: 'thinking' | 'tool-calling' | 'handoff';
|
||||
occurredAt: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
sourceAgentId?: string;
|
||||
sourceAgentName?: string;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface UpsertRunMessageEventInput {
|
||||
messageId: string;
|
||||
occurredAt: string;
|
||||
authorName?: string;
|
||||
content?: string;
|
||||
status: RunTimelineEventStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeRunTimelineAgent(
|
||||
agent: RunTimelineAgentRecord,
|
||||
): RunTimelineAgentRecord | undefined {
|
||||
const agentId = normalizeOptionalString(agent.agentId);
|
||||
const agentName = normalizeOptionalString(agent.agentName);
|
||||
const model = normalizeOptionalString(agent.model);
|
||||
if (!agentId || !agentName || !model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
agentName,
|
||||
model,
|
||||
reasoningEffort: agent.reasoningEffort,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRunTimelineEvent(
|
||||
event: RunTimelineEventRecord,
|
||||
): RunTimelineEventRecord | undefined {
|
||||
const id = normalizeOptionalString(event.id);
|
||||
const occurredAt = normalizeOptionalString(event.occurredAt);
|
||||
if (!id || !occurredAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: event.kind,
|
||||
occurredAt,
|
||||
updatedAt: normalizeOptionalString(event.updatedAt),
|
||||
status: event.status,
|
||||
agentId: normalizeOptionalString(event.agentId),
|
||||
agentName: normalizeOptionalString(event.agentName),
|
||||
sourceAgentId: normalizeOptionalString(event.sourceAgentId),
|
||||
sourceAgentName: normalizeOptionalString(event.sourceAgentName),
|
||||
targetAgentId: normalizeOptionalString(event.targetAgentId),
|
||||
targetAgentName: normalizeOptionalString(event.targetAgentName),
|
||||
toolName: normalizeOptionalString(event.toolName),
|
||||
messageId: normalizeOptionalString(event.messageId),
|
||||
content: event.content,
|
||||
error: normalizeOptionalString(event.error),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRunTimelineAgent(
|
||||
run: SessionRunRecord,
|
||||
agentId?: string,
|
||||
agentName?: string,
|
||||
): Pick<RunTimelineEventRecord, 'agentId' | 'agentName'> {
|
||||
const normalizedAgentId = normalizeOptionalString(agentId);
|
||||
const normalizedAgentName = normalizeOptionalString(agentName);
|
||||
|
||||
if (normalizedAgentId) {
|
||||
const matchedAgent = run.agents.find((agent) => agent.agentId === normalizedAgentId);
|
||||
if (matchedAgent) {
|
||||
return {
|
||||
agentId: matchedAgent.agentId,
|
||||
agentName: matchedAgent.agentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedAgentName) {
|
||||
const matchedAgent = run.agents.find((agent) => agent.agentName === normalizedAgentName);
|
||||
if (matchedAgent) {
|
||||
return {
|
||||
agentId: matchedAgent.agentId,
|
||||
agentName: matchedAgent.agentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: normalizedAgentId,
|
||||
agentName: normalizedAgentName,
|
||||
};
|
||||
}
|
||||
|
||||
function appendRunTimelineEvent(
|
||||
run: SessionRunRecord,
|
||||
event: Omit<RunTimelineEventRecord, 'id'> & { id?: string },
|
||||
): SessionRunRecord {
|
||||
const nextEvent = normalizeRunTimelineEvent({
|
||||
id: event.id ?? createId('run-event'),
|
||||
...event,
|
||||
});
|
||||
if (!nextEvent) {
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
events: [...run.events, nextEvent],
|
||||
};
|
||||
}
|
||||
|
||||
function settleOpenMessageEvents(
|
||||
run: SessionRunRecord,
|
||||
status: Extract<RunTimelineEventStatus, 'completed' | 'error'>,
|
||||
occurredAt: string,
|
||||
error?: string,
|
||||
): SessionRunRecord {
|
||||
let changed = false;
|
||||
const normalizedError = normalizeOptionalString(error);
|
||||
const nextEvents = run.events.map((event) => {
|
||||
if (event.kind !== 'message' || event.status !== 'running') {
|
||||
return event;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return {
|
||||
...event,
|
||||
status,
|
||||
updatedAt: occurredAt,
|
||||
error: normalizedError,
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
events: nextEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionRunRecord(input: CreateSessionRunRecordInput): SessionRunRecord {
|
||||
return {
|
||||
id: createId('run'),
|
||||
requestId: input.requestId,
|
||||
projectId: input.project.id,
|
||||
projectPath: input.project.path,
|
||||
workspaceKind: input.workspaceKind,
|
||||
patternId: input.pattern.id,
|
||||
patternName: input.pattern.name,
|
||||
patternMode: input.pattern.mode,
|
||||
triggerMessageId: input.triggerMessageId,
|
||||
startedAt: input.startedAt,
|
||||
status: 'running',
|
||||
completedAt: undefined,
|
||||
agents: input.pattern.agents
|
||||
.map((agent): RunTimelineAgentRecord => ({
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
model: agent.model,
|
||||
reasoningEffort: agent.reasoningEffort,
|
||||
}))
|
||||
.flatMap((agent) => {
|
||||
const normalized = normalizeRunTimelineAgent(agent);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
events: [
|
||||
{
|
||||
id: createId('run-event'),
|
||||
kind: 'run-started',
|
||||
occurredAt: input.startedAt,
|
||||
status: 'completed',
|
||||
messageId: input.triggerMessageId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSessionRunRecords(
|
||||
runs: readonly SessionRunRecord[] | undefined,
|
||||
): SessionRunRecord[] {
|
||||
if (!runs || runs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return runs.flatMap((run) => {
|
||||
const id = normalizeOptionalString(run.id);
|
||||
const requestId = normalizeOptionalString(run.requestId);
|
||||
const projectId = normalizeOptionalString(run.projectId);
|
||||
const projectPath = normalizeOptionalString(run.projectPath);
|
||||
const patternId = normalizeOptionalString(run.patternId);
|
||||
const patternName = normalizeOptionalString(run.patternName);
|
||||
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
||||
const startedAt = normalizeOptionalString(run.startedAt);
|
||||
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
requestId,
|
||||
projectId,
|
||||
projectPath,
|
||||
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
|
||||
patternId,
|
||||
patternName,
|
||||
patternMode: run.patternMode,
|
||||
triggerMessageId,
|
||||
startedAt,
|
||||
completedAt: normalizeOptionalString(run.completedAt),
|
||||
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : 'completed',
|
||||
agents: run.agents.flatMap((agent) => {
|
||||
const normalized = normalizeRunTimelineAgent(agent);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
events: run.events.flatMap((event) => {
|
||||
const normalized = normalizeRunTimelineEvent(event);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertSessionRunRecord(
|
||||
runs: readonly SessionRunRecord[],
|
||||
nextRun: SessionRunRecord,
|
||||
): SessionRunRecord[] {
|
||||
const runIndex = runs.findIndex((run) => run.id === nextRun.id);
|
||||
if (runIndex < 0) {
|
||||
return [nextRun, ...runs];
|
||||
}
|
||||
|
||||
const nextRuns = runs.slice();
|
||||
nextRuns[runIndex] = nextRun;
|
||||
return nextRuns;
|
||||
}
|
||||
|
||||
export function appendRunActivityEvent(
|
||||
run: SessionRunRecord,
|
||||
input: AppendRunActivityEventInput,
|
||||
): SessionRunRecord {
|
||||
switch (input.activityType) {
|
||||
case 'thinking': {
|
||||
const agent = resolveRunTimelineAgent(run, input.agentId, input.agentName);
|
||||
return appendRunTimelineEvent(run, {
|
||||
kind: 'thinking',
|
||||
occurredAt: input.occurredAt,
|
||||
status: 'completed',
|
||||
...agent,
|
||||
});
|
||||
}
|
||||
case 'tool-calling': {
|
||||
const agent = resolveRunTimelineAgent(run, input.agentId, input.agentName);
|
||||
return appendRunTimelineEvent(run, {
|
||||
kind: 'tool-call',
|
||||
occurredAt: input.occurredAt,
|
||||
status: 'completed',
|
||||
...agent,
|
||||
toolName: normalizeOptionalString(input.toolName),
|
||||
});
|
||||
}
|
||||
case 'handoff': {
|
||||
const sourceAgent = resolveRunTimelineAgent(run, input.sourceAgentId, input.sourceAgentName);
|
||||
const targetAgent = resolveRunTimelineAgent(run, input.agentId, input.agentName);
|
||||
return appendRunTimelineEvent(run, {
|
||||
kind: 'handoff',
|
||||
occurredAt: input.occurredAt,
|
||||
status: 'completed',
|
||||
agentId: sourceAgent.agentId,
|
||||
agentName: sourceAgent.agentName,
|
||||
sourceAgentId: sourceAgent.agentId,
|
||||
sourceAgentName: sourceAgent.agentName,
|
||||
targetAgentId: targetAgent.agentId,
|
||||
targetAgentName: targetAgent.agentName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertRunMessageEvent(
|
||||
run: SessionRunRecord,
|
||||
input: UpsertRunMessageEventInput,
|
||||
): SessionRunRecord {
|
||||
const messageId = normalizeOptionalString(input.messageId);
|
||||
if (!messageId) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const author = resolveRunTimelineAgent(run, undefined, input.authorName);
|
||||
const existingIndex = run.events.findIndex((event) => event.kind === 'message' && event.messageId === messageId);
|
||||
const normalizedError = normalizeOptionalString(input.error);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return appendRunTimelineEvent(run, {
|
||||
kind: 'message',
|
||||
occurredAt: input.occurredAt,
|
||||
updatedAt: input.occurredAt,
|
||||
status: input.status,
|
||||
messageId,
|
||||
content: input.content,
|
||||
error: normalizedError,
|
||||
...author,
|
||||
});
|
||||
}
|
||||
|
||||
const existingEvent = run.events[existingIndex];
|
||||
const nextEvent: RunTimelineEventRecord = {
|
||||
...existingEvent,
|
||||
agentId: existingEvent.agentId ?? author.agentId,
|
||||
agentName: existingEvent.agentName ?? author.agentName,
|
||||
updatedAt: input.occurredAt,
|
||||
status: input.status,
|
||||
content: input.content ?? existingEvent.content,
|
||||
error: normalizedError,
|
||||
};
|
||||
|
||||
if (
|
||||
nextEvent.agentId === existingEvent.agentId
|
||||
&& nextEvent.agentName === existingEvent.agentName
|
||||
&& nextEvent.updatedAt === existingEvent.updatedAt
|
||||
&& nextEvent.status === existingEvent.status
|
||||
&& nextEvent.content === existingEvent.content
|
||||
&& nextEvent.error === existingEvent.error
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextEvents = run.events.slice();
|
||||
nextEvents[existingIndex] = nextEvent;
|
||||
return {
|
||||
...run,
|
||||
events: nextEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export function completeSessionRunRecord(
|
||||
run: SessionRunRecord,
|
||||
completedAt: string,
|
||||
): SessionRunRecord {
|
||||
const settledRun = settleOpenMessageEvents(run, 'completed', completedAt);
|
||||
const completedRun: SessionRunRecord = {
|
||||
...settledRun,
|
||||
status: 'completed',
|
||||
completedAt,
|
||||
};
|
||||
|
||||
return appendRunTimelineEvent(completedRun, {
|
||||
kind: 'run-completed',
|
||||
occurredAt: completedAt,
|
||||
status: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
export function failSessionRunRecord(
|
||||
run: SessionRunRecord,
|
||||
failedAt: string,
|
||||
error: string,
|
||||
): SessionRunRecord {
|
||||
const settledRun = settleOpenMessageEvents(run, 'error', failedAt, error);
|
||||
const failedRun: SessionRunRecord = {
|
||||
...settledRun,
|
||||
status: 'error',
|
||||
completedAt: failedAt,
|
||||
};
|
||||
|
||||
return appendRunTimelineEvent(failedRun, {
|
||||
kind: 'run-failed',
|
||||
occurredAt: failedAt,
|
||||
status: 'error',
|
||||
error,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeSessionToolingSelection,
|
||||
type SessionToolingSelection,
|
||||
} from '@shared/domain/tooling';
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||
@@ -38,6 +39,7 @@ export interface SessionRecord {
|
||||
lastError?: string;
|
||||
scratchpadConfig?: ScratchpadSessionConfig;
|
||||
tooling?: SessionToolingSelection;
|
||||
runs: SessionRunRecord[];
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(
|
||||
|
||||
@@ -181,6 +181,7 @@ export function duplicateSessionRecord(
|
||||
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
||||
}
|
||||
: undefined,
|
||||
runs: [],
|
||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||
...message,
|
||||
pending: false,
|
||||
|
||||
Reference in New Issue
Block a user