Files
kandev/apps/web/lib/workflows/replay-cycle-analysis.ts
T
2026-07-17 18:47:27 +01:00

504 lines
16 KiB
TypeScript

import { stepHasOnEnterAction, type WorkflowStep } from "@/lib/types/http";
import type {
OnTurnCompleteAction,
OnTurnCompleteActionType,
OnTurnStartAction,
OnTurnStartActionType,
} from "@/lib/types/workflow-actions";
export type WorkflowReplayCycleSeverity = "blocking" | "warning";
export type WorkflowReplayCycleTrigger = "on_turn_start" | "on_turn_complete";
export type WorkflowReplayCycleActionKind = Extract<
OnTurnStartActionType | OnTurnCompleteActionType,
"move_to_next" | "move_to_previous" | "move_to_step"
>;
export type WorkflowReplayPromptSource =
| "task_description"
| "step_prompt_with_task_description"
| "step_prompt";
export type WorkflowReplayCycleHop = {
sourceStepId: string;
sourceStepName: string;
trigger: WorkflowReplayCycleTrigger;
actionKind: WorkflowReplayCycleActionKind;
destinationStepId: string;
destinationStepName: string;
requiresUserInvolvement: boolean;
};
export type WorkflowReplayCycleDiagnostic = {
identity: string;
severity: WorkflowReplayCycleSeverity;
autoStartStepId: string;
autoStartStepName: string;
affectedStepIds: string[];
trace: WorkflowReplayCycleHop[];
promptSource: WorkflowReplayPromptSource;
};
type TransitionAction = OnTurnStartAction | OnTurnCompleteAction;
type ReplayGraphEdge = {
source: WorkflowStep;
destination: WorkflowStep;
trigger: WorkflowReplayCycleTrigger;
actionKind: WorkflowReplayCycleActionKind;
requiresUserInvolvement: boolean;
};
type StepResolutionContext = {
orderedSteps: WorkflowStep[];
stepsById: Map<string, WorkflowStep>;
};
type CycleCandidate = {
identity: string;
severity: WorkflowReplayCycleSeverity;
trace: WorkflowReplayCycleHop[];
};
type ReplayAnalysisContext = {
orderedSteps: WorkflowStep[];
graph: Map<string, ReplayGraphEdge[]>;
};
type CycleInventory = {
candidates: CycleCandidate[];
truncated: boolean;
};
const MAX_CYCLE_INVENTORY_SIZE = 256;
const MAX_CYCLE_INVENTORY_STATES = 4096;
export function analyzeWorkflowReplayCycles(
steps: WorkflowStep[],
): WorkflowReplayCycleDiagnostic[] {
const { orderedSteps, graph } = buildAnalysisContext(steps);
return orderedSteps.flatMap((autoStartStep) => {
if (!stepHasOnEnterAction(autoStartStep, "auto_start_agent")) return [];
const selected = findPreferredCycleCandidate(autoStartStep, graph);
return selected ? [toDiagnostic(autoStartStep, selected)] : [];
});
}
export function analyzeIntroducedWorkflowReplayCycles(
baselineSteps: WorkflowStep[],
proposedSteps: WorkflowStep[],
): WorkflowReplayCycleDiagnostic[] {
const baseline = buildAnalysisContext(baselineSteps);
const existingIdentities = new Set(
baseline.orderedSteps.flatMap((step) =>
stepHasOnEnterAction(step, "auto_start_agent")
? collectCycleInventory(step, baseline.graph).candidates.map(
(candidate) => candidate.identity,
)
: [],
),
);
const proposed = buildAnalysisContext(proposedSteps);
return proposed.orderedSteps.flatMap((autoStartStep) => {
if (!stepHasOnEnterAction(autoStartStep, "auto_start_agent")) return [];
const inventory = collectCycleInventory(autoStartStep, proposed.graph);
const selected = inventory.candidates
.filter((candidate) => !existingIdentities.has(candidate.identity))
.sort(compareCandidates)[0];
const fallback = inventory.truncated ? inventory.candidates[0] : undefined;
const diagnosticCandidate = selected ?? fallback;
return diagnosticCandidate ? [toDiagnostic(autoStartStep, diagnosticCandidate)] : [];
});
}
function buildAnalysisContext(steps: WorkflowStep[]): ReplayAnalysisContext {
const orderedSteps = steps
.map((step, inputIndex) => ({ step, inputIndex }))
.sort(
(left, right) =>
left.step.position - right.step.position || left.inputIndex - right.inputIndex,
)
.map(({ step }) => step);
return { orderedSteps, graph: buildReplayGraph(orderedSteps) };
}
function toDiagnostic(
autoStartStep: WorkflowStep,
candidate: CycleCandidate,
): WorkflowReplayCycleDiagnostic {
return {
identity: candidate.identity,
severity: candidate.severity,
autoStartStepId: autoStartStep.id,
autoStartStepName: autoStartStep.name,
affectedStepIds: affectedStepIds(autoStartStep.id, candidate.trace),
trace: candidate.trace,
promptSource: classifyPromptSource(autoStartStep.prompt),
};
}
function buildReplayGraph(steps: WorkflowStep[]): Map<string, ReplayGraphEdge[]> {
const graph = new Map<string, ReplayGraphEdge[]>();
const stepsById = new Map(steps.map((step) => [step.id, step]));
const resolution = { orderedSteps: steps, stepsById };
steps.forEach((source, sourceIndex) => {
const edges = [
...resolveTriggerEdges(
source,
sourceIndex,
"on_turn_start",
source.events?.on_turn_start ?? [],
resolution,
),
...resolveTriggerEdges(
source,
sourceIndex,
"on_turn_complete",
source.events?.on_turn_complete ?? [],
resolution,
),
].sort(compareGraphEdges);
graph.set(source.id, edges);
});
return graph;
}
function resolveTriggerEdges(
source: WorkflowStep,
sourceIndex: number,
trigger: WorkflowReplayCycleTrigger,
actions: TransitionAction[],
resolution: StepResolutionContext,
): ReplayGraphEdge[] {
const edges: ReplayGraphEdge[] = [];
for (const action of actions) {
if (!isMoveAction(action)) continue;
const destination = resolveDestination(action, sourceIndex, resolution);
if (destination) {
edges.push({
source,
destination,
trigger,
actionKind: action.type,
requiresUserInvolvement:
!stepHasOnEnterAction(source, "auto_start_agent") ||
action.config?.requires_approval === true,
});
}
if (isAlwaysEligibleTransition(action)) break;
}
return edges;
}
function isMoveAction(
action: TransitionAction,
): action is Extract<TransitionAction, { type: WorkflowReplayCycleActionKind }> {
return ["move_to_next", "move_to_previous", "move_to_step"].includes(action.type);
}
function isAlwaysEligibleTransition(
action: Extract<TransitionAction, { type: WorkflowReplayCycleActionKind }>,
): boolean {
return action.config?.requires_approval !== true && !hasValidTransitionGuard(action.config);
}
function hasValidTransitionGuard(
config: Extract<TransitionAction, { type: WorkflowReplayCycleActionKind }>["config"],
): boolean {
const legacyConfig = config as
| (typeof config & {
wait_for_quorum?: { role?: unknown; threshold?: unknown };
})
| undefined;
const quorum = legacyConfig?.wait_for_quorum ?? config?.if?.wait_for_quorum;
return (
typeof quorum?.role === "string" &&
quorum.role !== "" &&
typeof quorum.threshold === "string" &&
quorum.threshold !== ""
);
}
function resolveDestination(
action: Extract<TransitionAction, { type: WorkflowReplayCycleActionKind }>,
sourceIndex: number,
resolution: StepResolutionContext,
): WorkflowStep | undefined {
if (action.type === "move_to_next") return resolution.orderedSteps[sourceIndex + 1];
if (action.type === "move_to_previous") return resolution.orderedSteps[sourceIndex - 1];
return resolution.stepsById.get(action.config?.step_id ?? "");
}
type CycleSearchState = {
stepId: string;
hasUserInvolvement: boolean;
trace: WorkflowReplayCycleHop[];
};
type CycleSearchExpansion =
| { kind: "closing"; candidate: CycleCandidate }
| { kind: "next"; state: CycleSearchState };
function findPreferredCycleCandidate(
autoStartStep: WorkflowStep,
graph: Map<string, ReplayGraphEdge[]>,
): CycleCandidate | undefined {
return (
findShortestCycleCandidate(autoStartStep, graph, "blocking") ??
findShortestCycleCandidate(autoStartStep, graph, "warning")
);
}
type CycleInventorySearchState = {
stepId: string;
trace: WorkflowReplayCycleHop[];
visitedStepIds: Set<string>;
};
function collectCycleInventory(
autoStartStep: WorkflowStep,
graph: Map<string, ReplayGraphEdge[]>,
): CycleInventory {
const candidates = new Map<string, CycleCandidate>();
let truncated = false;
const preferred = findPreferredCycleCandidate(autoStartStep, graph);
if (preferred) candidates.set(preferred.identity, preferred);
const queue: CycleInventorySearchState[] = [
{
stepId: autoStartStep.id,
trace: [],
visitedStepIds: new Set([autoStartStep.id]),
},
];
for (
let index = 0;
index < queue.length &&
index < MAX_CYCLE_INVENTORY_STATES &&
candidates.size < MAX_CYCLE_INVENTORY_SIZE;
index += 1
) {
const state = queue[index];
for (const edge of graph.get(state.stepId) ?? []) {
truncated =
expandCycleInventoryEdge(autoStartStep.id, state, edge, queue, candidates) || truncated;
if (candidates.size >= MAX_CYCLE_INVENTORY_SIZE) break;
}
}
return { candidates: [...candidates.values()].sort(compareCandidates), truncated };
}
function expandCycleInventoryEdge(
autoStartStepId: string,
state: CycleInventorySearchState,
edge: ReplayGraphEdge,
queue: CycleInventorySearchState[],
candidates: Map<string, CycleCandidate>,
): boolean {
const trace = [...state.trace, toTraceHop(edge)];
if (edge.destination.id === autoStartStepId) {
if (edge.trigger !== "on_turn_complete") return false;
const candidate = toCandidate(autoStartStepId, trace);
candidates.set(candidate.identity, candidate);
return candidates.size >= MAX_CYCLE_INVENTORY_SIZE;
}
if (state.visitedStepIds.has(edge.destination.id)) return false;
if (queue.length >= MAX_CYCLE_INVENTORY_STATES) return true;
queue.push({
stepId: edge.destination.id,
trace,
visitedStepIds: new Set([...state.visitedStepIds, edge.destination.id]),
});
return false;
}
function findShortestCycleCandidate(
autoStartStep: WorkflowStep,
graph: Map<string, ReplayGraphEdge[]>,
severity: WorkflowReplayCycleSeverity,
): CycleCandidate | undefined {
let frontier: CycleSearchState[] = [
{ stepId: autoStartStep.id, hasUserInvolvement: false, trace: [] },
];
const visitedStates = new Set([cycleSearchStateKey(autoStartStep.id, false)]);
while (frontier.length > 0) {
const { closingCandidates, nextFrontier } = expandCycleSearchLevel(
autoStartStep.id,
graph,
severity,
frontier,
visitedStates,
);
if (closingCandidates.length > 0) {
return closingCandidates.sort(compareCandidates)[0];
}
frontier = [...nextFrontier.values()].sort((left, right) =>
compareTraceIdentity(autoStartStep.id, left.trace, right.trace),
);
for (const state of frontier) {
visitedStates.add(cycleSearchStateKey(state.stepId, state.hasUserInvolvement));
}
}
return undefined;
}
function expandCycleSearchLevel(
autoStartStepId: string,
graph: Map<string, ReplayGraphEdge[]>,
severity: WorkflowReplayCycleSeverity,
frontier: CycleSearchState[],
visitedStates: Set<string>,
): {
closingCandidates: CycleCandidate[];
nextFrontier: Map<string, CycleSearchState>;
} {
const closingCandidates: CycleCandidate[] = [];
const nextFrontier = new Map<string, CycleSearchState>();
for (const state of frontier) {
for (const edge of graph.get(state.stepId) ?? []) {
const expansion = expandCycleSearchEdge(autoStartStepId, severity, state, edge);
if (!expansion) continue;
if (expansion.kind === "closing") {
closingCandidates.push(expansion.candidate);
continue;
}
retainPreferredSearchState(autoStartStepId, expansion.state, visitedStates, nextFrontier);
}
}
return { closingCandidates, nextFrontier };
}
function expandCycleSearchEdge(
autoStartStepId: string,
severity: WorkflowReplayCycleSeverity,
state: CycleSearchState,
edge: ReplayGraphEdge,
): CycleSearchExpansion | undefined {
const hasUserInvolvement = state.hasUserInvolvement || edge.requiresUserInvolvement;
if (severity === "blocking" && hasUserInvolvement) return undefined;
const trace = [...state.trace, toTraceHop(edge)];
if (edge.destination.id !== autoStartStepId) {
return {
kind: "next",
state: { stepId: edge.destination.id, hasUserInvolvement, trace },
};
}
if (edge.trigger !== "on_turn_complete") return undefined;
if ((severity === "warning") !== hasUserInvolvement) return undefined;
return { kind: "closing", candidate: toCandidate(autoStartStepId, trace) };
}
function retainPreferredSearchState(
autoStartStepId: string,
nextState: CycleSearchState,
visitedStates: Set<string>,
nextFrontier: Map<string, CycleSearchState>,
): void {
const stateKey = cycleSearchStateKey(nextState.stepId, nextState.hasUserInvolvement);
if (visitedStates.has(stateKey)) return;
const currentState = nextFrontier.get(stateKey);
if (
!currentState ||
compareTraceIdentity(autoStartStepId, nextState.trace, currentState.trace) < 0
) {
nextFrontier.set(stateKey, nextState);
}
}
function toTraceHop(edge: ReplayGraphEdge): WorkflowReplayCycleHop {
return {
sourceStepId: edge.source.id,
sourceStepName: edge.source.name,
trigger: edge.trigger,
actionKind: edge.actionKind,
destinationStepId: edge.destination.id,
destinationStepName: edge.destination.name,
requiresUserInvolvement: edge.requiresUserInvolvement,
};
}
function toCandidate(autoStartStepId: string, trace: WorkflowReplayCycleHop[]): CycleCandidate {
const severity = trace.some((hop) => hop.requiresUserInvolvement) ? "warning" : "blocking";
return {
identity: traceIdentity(autoStartStepId, trace),
severity,
trace,
};
}
function traceIdentity(autoStartStepId: string, trace: WorkflowReplayCycleHop[]): string {
const identityParts = trace.map((hop) => [
hop.sourceStepId,
hop.trigger,
hop.actionKind,
hop.destinationStepId,
hop.requiresUserInvolvement,
]);
return `workflow-replay-cycle:${JSON.stringify([autoStartStepId, identityParts])}`;
}
function compareTraceIdentity(
autoStartStepId: string,
left: WorkflowReplayCycleHop[],
right: WorkflowReplayCycleHop[],
): number {
return compareLexical(
traceIdentity(autoStartStepId, left),
traceIdentity(autoStartStepId, right),
);
}
function cycleSearchStateKey(stepId: string, hasUserInvolvement: boolean): string {
return JSON.stringify([stepId, hasUserInvolvement]);
}
function compareGraphEdges(left: ReplayGraphEdge, right: ReplayGraphEdge): number {
return compareLexical(graphEdgeIdentity(left), graphEdgeIdentity(right));
}
function graphEdgeIdentity(edge: ReplayGraphEdge): string {
return JSON.stringify([
edge.source.id,
edge.trigger,
edge.actionKind,
edge.destination.id,
edge.requiresUserInvolvement,
]);
}
function compareLexical(left: string, right: string): number {
if (left === right) return 0;
return left < right ? -1 : 1;
}
function compareCandidates(left: CycleCandidate, right: CycleCandidate): number {
if (left.severity !== right.severity) return left.severity === "blocking" ? -1 : 1;
const lengthDifference = left.trace.length - right.trace.length;
if (lengthDifference !== 0) return lengthDifference;
if (left.identity === right.identity) return 0;
return left.identity < right.identity ? -1 : 1;
}
function affectedStepIds(autoStartStepId: string, trace: WorkflowReplayCycleHop[]): string[] {
const affected = new Set([autoStartStepId]);
trace.forEach((hop) => affected.add(hop.destinationStepId));
return [...affected];
}
function classifyPromptSource(prompt: string | undefined): WorkflowReplayPromptSource {
if (!prompt) return "task_description";
return prompt.includes("{{task_prompt}}") ? "step_prompt_with_task_description" : "step_prompt";
}