android-midscene-automation 0.2.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +35 -0
- package/artemis-main/.dockerignore +15 -0
- package/artemis-main/.env.example +34 -0
- package/artemis-main/CONTRIBUTING.md +35 -0
- package/artemis-main/Dockerfile +68 -0
- package/artemis-main/LICENSE +202 -0
- package/artemis-main/Makefile +115 -0
- package/artemis-main/README.md +322 -0
- package/artemis-main/README_CN.md +320 -0
- package/artemis-main/apps/__init__.py +15 -0
- package/artemis-main/apps/admin_console/__init__.py +19 -0
- package/artemis-main/apps/admin_console/core/__init__.py +13 -0
- package/artemis-main/apps/admin_console/core/config.py +43 -0
- package/artemis-main/apps/admin_console/core/security.py +178 -0
- package/artemis-main/apps/admin_console/core/state.py +243 -0
- package/artemis-main/apps/admin_console/database/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/connection.py +60 -0
- package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
- package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
- package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
- package/artemis-main/apps/admin_console/index.html +3787 -0
- package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
- package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
- package/artemis-main/apps/admin_console/routers/media.py +198 -0
- package/artemis-main/apps/admin_console/routers/replay.py +91 -0
- package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
- package/artemis-main/apps/admin_console/routers/steps.py +109 -0
- package/artemis-main/apps/admin_console/routers/stream.py +56 -0
- package/artemis-main/apps/admin_console/routers/system.py +761 -0
- package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
- package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
- package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
- package/artemis-main/apps/admin_console/server.py +508 -0
- package/artemis-main/apps/admin_console/services/__init__.py +13 -0
- package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
- package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
- package/artemis-main/apps/admin_console/services/media_service.py +623 -0
- package/artemis-main/apps/admin_console/services/model_service.py +148 -0
- package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
- package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
- package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
- package/artemis-main/apps/showcase_ui/README.md +31 -0
- package/artemis-main/apps/showcase_ui/angular.json +132 -0
- package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
- package/artemis-main/apps/showcase_ui/package.json +39 -0
- package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
- package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
- package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
- package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
- package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
- package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
- package/artemis-main/apps/showcase_ui/src/index.html +37 -0
- package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
- package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
- package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
- package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
- package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
- package/artemis-main/artemis/__init__.py +44 -0
- package/artemis-main/artemis/__main__.py +18 -0
- package/artemis-main/artemis/_legacy_imports.py +94 -0
- package/artemis-main/artemis/_version.py +30 -0
- package/artemis-main/artemis/agents/__init__.py +17 -0
- package/artemis-main/artemis/agents/checker/checker.json +9 -0
- package/artemis-main/artemis/agents/checker/checker.py +859 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
- package/artemis-main/artemis/agents/explorer/constants.py +52 -0
- package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
- package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
- package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
- package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
- package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
- package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
- package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
- package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
- package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
- package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
- package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
- package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
- package/artemis-main/artemis/agents/flash/runner.py +1167 -0
- package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
- package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
- package/artemis-main/artemis/agents/operator/operator.json +3 -0
- package/artemis-main/artemis/agents/operator/operator.py +1394 -0
- package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
- package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
- package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
- package/artemis-main/artemis/agents/planner/planner.json +32 -0
- package/artemis-main/artemis/agents/planner/planner.py +436 -0
- package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
- package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
- package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
- package/artemis-main/artemis/agents/validator/categories.py +33 -0
- package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
- package/artemis-main/artemis/agents/validator/incidents.py +120 -0
- package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
- package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
- package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
- package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
- package/artemis-main/artemis/agents/validator/validator.py +188 -0
- package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
- package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
- package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
- package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
- package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
- package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
- package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
- package/artemis-main/artemis/clients/accessibility_client.py +438 -0
- package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
- package/artemis-main/artemis/config/__init__.py +309 -0
- package/artemis-main/artemis/config/agent.py +1098 -0
- package/artemis-main/artemis/config/constants.py +151 -0
- package/artemis-main/artemis/config/core/__init__.py +24 -0
- package/artemis-main/artemis/config/core/engine.py +118 -0
- package/artemis-main/artemis/config/core/interpolator.py +78 -0
- package/artemis-main/artemis/config/llm.py +267 -0
- package/artemis-main/artemis/config/paths.py +244 -0
- package/artemis-main/artemis/config/runtime.py +197 -0
- package/artemis-main/artemis/config/settings.py +315 -0
- package/artemis-main/artemis/constants.py +47 -0
- package/artemis-main/artemis/context.py +289 -0
- package/artemis-main/artemis/controllers/__init__.py +41 -0
- package/artemis-main/artemis/controllers/controller_factory.py +41 -0
- package/artemis-main/artemis/controllers/device_controller.py +29 -0
- package/artemis-main/artemis/controllers/types.py +37 -0
- package/artemis-main/artemis/controllers/unified_controller.py +649 -0
- package/artemis-main/artemis/core/__init__.py +21 -0
- package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
- package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
- package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
- package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
- package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
- package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
- package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
- package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
- package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
- package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
- package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
- package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
- package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
- package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
- package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
- package/artemis-main/artemis/core/tool_declaration.py +43 -0
- package/artemis-main/artemis/core/tool_failure.py +56 -0
- package/artemis-main/artemis/data_engine/context_vars.py +24 -0
- package/artemis-main/artemis/data_engine/engine.py +1615 -0
- package/artemis-main/artemis/data_engine/history_reader.py +142 -0
- package/artemis-main/artemis/data_engine/models.py +131 -0
- package/artemis-main/artemis/data_engine/storage.py +1245 -0
- package/artemis-main/artemis/data_engine/trace.py +727 -0
- package/artemis-main/artemis/drivers/__init__.py +48 -0
- package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
- package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
- package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
- package/artemis-main/artemis/drivers/android/recorder.py +57 -0
- package/artemis-main/artemis/drivers/base.py +290 -0
- package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
- package/artemis-main/artemis/drivers/factory.py +80 -0
- package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
- package/artemis-main/artemis/drivers/types.py +67 -0
- package/artemis-main/artemis/graph/checkpoints.py +1243 -0
- package/artemis-main/artemis/graph/graph.py +1055 -0
- package/artemis-main/artemis/graph/perception.py +234 -0
- package/artemis-main/artemis/graph/state.py +151 -0
- package/artemis-main/artemis/graph/visibility.py +238 -0
- package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
- package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
- package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
- package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
- package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
- package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
- package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
- package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
- package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
- package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
- package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
- package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
- package/artemis-main/artemis/interfaces/cli/main.py +109 -0
- package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
- package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
- package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
- package/artemis-main/artemis/llm/__init__.py +25 -0
- package/artemis-main/artemis/llm/fake_model.py +179 -0
- package/artemis-main/artemis/llm/google/__init__.py +57 -0
- package/artemis-main/artemis/llm/google/provider.py +185 -0
- package/artemis-main/artemis/llm/google/usage.py +148 -0
- package/artemis-main/artemis/llm/parser.py +32 -0
- package/artemis-main/artemis/llm/reliability.py +267 -0
- package/artemis-main/artemis/llm/router.py +389 -0
- package/artemis-main/artemis/llm/schemas.py +36 -0
- package/artemis-main/artemis/llm/structured.py +196 -0
- package/artemis-main/artemis/main.py +58 -0
- package/artemis-main/artemis/mcp/__init__.py +20 -0
- package/artemis-main/artemis/mcp/action_executor.py +760 -0
- package/artemis-main/artemis/mcp/action_manifest.py +320 -0
- package/artemis-main/artemis/mcp/action_names.py +172 -0
- package/artemis-main/artemis/mcp/action_server.py +202 -0
- package/artemis-main/artemis/mcp/action_session.py +245 -0
- package/artemis-main/artemis/mcp/action_specs.py +868 -0
- package/artemis-main/artemis/mcp/action_types.py +86 -0
- package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
- package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
- package/artemis-main/artemis/mcp/actuators/base.py +113 -0
- package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
- package/artemis-main/artemis/mcp/adb_server.py +490 -0
- package/artemis-main/artemis/mcp/observation.py +133 -0
- package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
- package/artemis-main/artemis/memory/__init__.py +90 -0
- package/artemis-main/artemis/memory/chunking.py +1843 -0
- package/artemis-main/artemis/memory/context_policy.py +226 -0
- package/artemis-main/artemis/memory/step_capsule.md +73 -0
- package/artemis-main/artemis/memory/step_memory.py +281 -0
- package/artemis-main/artemis/memory/transcript.py +798 -0
- package/artemis-main/artemis/platform/__init__.py +61 -0
- package/artemis-main/artemis/platform/base.py +104 -0
- package/artemis-main/artemis/platform/darwin.py +184 -0
- package/artemis-main/artemis/platform/linux.py +207 -0
- package/artemis-main/artemis/platform/windows.py +193 -0
- package/artemis-main/artemis/resources/__init__.py +46 -0
- package/artemis-main/artemis/runtime/__init__.py +108 -0
- package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
- package/artemis-main/artemis/runtime/awake_lease.py +283 -0
- package/artemis-main/artemis/runtime/awake_service.py +343 -0
- package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
- package/artemis-main/artemis/runtime/daemon_client.py +422 -0
- package/artemis-main/artemis/runtime/device_lock.py +1013 -0
- package/artemis-main/artemis/runtime/device_pool.py +478 -0
- package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
- package/artemis-main/artemis/runtime/process_probe.py +98 -0
- package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
- package/artemis-main/artemis/runtime/supervisor.py +121 -0
- package/artemis-main/artemis/runtime/trace_store.py +375 -0
- package/artemis-main/artemis/sdk/__init__.py +26 -0
- package/artemis-main/artemis/sdk/agent.py +485 -0
- package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
- package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
- package/artemis-main/artemis/sdk/constants.py +15 -0
- package/artemis-main/artemis/sdk/run_outcome.py +58 -0
- package/artemis-main/artemis/sdk/types/__init__.py +61 -0
- package/artemis-main/artemis/sdk/types/agent.py +89 -0
- package/artemis-main/artemis/sdk/utils.py +28 -0
- package/artemis-main/artemis/services/llm.py +1040 -0
- package/artemis-main/artemis/services/token_meter.py +267 -0
- package/artemis-main/artemis/telemetry/__init__.py +35 -0
- package/artemis-main/artemis/telemetry/models.py +64 -0
- package/artemis-main/artemis/telemetry/replay.py +34 -0
- package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
- package/artemis-main/artemis/telemetry/tracer.py +85 -0
- package/artemis-main/artemis/toolchain/__init__.py +52 -0
- package/artemis-main/artemis/toolchain/descriptors.py +92 -0
- package/artemis-main/artemis/toolchain/resolver.py +185 -0
- package/artemis-main/artemis/tools/base.py +232 -0
- package/artemis-main/artemis/tools/command_tool.py +1240 -0
- package/artemis-main/artemis/tools/committee_prompts.json +5 -0
- package/artemis-main/artemis/tools/committee_tool.py +422 -0
- package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
- package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
- package/artemis-main/artemis/tools/explorer_tool.py +581 -0
- package/artemis-main/artemis/tools/history/__init__.py +398 -0
- package/artemis-main/artemis/tools/history/replay.py +120 -0
- package/artemis-main/artemis/tools/history/screenshot.py +139 -0
- package/artemis-main/artemis/tools/history/search.py +563 -0
- package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
- package/artemis-main/artemis/tools/index.py +47 -0
- package/artemis-main/artemis/tools/log_tool.py +108 -0
- package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
- package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
- package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
- package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
- package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
- package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
- package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
- package/artemis-main/artemis/tools/scratchpad.py +819 -0
- package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
- package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
- package/artemis-main/artemis/tools/video_tool.py +261 -0
- package/artemis-main/artemis/tools/wait_tool.py +97 -0
- package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
- package/artemis-main/artemis/utils/coordinates.py +606 -0
- package/artemis-main/artemis/utils/credentials_validator.py +201 -0
- package/artemis-main/artemis/utils/cv_canvas.py +189 -0
- package/artemis-main/artemis/utils/cython_compat.py +45 -0
- package/artemis-main/artemis/utils/element_hit_test.py +80 -0
- package/artemis-main/artemis/utils/image_diff.py +335 -0
- package/artemis-main/artemis/utils/image_hash.py +80 -0
- package/artemis-main/artemis/utils/notes.py +470 -0
- package/artemis-main/artemis/utils/ocr_api.py +105 -0
- package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
- package/artemis-main/artemis/utils/plan_grammar.py +513 -0
- package/artemis-main/artemis/utils/python_executor.py +213 -0
- package/artemis-main/artemis/utils/startup_progress.py +60 -0
- package/artemis-main/artemis/utils/task_tree.py +1174 -0
- package/artemis-main/artemis/utils/text.py +37 -0
- package/artemis-main/artemis/utils/ui_filter.py +651 -0
- package/artemis-main/artemis/utils/video.py +685 -0
- package/artemis-main/artemis/utils/visualization.py +802 -0
- package/artemis-main/config/artemis.example.jsonc +12 -0
- package/artemis-main/mcp_server/README.md +111 -0
- package/artemis-main/mcp_server/__init__.py +21 -0
- package/artemis-main/mcp_server/__main__.py +20 -0
- package/artemis-main/mcp_server/background/__init__.py +15 -0
- package/artemis-main/mcp_server/background/task_runner.py +453 -0
- package/artemis-main/mcp_server/base.py +49 -0
- package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
- package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
- package/artemis-main/mcp_server/notifiers/base.py +45 -0
- package/artemis-main/mcp_server/notifiers/composite.py +87 -0
- package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
- package/artemis-main/mcp_server/notifiers/file.py +69 -0
- package/artemis-main/mcp_server/notifiers/script.py +90 -0
- package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
- package/artemis-main/mcp_server/rules.md +68 -0
- package/artemis-main/mcp_server/server.py +106 -0
- package/artemis-main/mcp_server/tools/__init__.py +29 -0
- package/artemis-main/mcp_server/tools/device_state.py +107 -0
- package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
- package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
- package/artemis-main/mcp_server/tools/task_manager.py +627 -0
- package/artemis-main/mcp_server/tools/task_runner.py +548 -0
- package/artemis-main/mcp_server/utils/__init__.py +15 -0
- package/artemis-main/mcp_server/utils/device_utils.py +180 -0
- package/artemis-main/mcp_server/utils/env_utils.py +65 -0
- package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
- package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
- package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
- package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
- package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
- package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
- package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
- package/artemis-main/packages/artemis-client/README.md +112 -0
- package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
- package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
- package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
- package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
- package/artemis-main/pyproject.toml +259 -0
- package/artemis-main/pyright-core.json +17 -0
- package/artemis-main/scripts/check_dependency_sources.py +68 -0
- package/artemis-main/scripts/install_deps.ps1 +520 -0
- package/artemis-main/scripts/install_deps.sh +533 -0
- package/artemis-main/scripts/quality_ratchet.py +97 -0
- package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
- package/artemis-main/scripts/run_edge_case_part.py +327 -0
- package/artemis-main/scripts/run_ui.py +242 -0
- package/artemis-main/scripts/start.ps1 +500 -0
- package/artemis-main/scripts/test_all_edge_cases.py +269 -0
- package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
- package/artemis-main/setup.py +164 -0
- package/artemis-main/start.bat +35 -0
- package/artemis-main/start.sh +423 -0
- package/artemis-main/third_party/mobile_use/LICENSE +190 -0
- package/artemis-main/third_party/mobile_use/METADATA +27 -0
- package/artemis-main/third_party/mobile_use/NOTICE +12 -0
- package/artemis-main/third_party/mobile_use/__init__.py +19 -0
- package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
- package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
- package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
- package/artemis-main/third_party/mobile_use/config/output.py +184 -0
- package/artemis-main/third_party/mobile_use/context.py +119 -0
- package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
- package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
- package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
- package/artemis-main/third_party/mobile_use/graph.py +54 -0
- package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
- package/artemis-main/third_party/mobile_use/main.py +130 -0
- package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
- package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
- package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
- package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
- package/artemis-main/third_party/mobile_use/testing.py +59 -0
- package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
- package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
- package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
- package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
- package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
- package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
- package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
- package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
- package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
- package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
- package/artemis-main/uv.lock +4908 -0
- package/bin/android-midscene-automation.js +1 -5
- package/bin/artemis-config.mjs +15 -0
- package/bin/artemis-embedded.py +56 -0
- package/bin/start-artemis.mjs +30 -0
- package/bin/start-dev.mjs +112 -0
- package/bin/test_artemis_embedded.py +44 -0
- package/package.json +4 -2
- package/server/artemis.ts +34 -0
- package/server/http-api.ts +12 -0
- package/src/App.vue +10 -5
- package/src/api.ts +5 -0
- package/src/appium-recorder/AppiumPage.vue +1 -1
- package/src/appium-recorder/components/ComponentInspector.vue +1 -1
- package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
- package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
- package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
- package/src/appium-recorder/flow-appearance.ts +4 -4
- package/src/components/AppSidebar.vue +8 -7
- package/src/components/MidsceneMaintenanceNotice.vue +17 -0
- package/src/components/analytics/AnalysisCharts.vue +4 -4
- package/src/components/analytics/ReportAnalysis.vue +1 -1
- package/src/components/generator/TestCaseFileUpload.vue +1 -1
- package/src/components/help/HelpCenter.vue +56 -37
- package/src/pages/AiGeneratorPage.vue +1 -1
- package/src/pages/ArtemisPage.vue +72 -0
- package/src/pages/ConfigPage.vue +18 -19
- package/src/style.css +340 -121
- package/src/types.ts +1 -1
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
|
|
18
|
+
import { HttpClient } from '@angular/common/http';
|
|
19
|
+
import { Observable } from 'rxjs';
|
|
20
|
+
|
|
21
|
+
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
|
|
22
|
+
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
|
23
|
+
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
|
24
|
+
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
|
25
|
+
import { persistedStreamToSegments } from '../utils/stream-aggregator.util';
|
|
26
|
+
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice };
|
|
27
|
+
|
|
28
|
+
const SESSION_CACHE_KEY = 'artemis.sessions.v1';
|
|
29
|
+
|
|
30
|
+
export interface VideoSegment {
|
|
31
|
+
url: string;
|
|
32
|
+
/** Back-to-back playlist timeline start (seconds); gaps between segments are not represented. */
|
|
33
|
+
start: number;
|
|
34
|
+
duration: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
/** Session-relative first-frame offset in milliseconds (manifest v2). */
|
|
38
|
+
offset_ms?: number;
|
|
39
|
+
duration_ms?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type RecordingPlaybackStatus =
|
|
43
|
+
| 'idle'
|
|
44
|
+
| 'live'
|
|
45
|
+
| 'processing'
|
|
46
|
+
| 'ready'
|
|
47
|
+
| 'failed'
|
|
48
|
+
| 'unavailable';
|
|
49
|
+
|
|
50
|
+
export interface StartupProgressEvent {
|
|
51
|
+
session_id?: string;
|
|
52
|
+
stage: string;
|
|
53
|
+
message: string;
|
|
54
|
+
timestamp: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SessionVideoResponse {
|
|
58
|
+
session_id: string;
|
|
59
|
+
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
|
60
|
+
has_video: boolean;
|
|
61
|
+
video_url: string | null;
|
|
62
|
+
video_segments?: VideoSegment[];
|
|
63
|
+
retry_after_ms?: number;
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Injectable({
|
|
68
|
+
providedIn: 'root'
|
|
69
|
+
})
|
|
70
|
+
export class AgentService {
|
|
71
|
+
private http = inject(HttpClient);
|
|
72
|
+
private zone = inject(NgZone);
|
|
73
|
+
private activePauseCardKey: string | null = null;
|
|
74
|
+
|
|
75
|
+
// Signals to expose state to components
|
|
76
|
+
private rawSessions = signal<Session[]>([]);
|
|
77
|
+
private pendingQueue = signal<Session[]>([]);
|
|
78
|
+
public activeTasks = signal<any[]>([]);
|
|
79
|
+
// Persistent tracking of active/pending sessions across polling boundaries
|
|
80
|
+
private activeSessionTracking = new Map<string, Session>();
|
|
81
|
+
|
|
82
|
+
public sessions = computed(() => {
|
|
83
|
+
const raw = this.rawSessions();
|
|
84
|
+
const pending = this.pendingQueue();
|
|
85
|
+
const status = this.agentStatus();
|
|
86
|
+
const goal = this.runningGoal();
|
|
87
|
+
const runId = this.runningSessionId();
|
|
88
|
+
const activeList = this.activeTasks();
|
|
89
|
+
|
|
90
|
+
const sessionMap = new Map<string, Session>();
|
|
91
|
+
// Lookup maps keep the per-session merge O(1) instead of scanning the
|
|
92
|
+
// pending/active lists multiple times for every raw session.
|
|
93
|
+
const pendingById = new Map(pending.map((p) => [p.session_id, p]));
|
|
94
|
+
const activeById = new Map(activeList.map((at) => [at.session_id, at]));
|
|
95
|
+
|
|
96
|
+
// 1. First add raw sessions from DB
|
|
97
|
+
raw.forEach((s) => {
|
|
98
|
+
const activeMatch = activeById.get(s.session_id);
|
|
99
|
+
const pendingMatch = pendingById.get(s.session_id);
|
|
100
|
+
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
|
101
|
+
|| !!activeMatch;
|
|
102
|
+
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
|
103
|
+
let sStatus = s.status;
|
|
104
|
+
if (!isTerminal) {
|
|
105
|
+
const isPending = !!pendingMatch && !isCurrentActive;
|
|
106
|
+
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
|
107
|
+
} else {
|
|
108
|
+
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
|
109
|
+
}
|
|
110
|
+
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
|
|
111
|
+
if (!serial && s.device_info) {
|
|
112
|
+
try {
|
|
113
|
+
const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
|
|
114
|
+
serial = info?.device_id || info?.device_serial || null;
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const finalSession: Session = {
|
|
120
|
+
...s,
|
|
121
|
+
status: sStatus,
|
|
122
|
+
device_serial: serial,
|
|
123
|
+
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
|
124
|
+
};
|
|
125
|
+
sessionMap.set(s.session_id, finalSession);
|
|
126
|
+
if (isTerminal) {
|
|
127
|
+
this.activeSessionTracking.delete(s.session_id);
|
|
128
|
+
} else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
|
|
129
|
+
this.activeSessionTracking.set(s.session_id, finalSession);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 2. Add pending queue sessions if not yet in raw sessions
|
|
134
|
+
pending.forEach((p) => {
|
|
135
|
+
if (!sessionMap.has(p.session_id)) {
|
|
136
|
+
const activeMatch = activeById.get(p.session_id);
|
|
137
|
+
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|
|
138
|
+
|| !!activeMatch;
|
|
139
|
+
const finalPending: Session = {
|
|
140
|
+
...p,
|
|
141
|
+
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
|
142
|
+
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
|
143
|
+
};
|
|
144
|
+
sessionMap.set(p.session_id, finalPending);
|
|
145
|
+
this.activeSessionTracking.set(p.session_id, finalPending);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 3. Ensure all currently active running sessions are present (multi-device & external runs)
|
|
150
|
+
if (activeList.length > 0) {
|
|
151
|
+
activeList.forEach((at) => {
|
|
152
|
+
const sid = at.session_id || `active-${at.device_id}`;
|
|
153
|
+
const existing = sessionMap.get(sid);
|
|
154
|
+
if (!existing) {
|
|
155
|
+
const newSession: Session = {
|
|
156
|
+
session_id: sid,
|
|
157
|
+
initial_goal: at.goal || goal || '',
|
|
158
|
+
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
|
159
|
+
status: 'running',
|
|
160
|
+
model_info: this.activeModel() || undefined,
|
|
161
|
+
device_serial: at.device_id || null
|
|
162
|
+
};
|
|
163
|
+
sessionMap.set(sid, newSession);
|
|
164
|
+
this.activeSessionTracking.set(sid, newSession);
|
|
165
|
+
} else if (existing.status === 'pending') {
|
|
166
|
+
const updated: Session = {
|
|
167
|
+
...existing,
|
|
168
|
+
status: 'running',
|
|
169
|
+
device_serial: at.device_id || existing.device_serial
|
|
170
|
+
};
|
|
171
|
+
sessionMap.set(sid, updated);
|
|
172
|
+
this.activeSessionTracking.set(sid, updated);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
} else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
|
176
|
+
const activeSession: Session = {
|
|
177
|
+
session_id: runId,
|
|
178
|
+
initial_goal: goal || '',
|
|
179
|
+
start_time: Date.now() / 1000,
|
|
180
|
+
status,
|
|
181
|
+
model_info: this.activeModel() || undefined
|
|
182
|
+
};
|
|
183
|
+
sessionMap.set(runId, activeSession);
|
|
184
|
+
this.activeSessionTracking.set(runId, activeSession);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Bridge transient queue-to-running transition gaps
|
|
188
|
+
this.activeSessionTracking.forEach((ts, sid) => {
|
|
189
|
+
if (!sessionMap.has(sid)) {
|
|
190
|
+
sessionMap.set(sid, {
|
|
191
|
+
...ts,
|
|
192
|
+
status: 'running'
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
public currentSessionId = signal<string | null>(null);
|
|
201
|
+
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
|
202
|
+
public isSessionContentLoading = signal<boolean>(false);
|
|
203
|
+
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
|
204
|
+
public runningSessionId = signal<string | null>(null);
|
|
205
|
+
public runningGoal = signal<string | null>(null);
|
|
206
|
+
public isPaused = signal<boolean>(false);
|
|
207
|
+
public pausedError = signal<string | null>(null);
|
|
208
|
+
public isRetrying = signal<boolean>(false);
|
|
209
|
+
public retryMessage = signal<string | null>(null);
|
|
210
|
+
/** Architecture / LLM of the task the status poll reports as running. */
|
|
211
|
+
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
|
|
212
|
+
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
|
|
213
|
+
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
|
|
214
|
+
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
|
|
215
|
+
|
|
216
|
+
public currentStartupProgress = computed(() => {
|
|
217
|
+
const sessionId = this.currentSessionId();
|
|
218
|
+
if (sessionId) {
|
|
219
|
+
const normalized = String(sessionId).trim().toLowerCase();
|
|
220
|
+
const bySession = this.startupProgressBySession();
|
|
221
|
+
const direct = bySession[sessionId];
|
|
222
|
+
if (direct?.length) return direct;
|
|
223
|
+
const matched = Object.entries(bySession).find(
|
|
224
|
+
([key]) => key.trim().toLowerCase() === normalized
|
|
225
|
+
);
|
|
226
|
+
if (matched?.[1]?.length) return matched[1];
|
|
227
|
+
}
|
|
228
|
+
return this.pendingStartupProgress();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Notes and Tab States
|
|
232
|
+
public currentNotes = signal<Record<string, string>>({});
|
|
233
|
+
public selectedNoteKey = signal<string>('task_plan.md');
|
|
234
|
+
public activeTab = signal<'tasks' | 'notes'>('tasks');
|
|
235
|
+
|
|
236
|
+
// Video Replay Floating Window States
|
|
237
|
+
public isVideoWindowOpen = signal<boolean>(false);
|
|
238
|
+
public isVideoMinimized = signal<boolean>(false);
|
|
239
|
+
public activeVideoUrl = signal<string | null>(null);
|
|
240
|
+
public activeVideoSegments = signal<VideoSegment[]>([]);
|
|
241
|
+
public activeVideoTitle = signal<string>('');
|
|
242
|
+
public isVideoLoading = signal<boolean>(false);
|
|
243
|
+
public recordingPlaybackStatus = signal<RecordingPlaybackStatus>('idle');
|
|
244
|
+
public recordingPlaybackMessage = signal<string>('');
|
|
245
|
+
public shouldAutoplayVideo = signal<boolean>(false);
|
|
246
|
+
public videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null);
|
|
247
|
+
public stepSeekRequest = signal<{ index: number; requestId: number } | null>(null);
|
|
248
|
+
public playerMode = signal<'video' | 'steps'>('video');
|
|
249
|
+
public streamResetEvent = signal<LLMStreamResetEventData | null>(null);
|
|
250
|
+
private activeVideoSessionId: string | null = null;
|
|
251
|
+
private videoSeekRequestId = 0;
|
|
252
|
+
private stepSeekRequestId = 0;
|
|
253
|
+
private videoRequestGeneration = 0;
|
|
254
|
+
private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
255
|
+
private videoWaitStartedAt = 0;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Step logs only. Streaming text chunks cannot change replay frames, so the
|
|
259
|
+
* custom equality keeps downstream frame extraction from re-running on every
|
|
260
|
+
* llm_stream update of the sessionLogs signal.
|
|
261
|
+
*/
|
|
262
|
+
private stepLogsForReplay = computed<any[]>(
|
|
263
|
+
() => this.sessionLogs().filter(
|
|
264
|
+
(log) => log && (log.type === 'step_updated' || log.type === 'step_recorded')
|
|
265
|
+
),
|
|
266
|
+
{ equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) }
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Computed step replay frames from current session logs
|
|
271
|
+
*/
|
|
272
|
+
public currentSessionStepFrames = computed<StepReplayFrame[]>(() => {
|
|
273
|
+
return extractStepReplayFrames(this.stepLogsForReplay());
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
public hasCurrentSessionStepFrames = computed<boolean>(() => {
|
|
277
|
+
return this.currentSessionStepFrames().length > 0;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
public setPlayerMode(mode: 'video' | 'steps'): void {
|
|
281
|
+
this.playerMode.set(mode);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public togglePlayerMode(): void {
|
|
285
|
+
this.playerMode.update((mode) => (mode === 'video' ? 'steps' : 'video'));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public requestStepSeek(index: number): void {
|
|
289
|
+
this.stepSeekRequestId++;
|
|
290
|
+
this.stepSeekRequest.set({ index, requestId: this.stepSeekRequestId });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Clear user-pinned selection so subsequent runs automatically follow active runner
|
|
295
|
+
*/
|
|
296
|
+
public clearUserPinnedSession(): void {
|
|
297
|
+
this.userPinnedSessionId.set(null);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Computed active/viewed session
|
|
302
|
+
*/
|
|
303
|
+
public currentSession = computed(() => {
|
|
304
|
+
const curId = this.currentSessionId();
|
|
305
|
+
if (!curId) return null;
|
|
306
|
+
return this.sessions().find(s => s.session_id === curId) || null;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Architecture / LLM of the session being viewed.
|
|
311
|
+
*
|
|
312
|
+
* Each session row carries its own `model_info` (resolved by the backend),
|
|
313
|
+
* so a Pro session stays "Pro" while a Flash task runs elsewhere. Only a
|
|
314
|
+
* session that has not reached the list yet falls back to the running model.
|
|
315
|
+
*/
|
|
316
|
+
public viewedModel = computed<ModelInfo | null>(() => {
|
|
317
|
+
const current = this.currentSession();
|
|
318
|
+
if (current?.model_info) return current.model_info;
|
|
319
|
+
return this.activeModel();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Computed whether the currently viewed session is actively running or paused.
|
|
324
|
+
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
|
|
325
|
+
*/
|
|
326
|
+
public isCurrentSessionRunning = computed(() => {
|
|
327
|
+
const curId = this.currentSessionId();
|
|
328
|
+
if (!curId) return false;
|
|
329
|
+
const session = this.currentSession();
|
|
330
|
+
if (session) {
|
|
331
|
+
return session.status === 'running' || session.status === 'paused';
|
|
332
|
+
}
|
|
333
|
+
// Fallback if session was just submitted/selected but not yet merged into sessions list
|
|
334
|
+
const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
|
|
335
|
+
if (isActiveStatus && this.runningSessionId() === curId) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (this.activeTasks().some((at) => at.session_id === curId)) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
return false;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Computed video URL for the currently viewed session
|
|
346
|
+
*/
|
|
347
|
+
public currentSessionVideoUrl = computed(() => {
|
|
348
|
+
const curId = this.currentSessionId();
|
|
349
|
+
if (!curId) return null;
|
|
350
|
+
const session = this.sessions().find(s => s.session_id === curId);
|
|
351
|
+
return session?.video_url || null;
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
public currentSessionRecordingStatus = computed(() => {
|
|
355
|
+
const session = this.currentSession();
|
|
356
|
+
const status = session?.recording_status;
|
|
357
|
+
return status === 'recording' || status === 'finalizing' || status === 'processing'
|
|
358
|
+
? 'processing'
|
|
359
|
+
: status;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
private eventSource: EventSource | null = null;
|
|
363
|
+
private statusInterval: any = null;
|
|
364
|
+
private sessionLoadGeneration = 0;
|
|
365
|
+
private sessionSnapshotRequestId = 0;
|
|
366
|
+
private sessionSnapshotAppliedId = 0;
|
|
367
|
+
private pendingSnapshotRequests = new Set<number>();
|
|
368
|
+
|
|
369
|
+
// Streaming chunks are coalesced into one signal update per short window so a
|
|
370
|
+
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
|
|
371
|
+
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; sessionId?: string | null; parentTraceId?: any; streamType: string; chunk: string }>();
|
|
372
|
+
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
373
|
+
private lastQueueSignature: string | null = null;
|
|
374
|
+
private lastActiveTasksSignature: string | null = null;
|
|
375
|
+
private lastPersistedSessionsJson: string | null = null;
|
|
376
|
+
private onVisibilityChange = () => {
|
|
377
|
+
if (typeof document !== 'undefined' && !document.hidden) {
|
|
378
|
+
this.fetchStatus();
|
|
379
|
+
this.fetchSessions();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
constructor() {
|
|
384
|
+
this.restoreSessionsCache();
|
|
385
|
+
this.fetchSessions();
|
|
386
|
+
this.startStatusPolling();
|
|
387
|
+
this.ensureLiveStream();
|
|
388
|
+
if (typeof document !== 'undefined') {
|
|
389
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
390
|
+
}
|
|
391
|
+
inject(DestroyRef).onDestroy(() => this.destroy());
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Check if any task is currently running
|
|
396
|
+
*/
|
|
397
|
+
public isRunningTask = computed(() => {
|
|
398
|
+
if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
|
|
399
|
+
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Effective Pro tuning defaults (verification level, explorer mode) from the
|
|
404
|
+
* backend agent config, so the launcher sliders start where the config is.
|
|
405
|
+
*/
|
|
406
|
+
public getProTuningDefaults(): Observable<ProTuningDefaults> {
|
|
407
|
+
return this.http.get<ProTuningDefaults>('/api/run/defaults');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Session-wide token totals, live executor context size and the run's tuning. */
|
|
411
|
+
public getSessionUsage(sessionId: string): Observable<SessionUsage> {
|
|
412
|
+
return this.http.get<SessionUsage>(`/api/sessions/${encodeURIComponent(sessionId)}/usage`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Run a new task by submitting to backend queue
|
|
417
|
+
*/
|
|
418
|
+
public runTask(
|
|
419
|
+
goal: string,
|
|
420
|
+
profile: string = 'flash',
|
|
421
|
+
expectedOutput?: string,
|
|
422
|
+
enableOutputter?: boolean,
|
|
423
|
+
proTuning?: ProTuningOptions
|
|
424
|
+
): Observable<any> {
|
|
425
|
+
return new Observable((obs) => {
|
|
426
|
+
const submittedEvent: StartupProgressEvent = {
|
|
427
|
+
stage: 'submitting',
|
|
428
|
+
message: 'Submitting the task',
|
|
429
|
+
timestamp: Date.now() / 1000
|
|
430
|
+
};
|
|
431
|
+
this.pendingStartupProgress.set([submittedEvent]);
|
|
432
|
+
const payload: any = { goal, profile };
|
|
433
|
+
if (expectedOutput && expectedOutput.trim()) {
|
|
434
|
+
payload.expected_output = expectedOutput.trim();
|
|
435
|
+
}
|
|
436
|
+
if (enableOutputter !== undefined) {
|
|
437
|
+
payload.enable_outputter = enableOutputter;
|
|
438
|
+
}
|
|
439
|
+
if (proTuning?.verificationLevel) {
|
|
440
|
+
payload.verification_level = proTuning.verificationLevel;
|
|
441
|
+
}
|
|
442
|
+
if (proTuning?.explorerMode) {
|
|
443
|
+
payload.explorer_mode = proTuning.explorerMode;
|
|
444
|
+
}
|
|
445
|
+
this.clearUserPinnedSession();
|
|
446
|
+
this.http.post<any>('/api/run', payload).subscribe({
|
|
447
|
+
next: (res) => {
|
|
448
|
+
if (res && res.tasks && res.tasks.length > 0) {
|
|
449
|
+
const newSessionId = res.tasks[0].session_id;
|
|
450
|
+
if (newSessionId) {
|
|
451
|
+
this.appendStartupProgress(
|
|
452
|
+
{ ...submittedEvent, session_id: newSessionId },
|
|
453
|
+
newSessionId
|
|
454
|
+
);
|
|
455
|
+
this.pendingStartupProgress.set([]);
|
|
456
|
+
const isCurrentlyRunning =
|
|
457
|
+
this.agentStatus() === 'running' ||
|
|
458
|
+
this.sessions().some((s) => s.status === 'running');
|
|
459
|
+
const activeSessionId = this.runningSessionId()
|
|
460
|
+
|| this.sessions().find((s) => s.status === 'running' || s.status === 'paused')?.session_id;
|
|
461
|
+
// The status poll can observe the new runner before /api/run
|
|
462
|
+
// returns. In that case it is still the task we just submitted,
|
|
463
|
+
// not an older task that should remain selected.
|
|
464
|
+
if (!isCurrentlyRunning || activeSessionId === newSessionId) {
|
|
465
|
+
this.selectSession(newSessionId, false);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.pendingStartupProgress.set([]);
|
|
470
|
+
obs.next(res);
|
|
471
|
+
obs.complete();
|
|
472
|
+
},
|
|
473
|
+
error: (err) => {
|
|
474
|
+
this.pendingStartupProgress.set([]);
|
|
475
|
+
obs.error(err);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Stop the currently viewed task (or specified task), and optionally clear queue.
|
|
483
|
+
* Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
|
|
484
|
+
*/
|
|
485
|
+
public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
|
|
486
|
+
let targetSessionId: string | null = null;
|
|
487
|
+
let effectiveStopAll = stopAll;
|
|
488
|
+
|
|
489
|
+
if (typeof targetOrStopAll === 'boolean') {
|
|
490
|
+
effectiveStopAll = targetOrStopAll;
|
|
491
|
+
} else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
|
|
492
|
+
targetSessionId = targetOrStopAll.trim();
|
|
493
|
+
} else {
|
|
494
|
+
// Default to currently viewed session, or currently running session
|
|
495
|
+
targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!effectiveStopAll && !targetSessionId) {
|
|
499
|
+
targetSessionId = this.sessions().find(
|
|
500
|
+
(session) => session.status === 'running' || session.status === 'paused'
|
|
501
|
+
)?.session_id || null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Keep the task's terminal state stable while the stop request and the
|
|
505
|
+
// persisted session update cross the network. Clearing the global runner
|
|
506
|
+
// state first used to make the session merger infer "completed" from the
|
|
507
|
+
// still-stale DB row, causing a brief completed -> cancelled flicker.
|
|
508
|
+
if (targetSessionId) {
|
|
509
|
+
this.setSessionStatus(targetSessionId, 'cancelled');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Determine if other active sessions are still executing across multiple devices
|
|
513
|
+
const otherRunningSessions = this.sessions().filter(
|
|
514
|
+
(s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
|
|
518
|
+
if (effectiveStopAll || otherRunningSessions.length === 0) {
|
|
519
|
+
this.agentStatus.set('idle');
|
|
520
|
+
this.runningSessionId.set(null);
|
|
521
|
+
this.runningGoal.set(null);
|
|
522
|
+
} else if (this.runningSessionId() === targetSessionId) {
|
|
523
|
+
this.runningSessionId.set(otherRunningSessions[0].session_id);
|
|
524
|
+
this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.isPaused.set(false);
|
|
528
|
+
this.pausedError.set(null);
|
|
529
|
+
this.isRetrying.set(false);
|
|
530
|
+
this.invalidateStatusSignatures();
|
|
531
|
+
if (effectiveStopAll) {
|
|
532
|
+
this.pendingQueue.set([]);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Mark active streaming / pending logs as finished if viewing target session
|
|
536
|
+
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
|
|
537
|
+
this.flushStreamChunks();
|
|
538
|
+
this.sessionLogs.update((logs) => {
|
|
539
|
+
if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) {
|
|
540
|
+
return logs;
|
|
541
|
+
}
|
|
542
|
+
return logs.map((l) => {
|
|
543
|
+
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
|
|
544
|
+
return { ...l, data: { ...l.data, isCompleted: true } };
|
|
545
|
+
}
|
|
546
|
+
return l;
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Optimistically remove from activeTasks
|
|
552
|
+
if (targetSessionId) {
|
|
553
|
+
this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let url = `/api/stop?all=${effectiveStopAll}`;
|
|
557
|
+
if (!effectiveStopAll && targetSessionId) {
|
|
558
|
+
url += `&session_id=${encodeURIComponent(targetSessionId)}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const payload: any = { all: effectiveStopAll };
|
|
562
|
+
if (targetSessionId) {
|
|
563
|
+
payload.session_id = targetSessionId;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
this.http.post<any>(url, payload).subscribe({
|
|
567
|
+
next: () => {
|
|
568
|
+
this.fetchStatus();
|
|
569
|
+
this.fetchSessions();
|
|
570
|
+
},
|
|
571
|
+
error: (err) => {
|
|
572
|
+
console.error('Failed to stop task:', err);
|
|
573
|
+
this.fetchSessions();
|
|
574
|
+
this.fetchStatus();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Resume paused task
|
|
581
|
+
*/
|
|
582
|
+
public resumeTask(): void {
|
|
583
|
+
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
|
|
584
|
+
next: (response) => {
|
|
585
|
+
if (response?.status !== 'resumed') {
|
|
586
|
+
// A stale recovery control must not optimistically change the task
|
|
587
|
+
// back to running when the backend says there is nothing to resume.
|
|
588
|
+
this.fetchStatus();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const resumedSessionId = this.runningSessionId();
|
|
592
|
+
this.isPaused.set(false);
|
|
593
|
+
this.pausedError.set(null);
|
|
594
|
+
this.agentStatus.set('running');
|
|
595
|
+
this.setSessionStatus(resumedSessionId, 'running');
|
|
596
|
+
this.fetchStatus();
|
|
597
|
+
},
|
|
598
|
+
error: (err) => {
|
|
599
|
+
console.error('Failed to resume task:', err);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Fetch all past and active sessions from the backend
|
|
606
|
+
*/
|
|
607
|
+
public fetchSessions(): void {
|
|
608
|
+
this.http.get<Session[]>('/api/sessions').subscribe({
|
|
609
|
+
next: (data) => {
|
|
610
|
+
this.rawSessions.set(data);
|
|
611
|
+
this.persistSessionsCache(data);
|
|
612
|
+
// On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest
|
|
613
|
+
if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) {
|
|
614
|
+
this.selectSession(data[0].session_id, false);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
error: (err) => {
|
|
618
|
+
console.error('Failed to fetch sessions from backend:', err);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Delete an individual session / task
|
|
625
|
+
*/
|
|
626
|
+
public deleteSession(sessionId: string): Observable<any> {
|
|
627
|
+
// 1. Optimistically update local session state immediately
|
|
628
|
+
this.invalidateStatusSignatures();
|
|
629
|
+
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
|
|
630
|
+
this.persistSessionsCache(this.rawSessions());
|
|
631
|
+
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
|
|
632
|
+
|
|
633
|
+
if (this.userPinnedSessionId() === sessionId) {
|
|
634
|
+
this.userPinnedSessionId.set(null);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (this.currentSessionId() === sessionId) {
|
|
638
|
+
this.selectSession('', false);
|
|
639
|
+
if (this.isVideoWindowOpen()) {
|
|
640
|
+
this.closeVideoPlayer();
|
|
641
|
+
}
|
|
642
|
+
const runningId = this.runningSessionId();
|
|
643
|
+
if (runningId && this.agentStatus() === 'running') {
|
|
644
|
+
this.selectSession(runningId, false);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 2. Send API request
|
|
649
|
+
return new Observable((obs) => {
|
|
650
|
+
this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).subscribe({
|
|
651
|
+
next: (res) => {
|
|
652
|
+
this.fetchSessions();
|
|
653
|
+
this.fetchStatus();
|
|
654
|
+
obs.next(res);
|
|
655
|
+
obs.complete();
|
|
656
|
+
},
|
|
657
|
+
error: (err) => {
|
|
658
|
+
console.error(`Failed to delete session ${sessionId}:`, err);
|
|
659
|
+
this.fetchSessions();
|
|
660
|
+
this.fetchStatus();
|
|
661
|
+
obs.error(err);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Clear all sessions, tasks, and history
|
|
669
|
+
*/
|
|
670
|
+
public clearAllHistory(): Observable<any> {
|
|
671
|
+
// 1. Optimistically clear all local session state immediately
|
|
672
|
+
this.invalidateStatusSignatures();
|
|
673
|
+
this.userPinnedSessionId.set(null);
|
|
674
|
+
this.rawSessions.set([]);
|
|
675
|
+
this.clearSessionsCache();
|
|
676
|
+
this.pendingQueue.set([]);
|
|
677
|
+
this.selectSession('', false);
|
|
678
|
+
if (this.isVideoWindowOpen()) {
|
|
679
|
+
this.closeVideoPlayer();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 2. Send API request
|
|
683
|
+
return new Observable((obs) => {
|
|
684
|
+
this.http.post<any>('/api/cleanup', {}).subscribe({
|
|
685
|
+
next: (res) => {
|
|
686
|
+
this.fetchSessions();
|
|
687
|
+
this.fetchStatus();
|
|
688
|
+
obs.next(res);
|
|
689
|
+
obs.complete();
|
|
690
|
+
},
|
|
691
|
+
error: (err) => {
|
|
692
|
+
console.error('Failed to cleanup history:', err);
|
|
693
|
+
this.fetchSessions();
|
|
694
|
+
this.fetchStatus();
|
|
695
|
+
obs.error(err);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Connect to the Server-Sent Events stream for a specific session
|
|
703
|
+
* @param sessionId The ID of the session to select
|
|
704
|
+
* @param isUserAction Whether this selection was explicitly triggered by user interaction
|
|
705
|
+
*/
|
|
706
|
+
public selectSession(sessionId: string, isUserAction: boolean = false): void {
|
|
707
|
+
if (isUserAction) {
|
|
708
|
+
if (sessionId && sessionId === this.runningSessionId() && this.agentStatus() === 'running') {
|
|
709
|
+
// User explicitly clicked the currently running task -> follow live runner
|
|
710
|
+
this.userPinnedSessionId.set(null);
|
|
711
|
+
} else if (sessionId) {
|
|
712
|
+
// User explicitly clicked a non-running or historical task -> pin to it
|
|
713
|
+
this.userPinnedSessionId.set(sessionId);
|
|
714
|
+
} else {
|
|
715
|
+
this.userPinnedSessionId.set(null);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!sessionId) {
|
|
720
|
+
this.sessionLoadGeneration++;
|
|
721
|
+
this.pendingSnapshotRequests.clear();
|
|
722
|
+
this.discardPendingStreamChunks();
|
|
723
|
+
this.isSessionContentLoading.set(false);
|
|
724
|
+
this.currentSessionId.set(null);
|
|
725
|
+
this.sessionLogs.set([]);
|
|
726
|
+
this.currentNotes.set({});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (this.currentSessionId() === sessionId) {
|
|
731
|
+
return; // Already selected
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const loadGeneration = ++this.sessionLoadGeneration;
|
|
735
|
+
this.pendingSnapshotRequests.clear();
|
|
736
|
+
this.discardPendingStreamChunks();
|
|
737
|
+
this.sessionSnapshotAppliedId = 0;
|
|
738
|
+
this.isSessionContentLoading.set(true);
|
|
739
|
+
this.currentSessionId.set(sessionId);
|
|
740
|
+
this.sessionLogs.set([]); // Reset logs for new session selection
|
|
741
|
+
this.activePauseCardKey = null;
|
|
742
|
+
this.isPaused.set(false);
|
|
743
|
+
this.pausedError.set(null);
|
|
744
|
+
this.fetchNotes(sessionId);
|
|
745
|
+
this.fetchChecks(sessionId);
|
|
746
|
+
|
|
747
|
+
// If video window is currently open, dynamically sync/refresh video for new session
|
|
748
|
+
if (this.isVideoWindowOpen()) {
|
|
749
|
+
this.openVideoPlayer(sessionId);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
console.debug(`Selecting session: ${sessionId}`);
|
|
753
|
+
// Start reading the persisted snapshot immediately
|
|
754
|
+
this.backfillSessionSteps(sessionId, loadGeneration);
|
|
755
|
+
this.ensureLiveStream();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Ensure persistent single-channel live stream is established via /api/stream.
|
|
760
|
+
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
|
|
761
|
+
*/
|
|
762
|
+
public ensureLiveStream(): void {
|
|
763
|
+
if (this.eventSource) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
console.debug('Establishing persistent unified live stream via /api/stream');
|
|
768
|
+
// The stream is registered outside the Angular zone: high-frequency SSE
|
|
769
|
+
// callbacks must not schedule a change-detection pass each. Signal writes
|
|
770
|
+
// still notify the render scheduler, so the UI stays live.
|
|
771
|
+
this.zone.runOutsideAngular(() => {
|
|
772
|
+
this.eventSource = new EventSource('/api/stream');
|
|
773
|
+
|
|
774
|
+
this.eventSource.addEventListener('info', () => {
|
|
775
|
+
// Reconcile current session if active
|
|
776
|
+
const curId = this.currentSessionId();
|
|
777
|
+
if (curId) {
|
|
778
|
+
this.backfillSessionSteps(curId);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
this.eventSource.addEventListener('keep-alive', () => {
|
|
783
|
+
// Keep-alive tick
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const eventTypes = [
|
|
787
|
+
'llm_stream',
|
|
788
|
+
'llm_stream_reset',
|
|
789
|
+
'trace_recorded',
|
|
790
|
+
'step_recorded',
|
|
791
|
+
'step_updated',
|
|
792
|
+
'background_tasks_updated',
|
|
793
|
+
'task_paused',
|
|
794
|
+
'task_resumed',
|
|
795
|
+
'llm_retrying',
|
|
796
|
+
'startup_progress',
|
|
797
|
+
'session_started',
|
|
798
|
+
'session_ended',
|
|
799
|
+
'recording_ready',
|
|
800
|
+
'recording_failed',
|
|
801
|
+
'checker_event'
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
eventTypes.forEach((eventType) => {
|
|
805
|
+
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
|
|
806
|
+
try {
|
|
807
|
+
const parsedData = JSON.parse(event.data);
|
|
808
|
+
const evtSessionId = parsedData?.session_id;
|
|
809
|
+
|
|
810
|
+
// Preserve ordering: buffered stream text must land before any
|
|
811
|
+
// non-stream event is appended to the session logs.
|
|
812
|
+
if (eventType !== 'llm_stream') {
|
|
813
|
+
this.flushStreamChunks();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
|
|
817
|
+
if (
|
|
818
|
+
this.isVideoWindowOpen()
|
|
819
|
+
&& evtSessionId
|
|
820
|
+
&& String(evtSessionId) === String(this.activeVideoSessionId)
|
|
821
|
+
) {
|
|
822
|
+
if (eventType === 'recording_ready') {
|
|
823
|
+
this.refreshActiveRecording(true);
|
|
824
|
+
} else {
|
|
825
|
+
this.cancelVideoRetry();
|
|
826
|
+
this.isVideoLoading.set(false);
|
|
827
|
+
this.activeVideoUrl.set(null);
|
|
828
|
+
this.activeVideoSegments.set([]);
|
|
829
|
+
this.recordingPlaybackStatus.set('failed');
|
|
830
|
+
this.recordingPlaybackMessage.set(
|
|
831
|
+
parsedData?.error || 'Recording finalization failed.'
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (eventType === 'background_tasks_updated') {
|
|
839
|
+
this.fetchStatus();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (eventType === 'session_started') {
|
|
844
|
+
this.agentStatus.set('running');
|
|
845
|
+
if (parsedData?.session_id) {
|
|
846
|
+
this.runningSessionId.set(parsedData.session_id);
|
|
847
|
+
}
|
|
848
|
+
if (parsedData?.initial_goal) {
|
|
849
|
+
this.runningGoal.set(parsedData.initial_goal);
|
|
850
|
+
}
|
|
851
|
+
this.fetchSessions();
|
|
852
|
+
|
|
853
|
+
// Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
|
|
854
|
+
if (!this.userPinnedSessionId() && parsedData?.session_id) {
|
|
855
|
+
this.selectSession(parsedData.session_id, false);
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (eventType === 'session_ended') {
|
|
861
|
+
const endedId = evtSessionId || this.runningSessionId();
|
|
862
|
+
if (endedId) {
|
|
863
|
+
this.applySessionEndedStatus(endedId, parsedData);
|
|
864
|
+
this.invalidateStatusSignatures();
|
|
865
|
+
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
|
|
866
|
+
}
|
|
867
|
+
const remaining = this.activeTasks();
|
|
868
|
+
if (remaining.length > 0) {
|
|
869
|
+
this.agentStatus.set('running');
|
|
870
|
+
this.runningSessionId.set(remaining[0].session_id);
|
|
871
|
+
this.runningGoal.set(remaining[0].goal || null);
|
|
872
|
+
} else if (this.pendingQueue().length > 0) {
|
|
873
|
+
const nextPending = this.pendingQueue()[0];
|
|
874
|
+
this.agentStatus.set('running');
|
|
875
|
+
this.runningSessionId.set(nextPending.session_id);
|
|
876
|
+
this.runningGoal.set(nextPending.initial_goal || null);
|
|
877
|
+
} else {
|
|
878
|
+
this.agentStatus.set('idle');
|
|
879
|
+
this.runningSessionId.set(null);
|
|
880
|
+
this.runningGoal.set(null);
|
|
881
|
+
}
|
|
882
|
+
this.isRetrying.set(false);
|
|
883
|
+
this.isPaused.set(false);
|
|
884
|
+
this.fetchSessions();
|
|
885
|
+
this.fetchStatus();
|
|
886
|
+
if (endedId) {
|
|
887
|
+
this.fetchNotes(endedId);
|
|
888
|
+
if (String(endedId) === String(this.currentSessionId() || '')) {
|
|
889
|
+
// The exit final review / run outcome may have landed while the
|
|
890
|
+
// stream was reconnecting: reconcile from the persisted ledger.
|
|
891
|
+
this.fetchChecks(endedId);
|
|
892
|
+
}
|
|
893
|
+
if (
|
|
894
|
+
this.isVideoWindowOpen()
|
|
895
|
+
&& this.activeVideoSessionId === endedId
|
|
896
|
+
&& this.recordingPlaybackStatus() === 'live'
|
|
897
|
+
) {
|
|
898
|
+
this.beginRecordingFinalization(endedId);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (eventType === 'startup_progress') {
|
|
905
|
+
const targetSid = evtSessionId || this.currentSessionId();
|
|
906
|
+
if (targetSid) {
|
|
907
|
+
this.appendStartupProgress(parsedData, targetSid);
|
|
908
|
+
const curId = this.currentSessionId();
|
|
909
|
+
if (
|
|
910
|
+
!this.userPinnedSessionId() &&
|
|
911
|
+
(!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
|
|
912
|
+
) {
|
|
913
|
+
this.agentStatus.set('running');
|
|
914
|
+
this.runningSessionId.set(targetSid);
|
|
915
|
+
this.selectSession(targetSid, false);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// If the event is for a different session than what user is currently viewing, ignore for logs
|
|
922
|
+
const curId = this.currentSessionId();
|
|
923
|
+
if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (eventType === 'llm_retrying') {
|
|
928
|
+
this.isRetrying.set(true);
|
|
929
|
+
const attempt = Number(parsedData.attempt || 0);
|
|
930
|
+
const max = Number(parsedData.max_retries || 0);
|
|
931
|
+
const delay = Number(parsedData.delay || 0);
|
|
932
|
+
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
|
|
933
|
+
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
|
|
934
|
+
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
|
|
935
|
+
if (curId) {
|
|
936
|
+
this.appendLiveLLMRetryTrace(parsedData, curId);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (eventType === 'task_paused') {
|
|
942
|
+
this.isPaused.set(true);
|
|
943
|
+
this.isRetrying.set(false);
|
|
944
|
+
this.agentStatus.set('paused');
|
|
945
|
+
if (curId) {
|
|
946
|
+
this.runningSessionId.set(curId);
|
|
947
|
+
this.setSessionStatus(curId, 'paused');
|
|
948
|
+
const pauseError = parsedData.error || 'AI call failed';
|
|
949
|
+
this.pausedError.set(pauseError);
|
|
950
|
+
this.appendPausedErrorCard(
|
|
951
|
+
pauseError,
|
|
952
|
+
curId,
|
|
953
|
+
parsedData.timestamp,
|
|
954
|
+
parsedData.step_id,
|
|
955
|
+
parsedData
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (eventType === 'task_resumed') {
|
|
962
|
+
this.isPaused.set(false);
|
|
963
|
+
this.isRetrying.set(false);
|
|
964
|
+
this.pausedError.set(null);
|
|
965
|
+
if (curId && this.runningSessionId() === curId) {
|
|
966
|
+
this.agentStatus.set('running');
|
|
967
|
+
this.setSessionStatus(curId, 'running');
|
|
968
|
+
}
|
|
969
|
+
this.activePauseCardKey = null;
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (eventType === 'step_updated' || eventType === 'step_recorded' || eventType === 'llm_stream') {
|
|
974
|
+
this.isRetrying.set(false);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (eventType === 'llm_stream_reset') {
|
|
978
|
+
const streamExecId = parsedData?.stream_exec_id || parsedData?.stream_execution_id;
|
|
979
|
+
if (streamExecId) {
|
|
980
|
+
for (const key of Array.from(this.pendingStreamChunks.keys())) {
|
|
981
|
+
if (key.startsWith(`${streamExecId}|`) || key === streamExecId) {
|
|
982
|
+
this.pendingStreamChunks.delete(key);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const resetMessage = parsedData?.message || DEFAULT_STREAM_RESET_MESSAGE;
|
|
987
|
+
this.streamResetEvent.set({
|
|
988
|
+
stream_exec_id: streamExecId,
|
|
989
|
+
stream_execution_id: streamExecId,
|
|
990
|
+
step_id: parsedData?.step_id,
|
|
991
|
+
session_id: parsedData?.session_id,
|
|
992
|
+
action: parsedData?.action || 'discard',
|
|
993
|
+
reason: parsedData?.reason || 'mid_stream_failure',
|
|
994
|
+
category: parsedData?.category,
|
|
995
|
+
error: parsedData?.error,
|
|
996
|
+
message: resetMessage,
|
|
997
|
+
retry_attempt: parsedData?.retry_attempt,
|
|
998
|
+
timestamp: Date.now()
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
this.sessionLogs.update((logs) => {
|
|
1002
|
+
let updated = false;
|
|
1003
|
+
const next = logs.map((log) => {
|
|
1004
|
+
if (log.type === 'llm_stream' && log.data?.execution_id === streamExecId) {
|
|
1005
|
+
updated = true;
|
|
1006
|
+
return {
|
|
1007
|
+
...log,
|
|
1008
|
+
data: {
|
|
1009
|
+
...log.data,
|
|
1010
|
+
isReset: true,
|
|
1011
|
+
resetMessage
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return log;
|
|
1016
|
+
});
|
|
1017
|
+
if (!updated && streamExecId) {
|
|
1018
|
+
next.push({
|
|
1019
|
+
type: 'llm_stream',
|
|
1020
|
+
timestamp: new Date().toISOString(),
|
|
1021
|
+
session_id: parsedData?.session_id,
|
|
1022
|
+
data: {
|
|
1023
|
+
execution_id: streamExecId,
|
|
1024
|
+
step_id: parsedData?.step_id,
|
|
1025
|
+
stream_type: 'text',
|
|
1026
|
+
text: '',
|
|
1027
|
+
isCompleted: false,
|
|
1028
|
+
isReset: true,
|
|
1029
|
+
resetMessage
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
return next;
|
|
1034
|
+
});
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (eventType === 'llm_stream') {
|
|
1039
|
+
this.queueStreamChunk(parsedData);
|
|
1040
|
+
} else {
|
|
1041
|
+
this.sessionLogs.update((logs) => {
|
|
1042
|
+
// Copying every log object on every event is wasted work when no
|
|
1043
|
+
// stream is open; only rewrite entries that actually change.
|
|
1044
|
+
const hasOpenStream = logs.some(
|
|
1045
|
+
(l) => l.type === 'llm_stream' && !l.data.isCompleted
|
|
1046
|
+
);
|
|
1047
|
+
const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => {
|
|
1048
|
+
if (l.type === 'llm_stream' && !l.data.isCompleted) {
|
|
1049
|
+
return {
|
|
1050
|
+
...l,
|
|
1051
|
+
data: { ...l.data, isCompleted: true }
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
return l;
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
const evtTime = typeof parsedData?.timestamp === 'number'
|
|
1058
|
+
? new Date(parsedData.timestamp * 1000).toISOString()
|
|
1059
|
+
: new Date().toISOString();
|
|
1060
|
+
|
|
1061
|
+
const nextLog = {
|
|
1062
|
+
type: eventType,
|
|
1063
|
+
timestamp: evtTime,
|
|
1064
|
+
data: parsedData
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
if (eventType === 'trace_recorded' && parsedData?.trace_id) {
|
|
1068
|
+
const existingTraceIndex = updatedLogs.findIndex((log) =>
|
|
1069
|
+
log.type === 'trace_recorded'
|
|
1070
|
+
&& log.data?.trace_id === parsedData.trace_id
|
|
1071
|
+
);
|
|
1072
|
+
if (existingTraceIndex > -1) {
|
|
1073
|
+
const deduplicatedLogs = [...updatedLogs];
|
|
1074
|
+
deduplicatedLogs[existingTraceIndex] = nextLog;
|
|
1075
|
+
return deduplicatedLogs;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return [...updatedLogs, nextLog];
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (eventType === 'trace_recorded' && parsedData) {
|
|
1084
|
+
const trName = parsedData.name || '';
|
|
1085
|
+
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
|
|
1086
|
+
const curSessionId = this.currentSessionId();
|
|
1087
|
+
if (curSessionId) {
|
|
1088
|
+
this.fetchNotes(curSessionId);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
console.error(`Failed to parse ${eventType} event data:`, e);
|
|
1094
|
+
this.sessionLogs.update((logs) => [
|
|
1095
|
+
...logs,
|
|
1096
|
+
{
|
|
1097
|
+
type: eventType,
|
|
1098
|
+
timestamp: new Date().toISOString(),
|
|
1099
|
+
data: event.data
|
|
1100
|
+
}
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
this.eventSource.onerror = (err) => {
|
|
1107
|
+
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
|
|
1108
|
+
};
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
|
|
1114
|
+
* once per short window (and immediately before any non-stream event so the
|
|
1115
|
+
* relative ordering of stream text and step events is preserved).
|
|
1116
|
+
*/
|
|
1117
|
+
private queueStreamChunk(parsedData: any): void {
|
|
1118
|
+
const execId = parsedData.execution_id;
|
|
1119
|
+
const stepId = parsedData.step_id;
|
|
1120
|
+
const sessionId = parsedData.session_id || null;
|
|
1121
|
+
// The emitting agent's trace: lets the aggregator route a concurrent
|
|
1122
|
+
// agent's stream (e.g. the Checker) to its own block instead of the
|
|
1123
|
+
// Operator step that happens to be current.
|
|
1124
|
+
const parentTraceId = parsedData.parent_trace_id || null;
|
|
1125
|
+
const streamType = parsedData.stream_type || 'text';
|
|
1126
|
+
const key = `${execId}|${streamType}`;
|
|
1127
|
+
const chunk = parsedData.chunk !== undefined ? parsedData.chunk : (parsedData.text || '');
|
|
1128
|
+
const pending = this.pendingStreamChunks.get(key);
|
|
1129
|
+
if (pending) {
|
|
1130
|
+
pending.chunk += chunk;
|
|
1131
|
+
if (stepId) pending.stepId = stepId;
|
|
1132
|
+
if (sessionId) pending.sessionId = sessionId;
|
|
1133
|
+
if (parentTraceId) pending.parentTraceId = parentTraceId;
|
|
1134
|
+
} else {
|
|
1135
|
+
this.pendingStreamChunks.set(key, { execId, stepId, sessionId, parentTraceId, streamType, chunk });
|
|
1136
|
+
}
|
|
1137
|
+
if (!this.streamFlushTimer) {
|
|
1138
|
+
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
|
|
1139
|
+
this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private discardPendingStreamChunks(): void {
|
|
1144
|
+
this.pendingStreamChunks?.clear();
|
|
1145
|
+
if (this.streamFlushTimer) {
|
|
1146
|
+
clearTimeout(this.streamFlushTimer);
|
|
1147
|
+
this.streamFlushTimer = null;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private flushStreamChunks(): void {
|
|
1152
|
+
if (this.streamFlushTimer) {
|
|
1153
|
+
clearTimeout(this.streamFlushTimer);
|
|
1154
|
+
this.streamFlushTimer = null;
|
|
1155
|
+
}
|
|
1156
|
+
if (!this.pendingStreamChunks?.size) return;
|
|
1157
|
+
const batches = Array.from(this.pendingStreamChunks.values());
|
|
1158
|
+
this.pendingStreamChunks.clear();
|
|
1159
|
+
|
|
1160
|
+
this.sessionLogs.update((logs) => {
|
|
1161
|
+
let next = [...logs];
|
|
1162
|
+
for (const batch of batches) {
|
|
1163
|
+
const existingIndex = next.findIndex(
|
|
1164
|
+
(l) => l.type === 'llm_stream' &&
|
|
1165
|
+
l.data.execution_id === batch.execId &&
|
|
1166
|
+
(l.data.stream_type || 'text') === batch.streamType
|
|
1167
|
+
);
|
|
1168
|
+
|
|
1169
|
+
if (existingIndex > -1) {
|
|
1170
|
+
const existingLog = next[existingIndex];
|
|
1171
|
+
next[existingIndex] = {
|
|
1172
|
+
...existingLog,
|
|
1173
|
+
session_id: batch.sessionId || existingLog.session_id,
|
|
1174
|
+
data: {
|
|
1175
|
+
...existingLog.data,
|
|
1176
|
+
session_id: batch.sessionId || existingLog.data?.session_id,
|
|
1177
|
+
text: (existingLog.data.text || '') + batch.chunk,
|
|
1178
|
+
step_id: batch.stepId || existingLog.data.step_id,
|
|
1179
|
+
parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
} else {
|
|
1183
|
+
// A new execution closes the previous open stream of the same agent
|
|
1184
|
+
// lane only; concurrent agents (Operator vs. Checker) keep theirs.
|
|
1185
|
+
next = next.map((l) => {
|
|
1186
|
+
if (l.type === 'llm_stream' &&
|
|
1187
|
+
(l.data.stream_type || 'text') === batch.streamType &&
|
|
1188
|
+
!l.data.isCompleted &&
|
|
1189
|
+
(l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
|
|
1190
|
+
return { ...l, data: { ...l.data, isCompleted: true } };
|
|
1191
|
+
}
|
|
1192
|
+
return l;
|
|
1193
|
+
});
|
|
1194
|
+
next.push({
|
|
1195
|
+
type: 'llm_stream',
|
|
1196
|
+
timestamp: new Date().toISOString(),
|
|
1197
|
+
session_id: batch.sessionId || undefined,
|
|
1198
|
+
data: {
|
|
1199
|
+
execution_id: batch.execId,
|
|
1200
|
+
session_id: batch.sessionId || undefined,
|
|
1201
|
+
step_id: batch.stepId,
|
|
1202
|
+
parent_trace_id: batch.parentTraceId || null,
|
|
1203
|
+
text: batch.chunk,
|
|
1204
|
+
stream_type: batch.streamType,
|
|
1205
|
+
isCompleted: false
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return next;
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Load a fresh persisted snapshot without replacing events already received
|
|
1216
|
+
* from SSE. Snapshot logs are replaceable, while live logs remain append-only;
|
|
1217
|
+
* the stream aggregator merges matching steps and trace IDs within each step.
|
|
1218
|
+
*/
|
|
1219
|
+
private backfillSessionSteps(sessionId: string, loadGeneration = this.sessionLoadGeneration): void {
|
|
1220
|
+
const requestId = ++this.sessionSnapshotRequestId;
|
|
1221
|
+
this.pendingSnapshotRequests.add(requestId);
|
|
1222
|
+
this.http.get<StepItemData[]>(`/api/sessions/${sessionId}/steps`).subscribe({
|
|
1223
|
+
next: (steps) => {
|
|
1224
|
+
this.pendingSnapshotRequests.delete(requestId);
|
|
1225
|
+
if (
|
|
1226
|
+
this.currentSessionId() !== sessionId
|
|
1227
|
+
|| loadGeneration !== this.sessionLoadGeneration
|
|
1228
|
+
|| requestId < this.sessionSnapshotAppliedId
|
|
1229
|
+
) return;
|
|
1230
|
+
this.sessionSnapshotAppliedId = requestId;
|
|
1231
|
+
const historicalLogs = steps.map((step) => ({
|
|
1232
|
+
type: 'step_updated',
|
|
1233
|
+
session_id: sessionId,
|
|
1234
|
+
timestamp: new Date((step.timestamp || Date.now() / 1000) * 1000).toISOString(),
|
|
1235
|
+
data: step,
|
|
1236
|
+
history_snapshot: true
|
|
1237
|
+
}));
|
|
1238
|
+
|
|
1239
|
+
this.sessionLogs.update((logs) => [
|
|
1240
|
+
...historicalLogs,
|
|
1241
|
+
...logs.filter((log) => !log.history_snapshot)
|
|
1242
|
+
]);
|
|
1243
|
+
this.isSessionContentLoading.set(false);
|
|
1244
|
+
},
|
|
1245
|
+
error: (err) => {
|
|
1246
|
+
this.pendingSnapshotRequests.delete(requestId);
|
|
1247
|
+
console.error('Failed to backfill session steps:', err);
|
|
1248
|
+
if (
|
|
1249
|
+
this.currentSessionId() === sessionId
|
|
1250
|
+
&& loadGeneration === this.sessionLoadGeneration
|
|
1251
|
+
&& this.pendingSnapshotRequests.size === 0
|
|
1252
|
+
) {
|
|
1253
|
+
this.isSessionContentLoading.set(false);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
|
|
1259
|
+
next: (events) => {
|
|
1260
|
+
if (Array.isArray(events) && events.length > 0) {
|
|
1261
|
+
this.startupProgressBySession.update((current) => {
|
|
1262
|
+
const existing = current[sessionId] || [];
|
|
1263
|
+
const merged = [...existing];
|
|
1264
|
+
for (const ev of events) {
|
|
1265
|
+
if (!merged.some((m) => m.stage === ev.stage)) {
|
|
1266
|
+
merged.push(ev);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
...current,
|
|
1271
|
+
[sessionId]: merged
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
error: () => {}
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private restoreSessionsCache(): void {
|
|
1281
|
+
try {
|
|
1282
|
+
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
|
1283
|
+
if (!cached) return;
|
|
1284
|
+
const sessions = JSON.parse(cached);
|
|
1285
|
+
if (Array.isArray(sessions)) {
|
|
1286
|
+
this.lastPersistedSessionsJson = cached;
|
|
1287
|
+
this.rawSessions.set(sessions);
|
|
1288
|
+
}
|
|
1289
|
+
} catch {
|
|
1290
|
+
this.clearSessionsCache();
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private persistSessionsCache(sessions: Session[]): void {
|
|
1295
|
+
try {
|
|
1296
|
+
const serialized = JSON.stringify(sessions);
|
|
1297
|
+
if (serialized === this.lastPersistedSessionsJson) return;
|
|
1298
|
+
this.lastPersistedSessionsJson = serialized;
|
|
1299
|
+
localStorage.setItem(SESSION_CACHE_KEY, serialized);
|
|
1300
|
+
} catch {
|
|
1301
|
+
// Storage can be unavailable in private browsing or embedded contexts.
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
private clearSessionsCache(): void {
|
|
1306
|
+
this.lastPersistedSessionsJson = null;
|
|
1307
|
+
try {
|
|
1308
|
+
localStorage.removeItem(SESSION_CACHE_KEY);
|
|
1309
|
+
} catch {
|
|
1310
|
+
// No-op when browser storage is unavailable.
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Convert the dedicated live retry event into the same trace shape used by
|
|
1316
|
+
* history APIs. This keeps live streaming and post-refresh rendering on one
|
|
1317
|
+
* data contract instead of maintaining a separate retry UI path.
|
|
1318
|
+
*/
|
|
1319
|
+
private appendLiveLLMRetryTrace(data: any, sessionId: string): void {
|
|
1320
|
+
const timestampSeconds = typeof data?.timestamp === 'number'
|
|
1321
|
+
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
|
1322
|
+
: Date.now() / 1000;
|
|
1323
|
+
const traceId = data?.trace_id
|
|
1324
|
+
|| `llm-retry-live-${data?.request_id || 'unknown'}-${timestampSeconds}`;
|
|
1325
|
+
const payload = {
|
|
1326
|
+
error: data?.error,
|
|
1327
|
+
delay: data?.delay,
|
|
1328
|
+
attempt: data?.attempt,
|
|
1329
|
+
max_retries: data?.max_retries,
|
|
1330
|
+
provider: data?.provider,
|
|
1331
|
+
source: data?.source,
|
|
1332
|
+
recoverable: data?.recoverable,
|
|
1333
|
+
request_id: data?.request_id,
|
|
1334
|
+
scheduled_at: data?.scheduled_at ?? timestampSeconds
|
|
1335
|
+
};
|
|
1336
|
+
const retryTrace = {
|
|
1337
|
+
trace_id: traceId,
|
|
1338
|
+
session_id: sessionId,
|
|
1339
|
+
step_id: data?.step_id || null,
|
|
1340
|
+
type: 'llm_call',
|
|
1341
|
+
name: 'llm_retry',
|
|
1342
|
+
timestamp: timestampSeconds,
|
|
1343
|
+
status: 'retrying',
|
|
1344
|
+
payload
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
this.sessionLogs.update((logs) => {
|
|
1348
|
+
const existingIndex = logs.findIndex((log) =>
|
|
1349
|
+
log.type === 'trace_recorded'
|
|
1350
|
+
&& (
|
|
1351
|
+
log.data?.trace_id === traceId
|
|
1352
|
+
|| (
|
|
1353
|
+
data?.request_id
|
|
1354
|
+
&& log.data?.payload?.request_id === data.request_id
|
|
1355
|
+
&& Number(log.data?.payload?.scheduled_at) === Number(payload.scheduled_at)
|
|
1356
|
+
)
|
|
1357
|
+
)
|
|
1358
|
+
);
|
|
1359
|
+
const retryLog = {
|
|
1360
|
+
type: 'trace_recorded',
|
|
1361
|
+
session_id: sessionId,
|
|
1362
|
+
timestamp: new Date(timestampSeconds * 1000).toISOString(),
|
|
1363
|
+
data: retryTrace
|
|
1364
|
+
};
|
|
1365
|
+
if (existingIndex < 0) return [...logs, retryLog];
|
|
1366
|
+
|
|
1367
|
+
const updatedLogs = [...logs];
|
|
1368
|
+
updatedLogs[existingIndex] = retryLog;
|
|
1369
|
+
return updatedLogs;
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Preserve pause failures in the normal failed-LLM card stream. New runners
|
|
1375
|
+
* emit a persisted failed trace first; this fallback also supports older
|
|
1376
|
+
* runners whose task_paused event only carried an error string.
|
|
1377
|
+
*/
|
|
1378
|
+
private appendPausedErrorCard(
|
|
1379
|
+
error: unknown,
|
|
1380
|
+
sessionId: string,
|
|
1381
|
+
timestamp?: number | string,
|
|
1382
|
+
stepId?: string | null,
|
|
1383
|
+
details?: any
|
|
1384
|
+
): void {
|
|
1385
|
+
const errorText = typeof error === 'string' ? error : JSON.stringify(error);
|
|
1386
|
+
const pauseKey = `${sessionId}:${errorText}`;
|
|
1387
|
+
if (this.activePauseCardKey === pauseKey) return;
|
|
1388
|
+
|
|
1389
|
+
this.sessionLogs.update((logs) => {
|
|
1390
|
+
const alreadyRecorded = logs.some((log) => {
|
|
1391
|
+
const traces = log?.type === 'trace_recorded'
|
|
1392
|
+
? [log.data]
|
|
1393
|
+
: (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []);
|
|
1394
|
+
return traces.some((trace: any) =>
|
|
1395
|
+
trace?.type === 'llm_call'
|
|
1396
|
+
&& trace?.status === 'failed'
|
|
1397
|
+
&& String(trace?.payload?.error ?? trace?.error ?? '') === errorText
|
|
1398
|
+
);
|
|
1399
|
+
});
|
|
1400
|
+
if (alreadyRecorded) return logs;
|
|
1401
|
+
|
|
1402
|
+
const latestStepId = stepId || [...logs].reverse().find((log) =>
|
|
1403
|
+
(log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id
|
|
1404
|
+
)?.data?.step_id || null;
|
|
1405
|
+
const timestampMs = typeof timestamp === 'number'
|
|
1406
|
+
? (timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
|
1407
|
+
: (timestamp ? new Date(timestamp).getTime() : Date.now());
|
|
1408
|
+
|
|
1409
|
+
return [
|
|
1410
|
+
...logs,
|
|
1411
|
+
{
|
|
1412
|
+
type: 'trace_recorded',
|
|
1413
|
+
session_id: sessionId,
|
|
1414
|
+
timestamp: new Date(timestampMs).toISOString(),
|
|
1415
|
+
data: {
|
|
1416
|
+
trace_id: `task-paused-${sessionId}-${timestampMs}`,
|
|
1417
|
+
session_id: sessionId,
|
|
1418
|
+
step_id: latestStepId,
|
|
1419
|
+
type: 'llm_call',
|
|
1420
|
+
name: 'llm_pause',
|
|
1421
|
+
status: 'failed',
|
|
1422
|
+
timestamp: timestampMs / 1000,
|
|
1423
|
+
payload: {
|
|
1424
|
+
error: errorText,
|
|
1425
|
+
pause: true,
|
|
1426
|
+
request_id: details?.request_id,
|
|
1427
|
+
provider: details?.provider,
|
|
1428
|
+
waited_seconds: details?.waited_seconds,
|
|
1429
|
+
retries: Array.isArray(details?.retries) ? details.retries : []
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
];
|
|
1434
|
+
});
|
|
1435
|
+
this.activePauseCardKey = pauseKey;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private pollCounter: number = 0;
|
|
1439
|
+
|
|
1440
|
+
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
|
|
1441
|
+
if (!sessionId) return;
|
|
1442
|
+
|
|
1443
|
+
this.invalidateStatusSignatures();
|
|
1444
|
+
this.rawSessions.update((sessions) => sessions.map((session) =>
|
|
1445
|
+
session.session_id === sessionId ? { ...session, status } : session
|
|
1446
|
+
));
|
|
1447
|
+
this.pendingQueue.update((sessions) => sessions.map((session) =>
|
|
1448
|
+
session.session_id === sessionId ? { ...session, status } : session
|
|
1449
|
+
));
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
private applySessionEndedStatus(sessionId: unknown, data: any): void {
|
|
1453
|
+
if (!sessionId) return;
|
|
1454
|
+
|
|
1455
|
+
const reportedStatus = String(data?.status || '').toLowerCase();
|
|
1456
|
+
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
|
|
1457
|
+
? 'cancelled'
|
|
1458
|
+
: reportedStatus === 'failed'
|
|
1459
|
+
? 'failed'
|
|
1460
|
+
: reportedStatus === 'completed' || reportedStatus === 'success'
|
|
1461
|
+
? 'completed'
|
|
1462
|
+
: null;
|
|
1463
|
+
|
|
1464
|
+
if (status) {
|
|
1465
|
+
this.activeSessionTracking.delete(String(sessionId));
|
|
1466
|
+
this.setSessionStatus(String(sessionId), status);
|
|
1467
|
+
this.invalidateStatusSignatures();
|
|
1468
|
+
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Start periodic status polling from the backend
|
|
1474
|
+
*/
|
|
1475
|
+
private startStatusPolling(): void {
|
|
1476
|
+
this.fetchStatus();
|
|
1477
|
+
// The interval runs outside the Angular zone so a poll tick alone never
|
|
1478
|
+
// schedules change detection; only actual signal updates do.
|
|
1479
|
+
this.zone.runOutsideAngular(() => {
|
|
1480
|
+
this.statusInterval = setInterval(() => {
|
|
1481
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
1482
|
+
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
|
1483
|
+
}
|
|
1484
|
+
this.fetchStatus();
|
|
1485
|
+
this.pollCounter++;
|
|
1486
|
+
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
|
1487
|
+
if (this.pollCounter % 3 === 0) {
|
|
1488
|
+
this.fetchSessions();
|
|
1489
|
+
}
|
|
1490
|
+
}, 2000);
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Optimistic local mutations of queue/active state must force the next poll
|
|
1496
|
+
* to re-apply backend data even when its payload has not changed.
|
|
1497
|
+
*/
|
|
1498
|
+
private invalidateStatusSignatures(): void {
|
|
1499
|
+
this.lastQueueSignature = null;
|
|
1500
|
+
this.lastActiveTasksSignature = null;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Fetch current agent runner process status
|
|
1505
|
+
*/
|
|
1506
|
+
public fetchStatus(): void {
|
|
1507
|
+
this.http.get<any>('/api/status').subscribe({
|
|
1508
|
+
next: (data) => {
|
|
1509
|
+
if (data && data.status) {
|
|
1510
|
+
const oldStatus = this.agentStatus();
|
|
1511
|
+
const oldRunningSessionId = this.runningSessionId();
|
|
1512
|
+
const isActive = data.status === 'running' || data.status === 'paused';
|
|
1513
|
+
this.agentStatus.set(data.status);
|
|
1514
|
+
this.runningSessionId.set(data.session_id || null);
|
|
1515
|
+
this.runningGoal.set(data.goal || null);
|
|
1516
|
+
if (data.model_info) {
|
|
1517
|
+
this.activeModel.set(data.model_info);
|
|
1518
|
+
}
|
|
1519
|
+
this.isPaused.set(data.status === 'paused');
|
|
1520
|
+
|
|
1521
|
+
if (data.status === 'paused') {
|
|
1522
|
+
this.isRetrying.set(false);
|
|
1523
|
+
const pauseError = data.paused_error
|
|
1524
|
+
|| this.pausedError()
|
|
1525
|
+
|| 'AI model request failed. The task is paused.';
|
|
1526
|
+
this.pausedError.set(pauseError);
|
|
1527
|
+
if (data.session_id && this.currentSessionId() === data.session_id) {
|
|
1528
|
+
this.appendPausedErrorCard(pauseError, data.session_id);
|
|
1529
|
+
}
|
|
1530
|
+
} else {
|
|
1531
|
+
this.pausedError.set(null);
|
|
1532
|
+
this.activePauseCardKey = null;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Skip signal updates when backend payloads did not change: setting a
|
|
1536
|
+
// fresh array reference every 2s poll would force the whole sessions
|
|
1537
|
+
// computed chain to re-derive for identical data.
|
|
1538
|
+
const queueSignature = JSON.stringify(data.queue || []);
|
|
1539
|
+
if (queueSignature !== this.lastQueueSignature) {
|
|
1540
|
+
this.lastQueueSignature = queueSignature;
|
|
1541
|
+
// Map backend pending queue to real Session / TaskQueueItem objects
|
|
1542
|
+
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
|
1543
|
+
if (typeof item === 'object' && item !== null) {
|
|
1544
|
+
return {
|
|
1545
|
+
session_id: item.session_id || `pending-task-${index}`,
|
|
1546
|
+
initial_goal: item.goal || '',
|
|
1547
|
+
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
|
1548
|
+
status: item.status || 'pending',
|
|
1549
|
+
device_serial: item.device_serial || item.device_id || null
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
session_id: `task-queued-${index}`,
|
|
1554
|
+
initial_goal: String(item),
|
|
1555
|
+
start_time: (Date.now() / 1000) + index,
|
|
1556
|
+
status: 'pending'
|
|
1557
|
+
};
|
|
1558
|
+
});
|
|
1559
|
+
this.pendingQueue.set(pending);
|
|
1560
|
+
}
|
|
1561
|
+
const activeTasksSignature = JSON.stringify(data.active_tasks || []);
|
|
1562
|
+
if (activeTasksSignature !== this.lastActiveTasksSignature) {
|
|
1563
|
+
this.lastActiveTasksSignature = activeTasksSignature;
|
|
1564
|
+
this.activeTasks.set(data.active_tasks || []);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
|
1568
|
+
this.fetchSessions();
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
if (
|
|
1572
|
+
!isActive
|
|
1573
|
+
&& (oldStatus === 'running' || oldStatus === 'paused')
|
|
1574
|
+
&& oldRunningSessionId
|
|
1575
|
+
&& this.isVideoWindowOpen()
|
|
1576
|
+
&& this.activeVideoSessionId === oldRunningSessionId
|
|
1577
|
+
&& this.recordingPlaybackStatus() === 'live'
|
|
1578
|
+
) {
|
|
1579
|
+
this.beginRecordingFinalization(oldRunningSessionId);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Auto-select the active session if user has not explicitly pinned a historical session
|
|
1583
|
+
if (isActive && data.session_id) {
|
|
1584
|
+
const currentId = this.currentSessionId();
|
|
1585
|
+
if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
|
|
1586
|
+
this.selectSession(data.session_id, false);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
error: (err) => {
|
|
1592
|
+
console.error('Failed to fetch status from backend:', err);
|
|
1593
|
+
this.agentStatus.set('offline');
|
|
1594
|
+
this.runningSessionId.set(null);
|
|
1595
|
+
this.runningGoal.set(null);
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Clean up event source connection and timers on service destruction
|
|
1602
|
+
*/
|
|
1603
|
+
public destroy(): void {
|
|
1604
|
+
if (this.statusInterval) {
|
|
1605
|
+
clearInterval(this.statusInterval);
|
|
1606
|
+
this.statusInterval = null;
|
|
1607
|
+
}
|
|
1608
|
+
if (this.eventSource) {
|
|
1609
|
+
this.eventSource.close();
|
|
1610
|
+
this.eventSource = null;
|
|
1611
|
+
}
|
|
1612
|
+
this.discardPendingStreamChunks();
|
|
1613
|
+
if (typeof document !== 'undefined') {
|
|
1614
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
1615
|
+
}
|
|
1616
|
+
this.cancelVideoRetry();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Fetch notes for a specific session
|
|
1621
|
+
*/
|
|
1622
|
+
/**
|
|
1623
|
+
* Backfill the Checker's attempts and run outcome from the persisted verdict
|
|
1624
|
+
* ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
|
|
1625
|
+
* historical sessions show the same timeline blocks as live ones. Live
|
|
1626
|
+
* events for attempts already known are merged by attempt id in the
|
|
1627
|
+
* aggregator, so re-fetching is idempotent.
|
|
1628
|
+
*/
|
|
1629
|
+
public fetchChecks(sessionId: string): void {
|
|
1630
|
+
if (!sessionId) return;
|
|
1631
|
+
this.http.get<{ records?: any[]; streams?: PersistedCheckerStream[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
|
|
1632
|
+
next: (res) => {
|
|
1633
|
+
if (this.currentSessionId() !== sessionId) return;
|
|
1634
|
+
const snapshot = this.buildCheckerSnapshotLogs(
|
|
1635
|
+
sessionId, res?.records || [], res?.run_outcome || null, res?.streams || []
|
|
1636
|
+
);
|
|
1637
|
+
this.sessionLogs.update((logs) => [
|
|
1638
|
+
...logs.filter((log) => !log.checks_snapshot),
|
|
1639
|
+
...snapshot
|
|
1640
|
+
]);
|
|
1641
|
+
},
|
|
1642
|
+
error: (err) => {
|
|
1643
|
+
console.error(`Failed to fetch checks for session ${sessionId}:`, err);
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* `streams` are the attempts' persisted transcripts (what the Checker
|
|
1650
|
+
* streamed while reaching its verdict): they become the attempt's
|
|
1651
|
+
* `stream_segments`, the same shape the live `llm_stream` chunks build, so
|
|
1652
|
+
* a reopened session interleaves Thought/Work text with tool rows exactly
|
|
1653
|
+
* like the live view did.
|
|
1654
|
+
*/
|
|
1655
|
+
private buildCheckerSnapshotLogs(
|
|
1656
|
+
sessionId: string,
|
|
1657
|
+
records: any[],
|
|
1658
|
+
runOutcome: any,
|
|
1659
|
+
streams: PersistedCheckerStream[] = []
|
|
1660
|
+
): any[] {
|
|
1661
|
+
const byAttempt = new Map<string, any>();
|
|
1662
|
+
const segmentsByAttempt = new Map<string, StreamSegment[]>();
|
|
1663
|
+
for (const stream of streams || []) {
|
|
1664
|
+
if (!stream || !stream.attempt_id) continue;
|
|
1665
|
+
const segments = persistedStreamToSegments(stream);
|
|
1666
|
+
if (segments.length > 0) segmentsByAttempt.set(String(stream.attempt_id), segments);
|
|
1667
|
+
}
|
|
1668
|
+
for (const rec of records) {
|
|
1669
|
+
if (!rec || !rec.attempt_id) continue;
|
|
1670
|
+
const attemptId = String(rec.attempt_id);
|
|
1671
|
+
const checkpointId = String(rec.checkpoint_id || '');
|
|
1672
|
+
let attempt = byAttempt.get(attemptId);
|
|
1673
|
+
if (!attempt) {
|
|
1674
|
+
attempt = {
|
|
1675
|
+
event: 'attempt_finished',
|
|
1676
|
+
phase: checkpointId === 'final' ? 'final' : 'checkpoint',
|
|
1677
|
+
attempt_id: attemptId,
|
|
1678
|
+
checkpoint_id: checkpointId,
|
|
1679
|
+
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
|
|
1680
|
+
? "Final review against the user's original goal"
|
|
1681
|
+
: `Subgoal ${checkpointId.slice(0, 8)}`)),
|
|
1682
|
+
anchor_step_id: rec.anchor_step_id ?? null,
|
|
1683
|
+
trace_id: rec.trace_id ?? null,
|
|
1684
|
+
status: 'done',
|
|
1685
|
+
verdicts: [],
|
|
1686
|
+
findings: [],
|
|
1687
|
+
ts: typeof rec.ts === 'number' ? rec.ts : undefined,
|
|
1688
|
+
session_id: sessionId
|
|
1689
|
+
};
|
|
1690
|
+
byAttempt.set(attemptId, attempt);
|
|
1691
|
+
}
|
|
1692
|
+
if (typeof rec.ts === 'number') {
|
|
1693
|
+
attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
|
|
1694
|
+
}
|
|
1695
|
+
if (!attempt.trace_id && rec.trace_id) {
|
|
1696
|
+
attempt.trace_id = rec.trace_id;
|
|
1697
|
+
}
|
|
1698
|
+
attempt.verdicts.push({
|
|
1699
|
+
item_text: String(rec.item_text || ''),
|
|
1700
|
+
kind: String(rec.kind || ''),
|
|
1701
|
+
status: String(rec.status || ''),
|
|
1702
|
+
evidence: String(rec.evidence || ''),
|
|
1703
|
+
suggestion: rec.suggestion ? String(rec.suggestion) : '',
|
|
1704
|
+
when: rec.when ? String(rec.when) : undefined
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const logs: any[] = [];
|
|
1709
|
+
for (const attempt of byAttempt.values()) {
|
|
1710
|
+
const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
|
|
1711
|
+
if (statuses.size === 1) {
|
|
1712
|
+
const only = [...statuses][0];
|
|
1713
|
+
if (only === 'superseded' || only === 'unchecked') attempt.status = only;
|
|
1714
|
+
}
|
|
1715
|
+
const ts = attempt.ts ?? Date.now() / 1000;
|
|
1716
|
+
attempt.timestamp = ts;
|
|
1717
|
+
const segments = segmentsByAttempt.get(attempt.attempt_id);
|
|
1718
|
+
if (segments) attempt.stream_segments = segments;
|
|
1719
|
+
logs.push({
|
|
1720
|
+
type: 'checker_event',
|
|
1721
|
+
session_id: sessionId,
|
|
1722
|
+
timestamp: new Date(ts * 1000).toISOString(),
|
|
1723
|
+
checks_snapshot: true,
|
|
1724
|
+
data: attempt
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
if (runOutcome && typeof runOutcome === 'object') {
|
|
1728
|
+
const lastTs = logs.length > 0
|
|
1729
|
+
? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
|
|
1730
|
+
: Date.now() / 1000;
|
|
1731
|
+
const ts = lastTs + 0.001;
|
|
1732
|
+
logs.push({
|
|
1733
|
+
type: 'checker_event',
|
|
1734
|
+
session_id: sessionId,
|
|
1735
|
+
timestamp: new Date(ts * 1000).toISOString(),
|
|
1736
|
+
checks_snapshot: true,
|
|
1737
|
+
data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
return logs;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
public fetchNotes(sessionId: string): void {
|
|
1744
|
+
if (!sessionId) {
|
|
1745
|
+
this.currentNotes.set({});
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
this.http.get<any>(`/api/sessions/${sessionId}/notes`).subscribe({
|
|
1749
|
+
next: (res) => {
|
|
1750
|
+
if (res && res.notes) {
|
|
1751
|
+
this.currentNotes.set(res.notes);
|
|
1752
|
+
|
|
1753
|
+
// Default to task_plan.md if available, otherwise first note
|
|
1754
|
+
const keys = Object.keys(res.notes);
|
|
1755
|
+
if (keys.length > 0) {
|
|
1756
|
+
const currentSelected = this.selectedNoteKey();
|
|
1757
|
+
if (!keys.includes(currentSelected)) {
|
|
1758
|
+
if (keys.includes('task_plan.md')) {
|
|
1759
|
+
this.selectedNoteKey.set('task_plan.md');
|
|
1760
|
+
} else {
|
|
1761
|
+
this.selectedNoteKey.set(keys[0]);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
error: (err) => {
|
|
1768
|
+
console.error(`Failed to fetch notes for session ${sessionId}:`, err);
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Open the video player for a given session or the current session
|
|
1775
|
+
*/
|
|
1776
|
+
public openVideoPlayer(
|
|
1777
|
+
sessionId?: string,
|
|
1778
|
+
videoUrl?: string,
|
|
1779
|
+
title?: string,
|
|
1780
|
+
seekSeconds?: number,
|
|
1781
|
+
stepIndex?: number
|
|
1782
|
+
): void {
|
|
1783
|
+
const targetSessionId = sessionId || this.currentSessionId();
|
|
1784
|
+
const session = this.sessions().find(s => s.session_id === targetSessionId);
|
|
1785
|
+
const targetUrl = videoUrl || session?.video_url || null;
|
|
1786
|
+
const goalTitle = title || session?.initial_goal || (targetSessionId ? `Task: ${targetSessionId.slice(0, 8)}...` : 'Screen Recording');
|
|
1787
|
+
|
|
1788
|
+
this.activeVideoTitle.set(goalTitle);
|
|
1789
|
+
this.isVideoWindowOpen.set(true);
|
|
1790
|
+
this.isVideoMinimized.set(false);
|
|
1791
|
+
this.activeVideoSessionId = targetSessionId;
|
|
1792
|
+
this.cancelVideoRetry();
|
|
1793
|
+
this.videoRequestGeneration++;
|
|
1794
|
+
this.shouldAutoplayVideo.set(false);
|
|
1795
|
+
this.recordingPlaybackMessage.set('');
|
|
1796
|
+
this.activeVideoSegments.set([]);
|
|
1797
|
+
if (Number.isFinite(seekSeconds)) this.requestVideoSeek(Number(seekSeconds));
|
|
1798
|
+
if (Number.isFinite(stepIndex)) {
|
|
1799
|
+
this.playerMode?.set('steps');
|
|
1800
|
+
this.requestStepSeek(Number(stepIndex));
|
|
1801
|
+
}
|
|
1802
|
+
if (!targetSessionId) {
|
|
1803
|
+
this.activeVideoUrl.set(null);
|
|
1804
|
+
this.isVideoLoading.set(false);
|
|
1805
|
+
this.recordingPlaybackStatus.set('unavailable');
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (session?.status === 'running' || session?.status === 'paused') {
|
|
1810
|
+
this.activeVideoUrl.set(null);
|
|
1811
|
+
this.isVideoLoading.set(false);
|
|
1812
|
+
this.recordingPlaybackStatus.set('live');
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
this.activeVideoUrl.set(targetUrl);
|
|
1817
|
+
this.shouldAutoplayVideo.set(true);
|
|
1818
|
+
this.videoWaitStartedAt = Date.now();
|
|
1819
|
+
this.isVideoLoading.set(true);
|
|
1820
|
+
this.recordingPlaybackStatus.set('processing');
|
|
1821
|
+
this.recordingPlaybackMessage.set('Loading screen recording...');
|
|
1822
|
+
if (targetUrl) {
|
|
1823
|
+
this.playerMode?.set('video');
|
|
1824
|
+
} else if (this.hasCurrentSessionStepFrames?.()) {
|
|
1825
|
+
this.playerMode?.set('steps');
|
|
1826
|
+
} else {
|
|
1827
|
+
this.playerMode?.set('video');
|
|
1828
|
+
}
|
|
1829
|
+
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private appendStartupProgress(data: any, sessionId: string): void {
|
|
1833
|
+
if (!data?.stage || !data?.message) return;
|
|
1834
|
+
|
|
1835
|
+
const timestamp = typeof data.timestamp === 'number'
|
|
1836
|
+
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
|
1837
|
+
: Date.now() / 1000;
|
|
1838
|
+
const event: StartupProgressEvent = {
|
|
1839
|
+
session_id: String(data.session_id || sessionId),
|
|
1840
|
+
stage: String(data.stage),
|
|
1841
|
+
message: String(data.message),
|
|
1842
|
+
timestamp
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
const rawKey = String(data.session_id || sessionId).trim();
|
|
1846
|
+
this.startupProgressBySession.update((allEvents) => {
|
|
1847
|
+
const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
|
|
1848
|
+
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
|
1849
|
+
if (existingIndex >= 0) {
|
|
1850
|
+
current[existingIndex] = event;
|
|
1851
|
+
} else {
|
|
1852
|
+
current.push(event);
|
|
1853
|
+
}
|
|
1854
|
+
current.sort((a, b) => a.timestamp - b.timestamp);
|
|
1855
|
+
const updated = { ...allEvents, [rawKey]: current };
|
|
1856
|
+
if (sessionId && sessionId !== rawKey) {
|
|
1857
|
+
updated[sessionId] = current;
|
|
1858
|
+
}
|
|
1859
|
+
return updated;
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private beginRecordingFinalization(sessionId: string): void {
|
|
1864
|
+
if (this.activeVideoSessionId !== sessionId) return;
|
|
1865
|
+
this.cancelVideoRetry();
|
|
1866
|
+
this.videoRequestGeneration++;
|
|
1867
|
+
this.videoWaitStartedAt = Date.now();
|
|
1868
|
+
this.activeVideoUrl.set(null);
|
|
1869
|
+
this.activeVideoSegments.set([]);
|
|
1870
|
+
this.shouldAutoplayVideo.set(true);
|
|
1871
|
+
this.isVideoLoading.set(true);
|
|
1872
|
+
this.recordingPlaybackStatus.set('processing');
|
|
1873
|
+
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
|
1874
|
+
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
private requestSessionVideo(sessionId: string, generation: number): void {
|
|
1878
|
+
this.http.get<SessionVideoResponse>(`/api/sessions/${sessionId}/video`).subscribe({
|
|
1879
|
+
next: (res) => {
|
|
1880
|
+
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable');
|
|
1884
|
+
if (status === 'ready' && res.video_url) {
|
|
1885
|
+
this.cancelVideoRetry();
|
|
1886
|
+
this.isVideoLoading.set(false);
|
|
1887
|
+
this.recordingPlaybackStatus.set('ready');
|
|
1888
|
+
this.recordingPlaybackMessage.set('');
|
|
1889
|
+
this.activeVideoUrl.set(res.video_url);
|
|
1890
|
+
this.activeVideoSegments.set(res.video_segments || []);
|
|
1891
|
+
this.playerMode?.set('video');
|
|
1892
|
+
this.rawSessions.update((list) =>
|
|
1893
|
+
list.map((s) => s.session_id === sessionId
|
|
1894
|
+
? { ...s, video_url: res.video_url || undefined, recording_status: 'ready' }
|
|
1895
|
+
: s)
|
|
1896
|
+
);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (status === 'processing') {
|
|
1900
|
+
this.activeVideoUrl.set(null);
|
|
1901
|
+
this.activeVideoSegments.set([]);
|
|
1902
|
+
this.isVideoLoading.set(true);
|
|
1903
|
+
this.recordingPlaybackStatus.set('processing');
|
|
1904
|
+
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
|
1905
|
+
this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
this.cancelVideoRetry();
|
|
1910
|
+
this.isVideoLoading.set(false);
|
|
1911
|
+
this.activeVideoUrl.set(null);
|
|
1912
|
+
this.activeVideoSegments.set([]);
|
|
1913
|
+
this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable');
|
|
1914
|
+
this.recordingPlaybackMessage.set(
|
|
1915
|
+
res.message || (status === 'failed'
|
|
1916
|
+
? 'Recording finalization failed.'
|
|
1917
|
+
: 'No screen recording is available for this task.')
|
|
1918
|
+
);
|
|
1919
|
+
if (this.hasCurrentSessionStepFrames?.()) {
|
|
1920
|
+
this.playerMode?.set('steps');
|
|
1921
|
+
}
|
|
1922
|
+
},
|
|
1923
|
+
error: () => {
|
|
1924
|
+
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
if (this.recordingPlaybackStatus() === 'processing') {
|
|
1928
|
+
this.scheduleVideoRetry(sessionId, generation, 1000);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
this.isVideoLoading.set(false);
|
|
1932
|
+
this.recordingPlaybackStatus.set('failed');
|
|
1933
|
+
this.recordingPlaybackMessage.set('Unable to load the screen recording.');
|
|
1934
|
+
if (this.hasCurrentSessionStepFrames?.()) {
|
|
1935
|
+
this.playerMode?.set('steps');
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
private scheduleVideoRetry(sessionId: string, generation: number, retryAfterMs = 1000): void {
|
|
1942
|
+
this.cancelVideoRetry();
|
|
1943
|
+
if (Date.now() - this.videoWaitStartedAt > 120_000) {
|
|
1944
|
+
this.isVideoLoading.set(false);
|
|
1945
|
+
this.recordingPlaybackStatus.set('failed');
|
|
1946
|
+
this.recordingPlaybackMessage.set('Recording finalization timed out. You can retry.');
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const delay = Math.max(500, Math.min(3000, retryAfterMs));
|
|
1950
|
+
this.videoRetryTimer = setTimeout(() => {
|
|
1951
|
+
this.videoRetryTimer = null;
|
|
1952
|
+
this.requestSessionVideo(sessionId, generation);
|
|
1953
|
+
}, delay);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private cancelVideoRetry(): void {
|
|
1957
|
+
if (this.videoRetryTimer) {
|
|
1958
|
+
clearTimeout(this.videoRetryTimer);
|
|
1959
|
+
this.videoRetryTimer = null;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
private refreshActiveRecording(autoplay: boolean): void {
|
|
1964
|
+
const sessionId = this.activeVideoSessionId;
|
|
1965
|
+
if (!sessionId) return;
|
|
1966
|
+
this.cancelVideoRetry();
|
|
1967
|
+
this.videoRequestGeneration++;
|
|
1968
|
+
if (autoplay) this.shouldAutoplayVideo.set(true);
|
|
1969
|
+
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
public retryVideoRecording(): void {
|
|
1973
|
+
const sessionId = this.activeVideoSessionId;
|
|
1974
|
+
if (!sessionId) return;
|
|
1975
|
+
this.beginRecordingFinalization(sessionId);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
public consumeVideoAutoplay(): boolean {
|
|
1979
|
+
const shouldAutoplay = this.shouldAutoplayVideo();
|
|
1980
|
+
this.shouldAutoplayVideo.set(false);
|
|
1981
|
+
return shouldAutoplay;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
public requestVideoSeek(seconds: number): void {
|
|
1985
|
+
if (!Number.isFinite(seconds)) return;
|
|
1986
|
+
this.videoSeekRequest.set({
|
|
1987
|
+
seconds: Math.max(0, seconds),
|
|
1988
|
+
requestId: ++this.videoSeekRequestId
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Toggle the video player for the current session
|
|
1994
|
+
*/
|
|
1995
|
+
public toggleVideoPlayer(): void {
|
|
1996
|
+
if (this.isVideoWindowOpen()) {
|
|
1997
|
+
this.isVideoWindowOpen.set(false);
|
|
1998
|
+
} else {
|
|
1999
|
+
this.openVideoPlayer();
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Close the video player
|
|
2005
|
+
*/
|
|
2006
|
+
public closeVideoPlayer(): void {
|
|
2007
|
+
this.isVideoWindowOpen.set(false);
|
|
2008
|
+
this.cancelVideoRetry();
|
|
2009
|
+
this.videoRequestGeneration++;
|
|
2010
|
+
this.activeVideoSessionId = null;
|
|
2011
|
+
}
|
|
2012
|
+
}
|