feat: add step-by-step visual replay and harden process lifecycle

- UI: Implement dual-mode playback in FloatingVideoPlayer supporting video and step-by-step frame replay with scrub controls and auto-fallback
- Runtime: Prevent asyncio event loop hangs on Darwin and Linux by avoiding psutil.wait_procs on direct child processes
- Task Queue: Add worker exit watchdog and automatic recording recovery for completed or interrupted sessions
- Recording: Add ffmpeg ultrafast transcode fallback, shield recording finalization against cancellation, and handle on-the-fly MP4 conversion
- Docs: Polish README_CN styling and streamline flash runner agent prompt
This commit is contained in:
somew1nd committed 2026-08-26 00:53:24 -07:00
1 parent 06643e955b
commit a36a66ce6d
26 files changed
+1152 -117

No files matched your search

@@ -1,6 +1,7 @@
import {
getStepPreImageUrl,
getStepPostImageUrl
getStepPostImageUrl,
extractStepReplayFrames
} from './action-formatter.util';
describe('action-formatter.util screenshot chaining', () => {
@@ -109,4 +110,100 @@ describe('action-formatter.util screenshot chaining', () => {
expect(preUrl).toBe('/images/pre123');
expect(postUrl).toBe('/images/post456');
});
it('should resolve post screenshot for primary action when generic_tools contains action traces', () => {
const stepData = {
step_id: 'step-02',
pre_image_name: 'pre_screen_hash',
post_image_name: 'post_screen_hash',
action_taken: {
action: 'click',
coordinates: [500, 928],
args: { target: [500, 928] },
timestamp: 1000
},
generic_tools: [
{
trace_id: 'trace-click-internal',
type: 'action',
name: 'click',
timestamp: 1001,
payload: {
args: { target: '[500, 928]', times: '1', delay_ms: '100' },
result: {
outcome: 'Clicked successfully.',
post_image_name: 'post_screen_hash',
status: 'success'
}
}
}
]
};
const preUrl = getStepPreImageUrl(stepData, stepData.action_taken);
const postUrl = getStepPostImageUrl(stepData, stepData.action_taken);
expect(preUrl).toBe('/images/pre_screen_hash');
expect(postUrl).toBe('/images/post_screen_hash');
});
});
describe('extractStepReplayFrames', () => {
it('should return an empty array for empty or invalid logs', () => {
expect(extractStepReplayFrames([])).toEqual([]);
expect(extractStepReplayFrames(null as any)).toEqual([]);
});
it('should extract ordered StepReplayFrames and deduplicate consecutive identical images', () => {
const logs = [
{
type: 'step_updated',
data: {
step_id: 's-1',
step_number: 1,
pre_image_name: 'img_screen_1',
post_image_name: 'img_screen_2',
action_taken: { action: 'tap', coordinates: [500, 1000] },
timestamp: 100
}
},
{
type: 'step_updated',
data: {
step_id: 's-2',
step_number: 2,
pre_image_name: 'img_screen_2',
post_image_name: 'img_screen_3',
action_taken: { action: 'input_text', text: 'hello' },
timestamp: 200
}
}
];
const frames = extractStepReplayFrames(logs);
expect(frames.length).toBe(3);
expect(frames[0].title).toBe('Step 1 (Before Action)');
expect(frames[0].imageUrl).toBe('/images/img_screen_1');
expect(frames[1].title).toContain('Step 1');
expect(frames[1].imageUrl).toBe('/images/img_screen_2');
expect(frames[2].title).toContain('Step 2');
expect(frames[2].imageUrl).toBe('/images/img_screen_3');
});
it('should handle terminal step with only pre_image (e.g. cancelled / failed task)', () => {
const logs = [
{
step_number: 1,
pre_image_name: 'img_start',
post_image_name: null,
action_taken: null,
timestamp: 100
}
];
const frames = extractStepReplayFrames(logs);
expect(frames.length).toBe(1);
expect(frames[0].imageUrl).toBe('/images/img_start');
expect(frames[0].stepNumber).toBe(1);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ActionParam } from '../core/models/stream.model';
import { ActionParam, StepReplayFrame } from '../core/models/stream.model';
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
import { cleanErrorMessage } from './tool-formatter.util';
@@ -698,7 +698,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
if (explicitPost) {
resolvedPost = explicitPost;
hasExplicitPost = true;
} else if (e.isPrimary && !hadPrimaryFailure && !hasSubsequentActions) {
} else if (e.isPrimary && !hadPrimaryFailure) {
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
} else if (e.item === lastActionItem && !hadPrimaryFailure) {
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
@@ -765,3 +765,86 @@ export function getStepPostImageUrl(stepData: any, actionData?: any): string | n
}
return postUrl;
}
/**
* Extract an ordered array of visual StepReplayFrames from session logs or step blocks
*/
export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
if (!Array.isArray(logsOrSteps) || logsOrSteps.length === 0) {
return [];
}
// Extract raw step data objects
const rawSteps: any[] = [];
for (const item of logsOrSteps) {
if (!item) continue;
if (item.type === 'step_updated' && item.data) {
rawSteps.push(item.data);
} else if (item.type === 'step' && item.data) {
rawSteps.push(item.data);
} else if (item.step_number !== undefined || item.step_id !== undefined) {
rawSteps.push(item);
}
}
// Deduplicate by step_id or step_number, keeping latest
const stepMap = new Map<any, any>();
for (const step of rawSteps) {
const key = step.step_id || step.step_number;
if (key !== undefined) {
stepMap.set(key, step);
}
}
const steps = Array.from(stepMap.values()).sort((a, b) => {
const numA = Number(a.step_number ?? 0);
const numB = Number(b.step_number ?? 0);
if (numA !== numB) return numA - numB;
return Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0);
});
const frames: StepReplayFrame[] = [];
let lastImageUrl: string | null = null;
for (const stepData of steps) {
const stepNum = Number(stepData.step_number ?? (frames.length + 1));
const act = stepData.action_taken;
const title = act ? (getActionTitle(act) || 'Action') : 'Step';
const coords = act ? getActionCoords(act) : '';
const actionDesc = coords ? `${title} (${coords})` : title;
const preUrl = getStepPreImageUrl(stepData, act);
const postUrl = getStepPostImageUrl(stepData, act);
// If pre-image exists and is new, add it
if (preUrl && preUrl !== lastImageUrl) {
frames.push({
index: frames.length,
stepNumber: stepNum,
title: `Step ${stepNum} (Before Action)`,
imageUrl: preUrl,
actionText: `Preparing: ${actionDesc}`,
isPost: false,
timestamp: stepData.timestamp
});
lastImageUrl = preUrl;
}
// If post-image exists and is different from current screen, add it
if (postUrl && postUrl !== lastImageUrl) {
frames.push({
index: frames.length,
stepNumber: stepNum,
title: `Step ${stepNum}: ${actionDesc}`,
imageUrl: postUrl,
actionText: actionDesc,
isPost: true,
timestamp: stepData.timestamp
});
lastImageUrl = postUrl;
}
}
return frames;
}