diff --git a/frontend/README.md b/frontend/README.md index 15881ace9..e0e51e4b3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -600,9 +600,22 @@ See [deployment and operation](service/studio_release_notifier/README.md). the framework, entry point, and open questions. Structured frameworks run the preinstalled `ak migrate`; Dify and Any projects run `ak migrate --execution in-place` with Codex in the same Session. Evaluation - deploys a temporary Runtime, checkpoints per-case execution as JSONL, judges - batches in one fresh resumable Codex thread, and always reconciles Runtime - cleanup before completing or cancelling. Reports show 0–100 display scores, + deploys a temporary Runtime, checkpoints per-case execution as JSONL, and + judges batches in one fresh resumable Codex thread. Analysis and judging both + deliver their contract through an app-server dynamic tool instead of parsing + free text, and the analysis turn runs on a Studio background worker so a long + analysis never waits inside the upload request. Each judged batch is a durable + request the runner writes into the Session: Studio answers it with one + app-server turn carrying the verdict, and the runner validates and caches that + verdict before the next batch. A request stays on disk until it is answered, so + a Studio restart replays the same batch; a request that cannot be answered + falls back to `codex exec` inside the same batch budget. The request declares + how long the runner will listen, and the turn is sized to answer inside that + window, so even a judge batch that is too slow comes back as an answered + failure rather than a timeout. Setting + `AGENTKIT_MIGRATION_JUDGE_APP_SERVER=0` pins that scripted judge for the + whole run instead of writing judge requests at all. Cleanup is always + reconciled before completing or cancelling. Reports show 0–100 display scores, execution success, evidence coverage, N/A counts, low-scoring and failed cases, versions, evidence severity, and cleanup status without a pass/fail verdict. Each raw judge score is rounded half up to a 0–100 integer before diff --git a/frontend/server/migration/activity.py b/frontend/server/migration/activity.py new file mode 100644 index 000000000..3141f2e60 --- /dev/null +++ b/frontend/server/migration/activity.py @@ -0,0 +1,473 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Record an app-server analysis turn as the activity log the page already reads. + +The scripted driver gets this log for free: ``codex exec --json`` writes its event +stream inside the Sandbox, and ``MigrationService.activity`` parses it into the items +the migration page renders. An app-server turn never writes that file, so analysis on +the app-server driver showed an empty activity feed even though Codex was working. + +Rather than teach the page a second source, this module translates the app-server's +typed events back into the same ``codex exec --json`` line shape, so exactly one reader +(``_parse_activity_log``) serves both drivers and the Sandbox stays the source of truth. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +import json +import logging + +from veadk.cli.codex_app_server import CodexAppServerEvent + +logger = logging.getLogger(__name__) + +DEFAULT_FLUSH_SECONDS = 2.0 +DEFAULT_MAX_BYTES = 4 * 1024 * 1024 +_MAX_TEXT_CHARS = 120_000 +_TRUNCATED_MARKER = "\n…内容已截断" + +_COMPLETED_STATUSES = {"completed", "done"} +_FAILED_STATUSES = {"failed", "error", "declined"} + +# The turn's own verdict, which the reader renders as one summary row the way the +# intelligent build renders its ``turn-summary`` block. +_TERMINAL_TURN_STATUSES = {"completed", "failed", "cancelled", "interrupted"} +_TURN_EVENT_TYPES = { + "completed": "turn.completed", + "failed": "turn.failed", + "cancelled": "turn.interrupted", + "interrupted": "turn.interrupted", +} +_TURN_FIELDS = ("startedAt", "completedAt", "durationMs", "model") + +_TOOL_ITEM_TYPES = { + "commandExecution": "command_execution", + "fileChange": "file_change", + "mcpToolCall": "mcp_tool_call", + "webSearch": "web_search", +} + +_TODO_STATUSES = { + "completed": "completed", + "done": "completed", + "inprogress": "in_progress", + "in_progress": "in_progress", + "running": "in_progress", + "failed": "failed", + "error": "failed", +} + + +def _turn_status(value: object) -> str: + """Read the turn status the app-server reports as a string or a tagged object.""" + if isinstance(value, dict): + value = value.get("type") + return str(value or "").strip().lower() + + +def _text(value: object, limit: int = _MAX_TEXT_CHARS) -> str: + if not isinstance(value, str): + return "" + return value[:limit] + + +def _bounded(value: object, limit: int = _MAX_TEXT_CHARS) -> object: + """Keep a payload small; the parser truncates and redacts what it renders.""" + if isinstance(value, str): + return value[:limit] + return value + + +class AnalysisActivityLog: + """Turn an app-server turn's events into an append-only analysis activity log. + + ``record`` is the turn's event sink, so it must stay cheap: events are buffered and + the Sandbox file is replaced as a whole by ``flush``, which the caller runs on a + timer and once more when the turn ends. A flush is best-effort by design — an + activity feed must never fail an analysis. + + Studio's own dynamic tools are normally the turn's contract rather than page + content. The delivery turn is the exception: its ``publishArtifact`` call *is* the + hand-over of the deliverable, so ``include_dynamic_tools`` records it the way the + intelligent build records its result tool. + """ + + def __init__( + self, + write: Callable[[bytes], None], + *, + flush_seconds: float = DEFAULT_FLUSH_SECONDS, + max_bytes: int = DEFAULT_MAX_BYTES, + include_dynamic_tools: bool = False, + ) -> None: + self._write = write + self._flush_seconds = max(0.1, flush_seconds) + self._max_bytes = max(1, max_bytes) + self._include_dynamic_tools = include_dynamic_tools + self._lines: list[str] = [] + self._dirty = False + self._texts: dict[str, str] = {} + self._outputs: dict[str, str] = {} + self._names: dict[str, str] = {} + self._commands: dict[str, str] = {} + self._turn: dict[str, object] = {} + self._usage: dict[str, object] = {} + + @property + def lines(self) -> list[str]: + return list(self._lines) + + def line(self, event: CodexAppServerEvent) -> dict[str, object] | None: + """One ``codex exec --json`` line for ``event``; ``None`` when it has no item.""" + turn = self._turn_line(event) + if turn is not None: + return turn + item = self._item(event) + if item is None: + return None + return {"type": self._event_type(event), "item": item} + + def record(self, event: object) -> None: + if not isinstance(event, CodexAppServerEvent): + return + line = self.line(event) + if line is None: + return + self._lines.append(json.dumps(line, ensure_ascii=False)) + self._dirty = True + + def flush(self) -> None: + """Replace the Sandbox log with everything recorded so far.""" + if not self._dirty: + return + self._write(self._content()) + self._dirty = False + + def complete_dynamic_tools(self) -> None: + """Close Studio tool rows the turn ended on. + + A turn that finishes as soon as its verdict arrives can be interrupted before + the app-server reports the call as completed, which would leave the page + showing a running row for a delivery that already landed. Callers invoke this + only once they accepted the turn's outcome, so a still-running row really is a + call that succeeded. + """ + if not self._include_dynamic_tools: + return + lines: list[str] = [] + for line in self._lines: + try: + event = json.loads(line) + except ValueError: + lines.append(line) + continue + item = event.get("item") if isinstance(event, dict) else None + raw_status = ( + str(item.get("status") or "").lower() if isinstance(item, dict) else "" + ) + if ( + isinstance(item, dict) + and item.get("type") == "dynamic_tool_call" + and raw_status not in _COMPLETED_STATUSES + and raw_status not in _FAILED_STATUSES + ): + item["status"] = "completed" + self._dirty = True + lines.append(json.dumps(event, ensure_ascii=False)) + continue + lines.append(line) + self._lines = lines + + def close(self) -> None: + try: + self.flush() + except Exception as error: # noqa: BLE001 - the feed is not the analysis result + logger.warning( + "Studio migration activity log could not be written error_type=%s", + type(error).__name__, + ) + + async def run(self) -> None: + """Flush on a timer for as long as the caller keeps this task alive.""" + while True: + await asyncio.sleep(self._flush_seconds) + with contextlib.suppress(Exception): + await asyncio.to_thread(self.flush) + + async def aclose(self) -> None: + await asyncio.to_thread(self.close) + + def _content(self) -> bytes: + lines = list(self._lines) + size = sum(len(line) + 1 for line in lines) + while lines and size > self._max_bytes: + size -= len(lines.pop(0)) + 1 + return ("\n".join(lines) + "\n").encode("utf-8") if lines else b"" + + def _turn_line(self, event: CodexAppServerEvent) -> dict[str, object] | None: + """One line for the turn's own cost, written when the turn settles. + + The page reports the same turn metrics the intelligent build does — how long + the turn took, how many tools it ran, what it cost in tokens — and the + intelligent build reads them off the app-server's turn lifecycle and usage + events rather than off any item. Items never carry them, so they are + accumulated here and written as one ``turn.*`` line that the reader turns into + a summary of everything logged before it. + """ + kind = str(event.kind or "") + if kind == "usage": + if event.usage is not None: + self._usage["usage"] = event.usage.public_dict() + if event.thread_total is not None: + self._usage["thread_total"] = event.thread_total.public_dict() + window = event.model_context_window + if isinstance(window, int) and not isinstance(window, bool): + self._usage["model_context_window"] = window + return None + if kind not in {"turn_started", "turn_completed"}: + return None + response = event.response if isinstance(event.response, dict) else {} + turn = {**self._turn, **response} + if event.turn_id: + turn["id"] = event.turn_id + status = _turn_status(event.status or turn.get("status")) + if kind == "turn_started" or status not in _TERMINAL_TURN_STATUSES: + self._turn = turn + return None + summary: dict[str, object] = { + "type": _TURN_EVENT_TYPES.get(status, "turn.completed"), + "turn": { + "id": str(turn.get("id") or ""), + "status": status, + **{key: turn[key] for key in _TURN_FIELDS if key in turn}, + }, + } + summary.update(self._usage) + self._turn = {} + self._usage = {} + return summary + + def _event_type(self, event: CodexAppServerEvent) -> str: + status = str(event.status or "").lower() + if status in _FAILED_STATUSES: + return "item.failed" + if status in _COMPLETED_STATUSES: + return "item.completed" + return "item.updated" + + def _item(self, event: CodexAppServerEvent) -> dict[str, object] | None: + item_id = str(event.item_id or "") + kind = str(event.kind or "") + item: dict[str, object] | None + if kind == "thinking": + item = self._message_item(item_id, "reasoning", event.text, append=False) + elif kind == "commentary": + item = self._message_item( + item_id, "agent_message", event.text, append=False + ) + elif kind == "text": + # Live deltas: the reader keeps the last text it saw for an item. + item = self._message_item(item_id, "agent_message", event.text, append=True) + elif kind in {"text_snapshot", "assistant_final"}: + item = self._message_item( + item_id, "agent_message", event.text, append=False + ) + elif kind == "tool": + item = self._tool_item(item_id, event) + elif kind == "tool_output": + item = self._output_item(item_id, event) + elif kind == "plan": + item = self._plan_item(event) + else: + return None + return self._timed(item, event) + + @staticmethod + def _timed( + item: dict[str, object] | None, + event: CodexAppServerEvent, + ) -> dict[str, object] | None: + """Carry the app-server's own phase and timing onto the log line. + + The page renders these lines the way the intelligent build renders its own + Codex activity, and that rendering reads ``phase`` and ``duration_ms`` off the + item: a line that drops them turns a call that ran for minutes into one that + looks like it never took any time. + """ + if item is None: + return None + phase = str(event.phase or "") + if phase: + item["phase"] = phase + duration = event.duration_ms + if ( + isinstance(duration, int) + and not isinstance(duration, bool) + and duration >= 0 + ): + item["duration_ms"] = duration + return item + + def _message_item( + self, + item_id: str, + item_type: str, + text: str, + *, + append: bool, + ) -> dict[str, object] | None: + value = _text(text) + if not value: + return None + if append and item_id: + value = self._append(self._texts, item_id, value) + elif item_id: + self._texts[item_id] = value + return {"id": item_id, "type": item_type, "text": value} + + def _output_item( + self, item_id: str, event: CodexAppServerEvent + ) -> dict[str, object] | None: + value = _text(event.text) + if not value: + return None + if item_id: + value = self._append(self._outputs, item_id, value) + item: dict[str, object] = { + "id": item_id, + "type": "command_execution", + "status": str(event.status or "running") or "running", + "aggregated_output": value, + } + # 输出增量常常是这一条 id 的最后一行,而它只带增量文本。页面要从行名认这条 + # 命令、从命令本身算标签,所以把这条调用已有的身份字段补齐,让最后读到的那 + # 一行和 app-server 报完成时那一行是同一件事。 + name = self._names.get(item_id, "") if item_id else "" + if name: + item["name"] = name + command = self._commands.get(item_id, "") if item_id else "" + if command: + item["command"] = command + return item + + @staticmethod + def _append(store: dict[str, str], key: str, value: str) -> str: + combined = f"{store.get(key, '')}{value}" + if len(combined) > _MAX_TEXT_CHARS: + combined = f"{_TRUNCATED_MARKER}\n{combined[-_MAX_TEXT_CHARS:]}" + store[key] = combined + return combined + + def _tool_item( + self, + item_id: str, + event: CodexAppServerEvent, + ) -> dict[str, object] | None: + raw_type = str(event.item_type or "") + item_type = _TOOL_ITEM_TYPES.get(raw_type) + if item_type is None: + if raw_type == "dynamicToolCall" and self._include_dynamic_tools: + return self._dynamic_tool_item(item_id, event) + # Studio's own dynamic tools are the turn's contract, not page content. + return None + arguments = event.arguments if isinstance(event.arguments, dict) else {} + response = event.response if isinstance(event.response, dict) else {} + status = str(event.status or "running") or "running" + item: dict[str, object] = {"id": item_id, "type": item_type, "status": status} + # The app-server names its own rows; the page labels them from that name so a + # migration turn reads exactly like the intelligent build's turn. + name = _text(event.name, 100) + if name: + item["name"] = name + if item_id: + self._names[item_id] = name + if item_type == "command_execution": + command = _text(arguments.get("command"), 20_000) + if command: + item["command"] = command + if item_id: + self._commands[item_id] = command + output = response.get("output") + if output not in (None, ""): + item["aggregated_output"] = _bounded(output) + exit_code = response.get("exitCode") + if isinstance(exit_code, int) and not isinstance(exit_code, bool): + item["exit_code"] = exit_code + if output in (None, ""): + item["aggregated_output"] = self._outputs.get(item_id, "") + actions = arguments.get("commandActions") + if actions: + item["command_actions"] = _bounded(actions) + return item + if item_type == "file_change": + item["changes"] = _bounded(arguments.get("changes")) + return item + if item_type == "mcp_tool_call": + server, _, tool = ( + str(event.name or "").removeprefix("MCP · ").partition("/") + ) + item["server"] = server + item["tool"] = tool + item["arguments"] = _bounded(arguments) + if response: + item["result"] = _bounded(response) + return item + item["query"] = _text(arguments.get("query"), 4_000) + return item + + @staticmethod + def _dynamic_tool_item( + item_id: str, + event: CodexAppServerEvent, + ) -> dict[str, object] | None: + """One Studio tool call, in the same item shape the reader already parses.""" + item: dict[str, object] = { + "id": item_id, + "type": "dynamic_tool_call", + "status": str(event.status or "running") or "running", + "name": _text(event.name, 100) or "studio_tool", + } + arguments = event.arguments if isinstance(event.arguments, dict) else {} + if arguments: + item["arguments"] = _bounded(arguments) + response = event.response if isinstance(event.response, dict) else {} + if response: + item["result"] = _bounded(response) + return item + + def _plan_item(self, event: CodexAppServerEvent) -> dict[str, object] | None: + steps = event.response if isinstance(event.response, list) else [] + todos: list[dict[str, object]] = [] + for step in steps: + if not isinstance(step, dict): + continue + raw = str(step.get("status") or "").lower() + text = _text(step.get("step"), 4_000) + if not text: + continue + todos.append({"text": text, "status": _TODO_STATUSES.get(raw, "pending")}) + if not todos: + return None + return { + "id": f"plan-{event.turn_id}" if event.turn_id else "plan", + "type": "todo_list", + "items": todos, + } + + +__all__ = ["AnalysisActivityLog", "DEFAULT_FLUSH_SECONDS", "DEFAULT_MAX_BYTES"] diff --git a/frontend/server/migration/analysis_contract.py b/frontend/server/migration/analysis_contract.py new file mode 100644 index 000000000..09971bc74 --- /dev/null +++ b/frontend/server/migration/analysis_contract.py @@ -0,0 +1,892 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The contract Codex speaks when it delivers a project analysis. + +Two boundaries live behind this module on purpose, and they are deliberately +different: + +* Codex hands Studio a *judgement*. A probabilistic model drifts in shape, so + acceptance here is liberal: optional fields take defaults, unknown fields are + ignored, scalar types are coerced, and anything that cannot be anchored (an + evidence item without a file path) is dropped with a note instead of failing the + turn. A model that cannot write the shape must lose quality, never lose a result. +* Studio then assembles the state file itself and validates it against the strict + on-disk contract, so persistence stays exact while the model never has to be. + +Nothing here asks the model for protocol bookkeeping (``schema_version``, ``attempt``, +``input_sha256``); Studio owns those. The one thing acceptance insists on is that the +destructive ``unsupported`` verdict cites files the model-free detection actually saw, +because a verdict the user can neither act on nor retry must not be reachable by +accident. +""" + +from __future__ import annotations + +import re +from typing import NamedTuple + +from .contracts import ( + MigrationContractError, + validate_analysis_result, +) +from .models import ( + MIGRATION_FRAMEWORKS, + STRUCTURED_MIGRATION_FRAMEWORKS, + is_valid_structured_entry, +) + +RECOMMENDATION_KIND = "recommendation" +NEEDS_INPUT_KIND = "needs_input" +UNSUPPORTED_KIND = "unsupported" +ANALYSIS_KINDS = (RECOMMENDATION_KIND, NEEDS_INPUT_KIND, UNSUPPORTED_KIND) + +KIND_BY_STATUS = { + "recommendation_ready": "recommendation", + "needs_input": "needs_input", + "unsupported": "unsupported", +} +STATUS_BY_KIND = { + RECOMMENDATION_KIND: "recommendation_ready", + NEEDS_INPUT_KIND: "needs_input", + UNSUPPORTED_KIND: "unsupported", +} + +# The verdict the user cannot act on and cannot retry is the one that must clear a +# deterministic bar: it has to cite files that were really in the archive. +_MINIMUM_UNSUPPORTED_EVIDENCE = 2 +_MINIMUM_UNSUPPORTED_SUMMARY = 20 +_MINIMUM_EVIDENCE_REASON = 4 + +_MAX_SUMMARY = 20_000 +_MAX_TEXT = 4_000 + +_SCHEMA_VERSION = 1 +# A string evidence item is only read as a citation when it starts with something +# that looks like a project path: guessing a path out of free text would invent +# evidence, so text that does not qualify is dropped and reported instead. +_CITATION = re.compile( + r"^(?P[^\s::]+)" + r"(?::(?P\d{1,7}))?" + r"(?:[\s::]+(?P.*\S))?$", + re.DOTALL, +) + + +class AnalysisIssue(NamedTuple): + """One reason acceptance refused a submission, stated where it can be fixed.""" + + path: str + expected: str + actual: str + + +class AnalysisAcceptanceError(ValueError): + """The submission cannot be accepted as written; the model should fix it.""" + + def __init__(self, issues: tuple[AnalysisIssue, ...]) -> None: + self.issues = issues + super().__init__( + "; ".join(f"{issue.path}:{issue.expected}" for issue in issues) + ) + + +class AnalysisAssemblyError(RuntimeError): + """Studio assembled a document that violates its own contract (a Studio bug).""" + + +def analysis_tool_schema(kind: str) -> dict[str, object]: + """The JSON Schema documented to Codex for one terminal analysis tool.""" + _require_kind(kind) + return { + "type": "object", + "additionalProperties": False, + "required": _required_fields(kind), + "properties": _payload_properties(kind), + } + + +def analysis_document_schema() -> dict[str, object]: + """The single-document contract used by the scripted ``codex exec`` fallback.""" + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "required": ["status", "summary"], + "properties": { + "status": {"enum": list(STATUS_BY_KIND.values())}, + "summary": _summary_schema(), + "frameworks": _frameworks_schema(), + "recommended": _recommended_schema(), + "entries": _entries_schema(), + "boundary": _boundary_schema(), + "assumptions": _string_list_schema(), + "questions": _questions_schema(), + "evidence": _evidence_schema(), + "warnings": _string_list_schema(), + }, + "allOf": [ + { + "if": { + "properties": {"status": {"const": "needs_input"}}, + "required": ["status"], + }, + "then": {"required": ["questions"]}, + }, + { + "if": { + "properties": {"status": {"const": "unsupported"}}, + "required": ["status"], + }, + "then": {"required": ["evidence"]}, + }, + ], + } + + +def is_model_document(value: object) -> bool: + """Whether a decoded object is a judgement Codex produced, not a state file.""" + return ( + isinstance(value, dict) + and value.get("status") in KIND_BY_STATUS + and isinstance(value.get("summary"), str) + and bool(value["summary"].strip()) + ) + + +def build_analysis_result( + kind: str, + payload: dict[str, object], + *, + attempt: int, + input_sha256: str, + detection: dict[str, object] | None = None, +) -> tuple[dict[str, object], list[str]]: + """Accept one submission and assemble the on-disk analysis document. + + Returns the validated document plus the notes describing everything that was + dropped or defaulted, so a degraded submission stays visible in diagnostics. + """ + _require_kind(kind) + notes: list[str] = [] + if not isinstance(payload, dict): + raise AnalysisAcceptanceError( + (AnalysisIssue("payload", "一个 JSON 对象", _type_name(payload)),) + ) + _note_unknown_fields(payload, kind, notes) + summary = _summary(payload, notes) + if kind == RECOMMENDATION_KIND: + body = _recommendation_body(payload, detection, notes) + elif kind == NEEDS_INPUT_KIND: + body = _needs_input_body(payload, detection, notes) + else: + body = _unsupported_body(payload, summary, detection, notes) + document: dict[str, object] = { + "schema_version": _SCHEMA_VERSION, + "status": STATUS_BY_KIND[kind], + "attempt": attempt, + "input_sha256": input_sha256, + "summary": summary, + **body, + } + try: + return validate_analysis_result(document), notes + except MigrationContractError as error: # Studio's own output, so a Studio bug + raise AnalysisAssemblyError(str(error)) from error + + +def acceptance_feedback(kind: str, error: AnalysisAcceptanceError) -> str: + """Render refusal reasons as a short instruction the model can act on.""" + lines = [ + f"{issue.path} 需要 {issue.expected},收到 {issue.actual}。" + for issue in error.issues + ] + tool = { + RECOMMENDATION_KIND: "reportRecommendation", + NEEDS_INPUT_KIND: "reportNeedsInput", + UNSUPPORTED_KIND: "reportUnsupported", + }[kind] + return ( + "这次提交没有被接受:\n" + + "\n".join(f"- {line}" for line in lines) + + f"\n修正后重新调用 {tool};不要为此重做已经完成的分析。" + ) + + +# --------------------------------------------------------------------------- payloads + + +def _recommendation_body( + payload: dict[str, object], + detection: dict[str, object] | None, + notes: list[str], +) -> dict[str, object]: + frameworks = _frameworks(payload.get("frameworks"), detection, notes) + recommended = _recommended(payload.get("recommended"), frameworks, notes) + entries = _entries(payload.get("entries"), notes) + if recommended["framework"] in STRUCTURED_MIGRATION_FRAMEWORKS: + if not any( + candidate["framework"] == recommended["framework"] + and candidate["value"] == recommended["entry"] + for candidate in entries + ): + if recommended["entry"] is not None and not any( + candidate["framework"] == recommended["framework"] + for candidate in entries + ): + notes.append("推荐入口没有对应的入口候选,已改为 null") + recommended = {**recommended, "entry": None} + else: + recommended = {**recommended, "entry": None} + return { + "frameworks": frameworks, + "recommended": recommended, + "entries": entries, + "boundary": _boundary(payload.get("boundary"), notes), + "assumptions": _text_list(payload.get("assumptions"), "assumptions", notes), + "questions": [], + "warnings": _text_list(payload.get("warnings"), "warnings", notes), + } + + +def _needs_input_body( + payload: dict[str, object], + detection: dict[str, object] | None, + notes: list[str], +) -> dict[str, object]: + questions = _questions(payload.get("questions"), notes) + if not questions: + raise AnalysisAcceptanceError( + ( + AnalysisIssue( + "questions", + "至少一个需要用户回答的问题(每项含 prompt)", + "空", + ), + ) + ) + # A needs_input document still carries the best recommendation so far: the + # confirmation page and the state-file contract both expect one, and the user may + # end up confirming it after answering. + frameworks = _frameworks(payload.get("frameworks"), detection, notes) + recommended = _recommended(payload.get("recommended"), frameworks, notes) + return { + "frameworks": frameworks, + "recommended": recommended, + "entries": [], + "boundary": _boundary(payload.get("boundary"), notes), + "assumptions": _text_list(payload.get("assumptions"), "assumptions", notes), + "questions": questions, + "warnings": _text_list(payload.get("warnings"), "warnings", notes), + } + + +def _unsupported_body( + payload: dict[str, object], + summary: str, + detection: dict[str, object] | None, + notes: list[str], +) -> dict[str, object]: + evidence = _evidence(payload.get("evidence"), notes) + issues: list[AnalysisIssue] = [] + if len(evidence) < _MINIMUM_UNSUPPORTED_EVIDENCE: + issues.append( + AnalysisIssue( + "evidence", + f"至少 {_MINIMUM_UNSUPPORTED_EVIDENCE} 条指向项目文件的证据" + "(每项含 path、line、reason)", + f"{len(evidence)} 条可用", + ) + ) + if len(summary.strip()) < _MINIMUM_UNSUPPORTED_SUMMARY: + issues.append( + AnalysisIssue( + "summary", + f"面向用户的说明,至少 {_MINIMUM_UNSUPPORTED_SUMMARY} 个字," + "依次写清发现了什么、为什么不能迁移、建议用户怎么做", + f"{len(summary.strip())} 个字", + ) + ) + issues.extend(_unknown_evidence_paths(evidence, detection)) + if issues: + raise AnalysisAcceptanceError(tuple(issues)) + # The user never sees the raw tool arguments, so the evidence that justifies the + # verdict is folded into warnings, which the analysis page renders verbatim. + warnings = _text_list(payload.get("warnings"), "warnings", notes) + warnings.extend( + f"证据:{item['path']}:{item['line']} — {item['reason']}" for item in evidence + ) + return { + "frameworks": [], + "recommended": None, + "entries": [], + "boundary": {"include": [], "exclude": []}, + "assumptions": [], + "questions": [], + "warnings": warnings, + } + + +def _unknown_evidence_paths( + evidence: list[dict[str, object]], + detection: dict[str, object] | None, +) -> list[AnalysisIssue]: + """Reject a verdict that cites files the archive never contained.""" + inventory = _inventory(detection) + if not inventory: + # Detection could not run: an unknown inventory must not block a verdict. + return [] + known = {name.casefold() for name in inventory} + unknown = [ + str(item["path"]) + for item in evidence + if not _in_inventory(str(item["path"]), known) + ] + if not unknown: + return [] + return [ + AnalysisIssue( + "evidence.path", + "确实存在于项目中的文件(可用文件:" + "、".join(inventory[:20]) + ")", + "、".join(unknown), + ) + ] + + +def _in_inventory(path: str, known: set[str]) -> bool: + folded = path.casefold().lstrip("./") + if folded in known: + return True + return any(name.endswith("/" + folded) for name in known) + + +# --------------------------------------------------------------------------- pieces + + +def _note_unknown_fields( + payload: dict[str, object], + kind: str, + notes: list[str], +) -> None: + """Record fields the model sent that this kind does not take. + + Echoed bookkeeping (``schema_version``, ``attempt``, ``input_sha256``) lands here, + which keeps a prompt drift visible without costing the analysis anything. + """ + allowed = set(_payload_properties(kind)) | {"status"} + extra = sorted(str(key) for key in payload if key not in allowed) + if extra: + notes.append("已忽略模型输出了本工具不接收的字段:" + "、".join(extra)) + + +def _summary(payload: dict[str, object], notes: list[str]) -> str: + value = payload.get("summary") + if not isinstance(value, str) or not value.strip(): + raise AnalysisAcceptanceError( + ( + AnalysisIssue( + "summary", + "非空字符串,用简体中文概括本次分析结论", + _type_name(value), + ), + ) + ) + text = value.strip() + if len(text) > _MAX_SUMMARY: + notes.append(f"summary 超长,已截断到 {_MAX_SUMMARY} 字符") + text = text[:_MAX_SUMMARY] + return text + + +def _frameworks( + value: object, + detection: dict[str, object] | None, + notes: list[str], +) -> list[dict[str, object]]: + candidates: list[dict[str, object]] = [] + seen: set[str] = set() + # Verified candidates come first: they are the ones later stages may act on. + for item in _detection_candidates(detection): + if item["id"] not in seen: + seen.add(str(item["id"])) + candidates.append(item) + for raw in _as_list(value, "frameworks", notes): + if not isinstance(raw, dict): + notes.append("已忽略一个不是对象的框架候选") + continue + framework = str(raw.get("id") or "").strip() + if framework not in MIGRATION_FRAMEWORKS: + notes.append(f"已忽略未知框架候选 {framework or '(空)'}") + continue + if framework in seen: + continue + seen.add(framework) + confidence = raw.get("confidence") + if confidence not in {"high", "medium", "low"}: + notes.append(f"框架候选 {framework} 的置信度无效,已按 low 处理") + confidence = "low" + candidates.append( + { + "id": framework, + "confidence": confidence, + "evidence": _evidence(raw.get("evidence"), notes), + } + ) + if not candidates: + notes.append("没有任何框架候选,已按 Any 处理") + candidates.append({"id": "any", "confidence": "low", "evidence": []}) + return candidates + + +def _recommended( + value: object, + frameworks: list[dict[str, object]], + notes: list[str], +) -> dict[str, object]: + fallback = str(frameworks[0]["id"]) + raw = value if isinstance(value, dict) else {} + framework = str(raw.get("framework") or "").strip() + if framework not in MIGRATION_FRAMEWORKS: + if framework: + notes.append(f"推荐的迁移方式 {framework} 无效,已按 {fallback} 处理") + else: + notes.append(f"没有给出推荐的迁移方式,已按 {fallback} 处理") + framework = fallback + if not any(str(item["id"]) == framework for item in frameworks): + # The confirmation page only offers the candidates, so the recommendation + # has to be selectable. + frameworks.append({"id": framework, "confidence": "low", "evidence": []}) + entry = raw.get("entry") + if framework in STRUCTURED_MIGRATION_FRAMEWORKS: + valid = isinstance(entry, str) and is_valid_structured_entry(entry) + if entry is not None and not valid: + notes.append(f"入口 {entry!r} 不是合法的结构化入口,已改为 null") + entry = entry if valid else None + else: + if entry is not None: + notes.append("Dify/Any 的入口必须为 null,已忽略给出的入口") + entry = None + reason = raw.get("reason") + if not isinstance(reason, str): + reason = "" + return { + "framework": framework, + "entry": entry, + "reason": reason.strip()[:_MAX_TEXT], + } + + +def _entries(value: object, notes: list[str]) -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + seen: set[tuple[str, str]] = set() + for raw in _as_list(value, "entries", notes): + if not isinstance(raw, dict): + notes.append("已忽略一个不是对象的入口候选") + continue + framework = str(raw.get("framework") or "").strip() + entry = raw.get("value") + if framework not in STRUCTURED_MIGRATION_FRAMEWORKS: + notes.append( + f"已忽略入口候选 {entry!r}:框架 {framework or '(空)'} 不需要入口" + ) + continue + if not isinstance(entry, str) or not is_valid_structured_entry(entry): + notes.append(f"已忽略入口候选 {entry!r}:不是合法的结构化入口") + continue + if (framework, entry) in seen: + continue + evidence = raw.get("evidence") + if not isinstance(evidence, str) or not evidence.strip(): + notes.append(f"已忽略入口候选 {entry}:缺少说明") + continue + seen.add((framework, entry)) + entries.append( + { + "value": entry, + "framework": framework, + "evidence": evidence.strip()[:_MAX_TEXT], + } + ) + return entries + + +def _boundary(value: object, notes: list[str]) -> dict[str, object]: + raw = value if isinstance(value, dict) else {} + include = _text_list(raw.get("include"), "boundary.include", notes) + exclude = _text_list(raw.get("exclude"), "boundary.exclude", notes) + if not include: + notes.append("没有给出迁移范围,已按项目内全部文件处理") + include = ["项目内全部文件"] + return {"include": include, "exclude": exclude} + + +def _evidence(value: object, notes: list[str]) -> list[dict[str, object]]: + items: list[dict[str, object]] = [] + if not isinstance(value, list): + if value is not None: + notes.append("evidence 不是数组,已忽略") + return items + dropped = 0 + for raw in value: + item = _evidence_item(raw, notes) + if item is None: + dropped += 1 + continue + items.append(item) + if dropped: + notes.append(f"已丢弃 {dropped} 条无法定位到文件的证据") + return items + + +def _evidence_item( + raw: object, + notes: list[str] | None = None, +) -> dict[str, object] | None: + if isinstance(raw, str): + text = raw.strip() + match = _CITATION.match(text) + if match is None or not _looks_like_path(match.group("path")): + return None + raw = { + "path": match.group("path"), + "line": match.group("line"), + "reason": match.group("reason") or text, + } + if not isinstance(raw, dict): + return None + path = _relative_path(raw.get("path")) + reason = raw.get("reason") + if path is None or not isinstance(reason, str) or not reason.strip(): + return None + line = _positive_int(raw.get("line")) + if line is None: + if notes is not None and raw.get("line") is not None: + notes.append("evidence.line 不是正整数,已按 1 处理") + line = 1 + return { + "path": path, + "line": line, + "reason": reason.strip()[:_MAX_TEXT], + } + + +def _questions(value: object, notes: list[str]) -> list[dict[str, object]]: + questions: list[dict[str, object]] = [] + seen: set[str] = set() + for index, raw in enumerate(_as_list(value, "questions", notes), start=1): + if not isinstance(raw, dict): + notes.append("已忽略一个不是对象的问题") + continue + prompt = raw.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + prompt = raw.get("question") + if not isinstance(prompt, str) or not prompt.strip(): + notes.append(f"已忽略第 {index} 个没有内容的问题") + continue + question_id = str(raw.get("id") or f"q{index}").strip() or f"q{index}" + if question_id in seen: + question_id = f"{question_id}-{index}" + seen.add(question_id) + required = raw.get("required") + if not isinstance(required, bool): + required = index == 1 + questions.append( + { + "id": question_id[:128], + "prompt": prompt.strip()[:_MAX_TEXT], + "required": required, + } + ) + if len(questions) >= 50: + notes.append("问题过多,已截断到 50 个") + break + if questions and not any(question["required"] for question in questions): + questions[0]["required"] = True + return questions + + +def _as_list(value: object, field: str, notes: list[str]) -> list[object]: + """A field that should be a list, reported instead of silently ignored.""" + if value is None: + return [] + if not isinstance(value, list): + notes.append(f"{field} 不是数组,已忽略") + return [] + return list(value) + + +def _text_list(value: object, field: str, notes: list[str]) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + notes.append(f"{field} 不是数组,已忽略") + return [] + items: list[str] = [] + for raw in value: + if isinstance(raw, str) and raw.strip(): + items.append(raw.strip()[:_MAX_TEXT]) + elif isinstance(raw, (int, float)) and not isinstance(raw, bool): + items.append(str(raw)) + else: + notes.append(f"{field} 中有一项不是文字,已忽略") + return items + + +def detection_candidates( + detection: dict[str, object] | None, +) -> list[dict[str, object]]: + """The framework candidates Studio verified without a model.""" + return _detection_candidates(detection) + + +def _detection_candidates( + detection: dict[str, object] | None, +) -> list[dict[str, object]]: + if not isinstance(detection, dict): + return [] + candidates: list[dict[str, object]] = [] + for raw in detection.get("candidates", []): + if not isinstance(raw, dict): + continue + framework = raw.get("id") + if framework not in MIGRATION_FRAMEWORKS: + continue + candidates.append( + { + "id": framework, + "confidence": ( + raw.get("confidence") + if raw.get("confidence") in {"high", "medium", "low"} + else "low" + ), + "evidence": [ + item + for item in ( + _evidence_item(entry) for entry in raw.get("evidence", []) + ) + if item is not None + ], + } + ) + return candidates + + +def _inventory(detection: dict[str, object] | None) -> list[str]: + if not isinstance(detection, dict) or detection.get("degraded"): + return [] + files = detection.get("files") + if not isinstance(files, dict): + return [] + listed = files.get("listed") + if not isinstance(listed, list): + return [] + return [str(item) for item in listed if isinstance(item, str)] + + +def _looks_like_path(value: str) -> bool: + return len(value) <= 512 and ("/" in value or "." in value) + + +def _relative_path(value: object) -> str | None: + if not isinstance(value, str): + return None + text = value.strip().replace("\\", "/").lstrip("/") + while text.startswith("./"): + text = text[2:] + parts = [part for part in text.split("/") if part not in {"", "."}] + if not parts or any(part == ".." for part in parts): + return None + return "/".join(parts)[:4_096] + + +def _positive_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 1 else None + if isinstance(value, str) and value.strip().isdigit(): + parsed = int(value.strip()) + return parsed if parsed >= 1 else None + return None + + +def _type_name(value: object) -> str: + return { + str: "字符串", + dict: "对象", + list: "数组", + int: "整数", + float: "小数", + bool: "布尔值", + type(None): "null", + }.get(type(value), type(value).__name__) + + +def _require_kind(kind: str) -> None: + if kind not in ANALYSIS_KINDS: + raise ValueError(f"unknown analysis kind: {kind}") + + +# --------------------------------------------------------------------------- schemas + + +def _required_fields(kind: str) -> list[str]: + if kind == RECOMMENDATION_KIND: + return ["summary"] + if kind == NEEDS_INPUT_KIND: + return ["summary", "questions"] + return ["summary", "evidence"] + + +def _payload_properties(kind: str) -> dict[str, object]: + if kind == RECOMMENDATION_KIND: + return { + "summary": _summary_schema(), + "frameworks": _frameworks_schema(), + "recommended": _recommended_schema(), + "entries": _entries_schema(), + "boundary": _boundary_schema(), + "assumptions": _string_list_schema(), + "warnings": _string_list_schema(), + } + if kind == NEEDS_INPUT_KIND: + return { + "summary": _summary_schema(), + "questions": _questions_schema(), + "frameworks": _frameworks_schema(), + "recommended": _recommended_schema(), + "boundary": _boundary_schema(), + "assumptions": _string_list_schema(), + "warnings": _string_list_schema(), + } + return { + "summary": _summary_schema(), + "evidence": _evidence_schema(), + "warnings": _string_list_schema(), + } + + +def _summary_schema() -> dict[str, object]: + return {"type": "string", "minLength": 1, "maxLength": _MAX_SUMMARY} + + +def _evidence_schema() -> dict[str, object]: + return { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["path", "reason"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "line": {"type": "integer", "minimum": 1}, + "reason": {"type": "string", "minLength": 1}, + }, + }, + } + + +def _frameworks_schema() -> dict[str, object]: + return { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id"], + "properties": { + "id": {"enum": list(MIGRATION_FRAMEWORKS)}, + "confidence": {"enum": ["high", "medium", "low"]}, + "evidence": _evidence_schema(), + }, + }, + } + + +def _recommended_schema() -> dict[str, object]: + return { + "type": "object", + "additionalProperties": False, + "required": ["framework"], + "properties": { + "framework": {"enum": list(MIGRATION_FRAMEWORKS)}, + "entry": {"type": ["string", "null"]}, + "reason": {"type": "string"}, + }, + } + + +def _entries_schema() -> dict[str, object]: + return { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["value", "framework", "evidence"], + "properties": { + "value": {"type": "string", "minLength": 1}, + "framework": {"enum": list(STRUCTURED_MIGRATION_FRAMEWORKS)}, + "evidence": {"type": "string", "minLength": 1}, + }, + }, + } + + +def _boundary_schema() -> dict[str, object]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "include": _string_list_schema(), + "exclude": _string_list_schema(), + }, + } + + +def _questions_schema() -> dict[str, object]: + return { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["prompt"], + "properties": { + "id": {"type": "string"}, + "prompt": {"type": "string", "minLength": 1}, + "required": {"type": "boolean"}, + }, + }, + } + + +def _string_list_schema() -> dict[str, object]: + return {"type": "array", "items": {"type": "string", "maxLength": _MAX_TEXT}} + + +__all__ = [ + "ANALYSIS_KINDS", + "KIND_BY_STATUS", + "NEEDS_INPUT_KIND", + "RECOMMENDATION_KIND", + "STATUS_BY_KIND", + "UNSUPPORTED_KIND", + "AnalysisAcceptanceError", + "AnalysisAssemblyError", + "AnalysisIssue", + "acceptance_feedback", + "analysis_document_schema", + "analysis_tool_schema", + "build_analysis_result", + "detection_candidates", + "is_model_document", +] diff --git a/frontend/server/migration/analysis_input.py b/frontend/server/migration/analysis_input.py new file mode 100644 index 000000000..6b17f1ef8 --- /dev/null +++ b/frontend/server/migration/analysis_input.py @@ -0,0 +1,384 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Questions a running analysis turn asks the user through Studio. + +Codex' own ``request_user_input`` tool exists in Plan mode only, so the read-only +analysis cannot use it: switching collaboration mode would rewrite the agent's +instructions and disable tools the analysis relies on. The analysis turn therefore +registers its own ``askUser`` dynamic tool. Its handler publishes the questions, +waits for the browser to answer, and returns the answers as the tool result, so one +attempt finishes the whole analysis instead of re-running it from scratch. + +The question and answer shapes mirror Codex' native protocol on purpose: questions are +``{id, header, question, options[]}`` and answers are ``{"": {"answers": +[...]}}``. Transport and protocol therefore stay interchangeable. +""" + +from __future__ import annotations + +import re +import threading +import time +import uuid +from collections.abc import Callable, Iterable +from concurrent.futures import Future, InvalidStateError +from dataclasses import dataclass + +ASK_TOOL_NAME = "askUser" +ASK_TOOL_DESCRIPTION = ( + "在只读分析过程中向用户提出必须由用户决定的问题,并等待用户回答。" + "一次提出 1-3 个问题,每个问题给出简短 header 和完整 question;" + "有自然选择时给出 2-3 个 options(每个含 label 和 description,第一项为推荐项)," + "没有自然选择时省略 options,用户会直接填写。" + "只能提问项目内容无法回答、且答案会改变迁移方式或迁移范围的问题;" + "能自己从项目里查到的事实必须自己查。" +) + +# The native protocol asks for at most three questions; a longer list is a +# questionnaire, which is a different product decision. +MAX_QUESTIONS = 3 +MAX_OPTIONS = 6 +MAX_HEADER_LENGTH = 24 +MAX_QUESTION_LENGTH = 1_000 +MAX_OPTION_LABEL_LENGTH = 60 +MAX_OPTION_DESCRIPTION_LENGTH = 200 +MAX_ANSWER_LENGTH = 4_000 +MAX_ANSWERS_PER_QUESTION = 8 +# An entry nothing ever settles would keep a card on the page forever. +MAX_PENDING_SECONDS = 3_600.0 + +_QUESTION_ID_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") + +Answers = dict[str, tuple[str, ...]] + + +class AnalysisAskError(ValueError): + """The ``askUser`` call cannot be shown to the user as written.""" + + +def _text( + value: object, + *, + maximum: int, + field: str, + allow_empty: bool = False, +) -> str: + if not isinstance(value, str): + raise AnalysisAskError(f"{field} 必须是字符串") + text = value.strip() + if not text and not allow_empty: + raise AnalysisAskError(f"{field} 不能为空") + if len(text) > maximum: + raise AnalysisAskError(f"{field} 不能超过 {maximum} 个字符") + return text + + +def _options(value: object) -> list[dict[str, str]]: + if value is None: + return [] + if not isinstance(value, list): + raise AnalysisAskError("options 必须是数组") + if len(value) > MAX_OPTIONS: + raise AnalysisAskError(f"options 不能超过 {MAX_OPTIONS} 项") + options: list[dict[str, str]] = [] + labels: set[str] = set() + for item in value: + if not isinstance(item, dict): + raise AnalysisAskError("options 的每一项都必须包含 label 和 description") + label = _text( + item.get("label"), + maximum=MAX_OPTION_LABEL_LENGTH, + field="options.label", + ) + if label in labels: + raise AnalysisAskError("options 的 label 不能重复") + labels.add(label) + options.append( + { + "label": label, + "description": _text( + item.get("description"), + maximum=MAX_OPTION_DESCRIPTION_LENGTH, + field="options.description", + ), + } + ) + return options + + +def normalize_questions(arguments: object) -> tuple[dict[str, object], ...]: + """Validate one ``askUser`` call and return the questions the page renders.""" + if not isinstance(arguments, dict): + raise AnalysisAskError("参数必须是对象") + raw = arguments.get("questions") + if not isinstance(raw, list) or not raw: + raise AnalysisAskError("questions 必须是非空数组") + if len(raw) > MAX_QUESTIONS: + raise AnalysisAskError(f"questions 不能超过 {MAX_QUESTIONS} 个") + questions: list[dict[str, object]] = [] + seen: set[str] = set() + for item in raw: + if not isinstance(item, dict): + raise AnalysisAskError("questions 的每一项都必须是对象") + question_id = _text(item.get("id"), maximum=64, field="questions.id") + if not _QUESTION_ID_RE.match(question_id): + raise AnalysisAskError( + "questions.id 只能包含字母、数字、下划线、点或连字符,且不超过 64 个字符" + ) + if question_id in seen: + raise AnalysisAskError("questions.id 不能重复") + seen.add(question_id) + questions.append( + { + "id": question_id, + "header": _text( + item.get("header"), + maximum=MAX_HEADER_LENGTH, + field="questions.header", + ), + "question": _text( + item.get("question"), + maximum=MAX_QUESTION_LENGTH, + field="questions.question", + ), + "options": _options(item.get("options")), + } + ) + return tuple(questions) + + +def _answers(value: object) -> Answers: + """Validate the answers the browser submits for one question set.""" + if not isinstance(value, dict) or not value: + raise AnalysisAskError("answers 必须是非空对象") + answers: Answers = {} + for raw_id, raw_answer in value.items(): + question_id = _text(raw_id, maximum=64, field="answers 的键") + values = raw_answer if isinstance(raw_answer, list) else [raw_answer] + if not values or len(values) > MAX_ANSWERS_PER_QUESTION: + raise AnalysisAskError("answers 的每项必须包含 1-8 个回答") + collected: list[str] = [] + for entry in values: + text = _text( + entry, + maximum=MAX_ANSWER_LENGTH, + field="answers 的值", + allow_empty=True, + ) + if text: + collected.append(text) + if not collected: + raise AnalysisAskError("answers 的值不能为空") + answers[question_id] = tuple(collected) + return answers + + +def normalize_answers(value: object) -> Answers: + """Public wrapper so routes and tests validate answers the same way.""" + return _answers(value) + + +ASK_TOOL_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["questions"], + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "maxItems": MAX_QUESTIONS, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["id", "header", "question"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9_.-]+$", + }, + "header": { + "type": "string", + "minLength": 1, + "maxLength": MAX_HEADER_LENGTH, + }, + "question": { + "type": "string", + "minLength": 1, + "maxLength": MAX_QUESTION_LENGTH, + }, + "options": { + "type": "array", + "minItems": 1, + "maxItems": MAX_OPTIONS, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["label", "description"], + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": MAX_OPTION_LABEL_LENGTH, + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": MAX_OPTION_DESCRIPTION_LENGTH, + }, + }, + }, + }, + }, + }, + } + }, +} + + +@dataclass(frozen=True) +class PendingAnalysisInput: + """One question set that an analysis turn is waiting on.""" + + request_id: str + attempt: int + questions: tuple[dict[str, object], ...] + created_at: float + future: Future[Answers | None] + + +def ask_payload(pending: PendingAnalysisInput) -> dict[str, object]: + """The question set as the page renders it.""" + return { + "id": pending.request_id, + "questions": [dict(question) for question in pending.questions], + } + + +class AnalysisInputRegistry: + """Track the questions each Sandbox session's analysis turn is waiting on. + + The analysis turn runs on its own event loop inside a Studio background worker, + while the answer arrives on an HTTP request thread, so the hand-off is a + ``concurrent.futures.Future``: it is settled from any thread and awaited from the + worker's loop. Settling with ``None`` is the "no answer" outcome, which the turn + turns into the ``needs_input`` fallback instead of a hung tool call. + """ + + def __init__( + self, + *, + clock: Callable[[], float] = time.monotonic, + max_pending_seconds: float = MAX_PENDING_SECONDS, + ) -> None: + self._clock = clock + self._max_pending_seconds = max_pending_seconds + self._lock = threading.Lock() + self._pending: dict[str, PendingAnalysisInput] = {} + + def open( + self, + session_id: str, + *, + attempt: int, + questions: Iterable[dict[str, object]], + ) -> PendingAnalysisInput: + """Publish one question set, releasing whatever was pending before.""" + pending = PendingAnalysisInput( + request_id=uuid.uuid4().hex, + attempt=attempt, + questions=tuple(dict(question) for question in questions), + created_at=self._clock(), + future=Future(), + ) + with self._lock: + previous = self._pending.pop(session_id, None) + self._pending[session_id] = pending + if previous is not None: + _settle(previous.future, None) + return pending + + def pending(self, session_id: str) -> PendingAnalysisInput | None: + """The live question set for one session, or ``None``.""" + with self._lock: + pending = self._pending.get(session_id) + if pending is None: + return None + if pending.future.done() or self._is_stale(pending): + self._pending.pop(session_id, None) + return None + return pending + + def resolve( + self, + session_id: str, + *, + request_id: str, + answers: Answers, + ) -> bool: + """Deliver answers to the waiting turn. ``False`` means the ask is gone.""" + with self._lock: + pending = self._pending.get(session_id) + if ( + pending is None + or pending.request_id != request_id + or not _settle(pending.future, answers) + ): + return False + self._pending.pop(session_id, None) + return True + + def discard(self, session_id: str, *, request_id: str = "") -> None: + """Withdraw one question set and release anyone still waiting on it.""" + with self._lock: + pending = self._pending.get(session_id) + if pending is None: + return + if request_id and pending.request_id != request_id: + return + self._pending.pop(session_id, None) + _settle(pending.future, None) + + def _is_stale(self, pending: PendingAnalysisInput) -> bool: + if self._max_pending_seconds <= 0: + return False + return self._clock() - pending.created_at > self._max_pending_seconds + + +def _settle(future: Future[Answers | None], value: Answers | None) -> bool: + """Settle one pending hand-off, tolerating a waiter that already gave up.""" + if future.done(): + return False + try: + future.set_result(value) + except InvalidStateError: + return False + return True + + +__all__ = [ + "ASK_TOOL_DESCRIPTION", + "ASK_TOOL_NAME", + "ASK_TOOL_SCHEMA", + "MAX_PENDING_SECONDS", + "AnalysisAskError", + "AnalysisInputRegistry", + "Answers", + "PendingAnalysisInput", + "ask_payload", + "normalize_answers", + "normalize_questions", +] diff --git a/frontend/server/migration/app_server.py b/frontend/server/migration/app_server.py new file mode 100644 index 000000000..ad095b0a6 --- /dev/null +++ b/frontend/server/migration/app_server.py @@ -0,0 +1,343 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Codex app-server route analysis for Studio project migration. + +The analysis result arrives through registered dynamic tools, so a progress update, a +commentary message, or a Markdown-fenced reply can no longer be mistaken for the result. +Acceptance is deliberately liberal: Studio owns the state-file format, fills in the +protocol bookkeeping itself, and defaults or drops whatever the model could not shape, +so a model that writes imperfect arguments still lands a usable result. Only the +destructive ``unsupported`` verdict is held to a deterministic bar, and a refusal is +returned to Codex as ``success: false`` so the same turn corrects itself. + +The same turn also carries ``askUser``. When the project cannot answer a question that +changes the migration, Codex asks the user inside the turn and keeps analysing with the +answer, which is cheaper and more accurate than re-running the analysis afterwards. +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Awaitable, Callable + +from veadk.cli.codex_app_server import CodexDynamicToolResult + +from .analysis_input import ( + ASK_TOOL_DESCRIPTION, + ASK_TOOL_NAME, + ASK_TOOL_SCHEMA, + AnalysisAskError, + Answers, + normalize_questions, +) +from .analysis_contract import ( + NEEDS_INPUT_KIND, + RECOMMENDATION_KIND, + UNSUPPORTED_KIND, + AnalysisAcceptanceError, + AnalysisAssemblyError, + acceptance_feedback, + analysis_tool_schema, + build_analysis_result, +) +from .codex_tool_turn import DynamicTool, ToolTurnUnavailable, run_tool_turn + +logger = logging.getLogger(__name__) + +RECOMMENDATION_TOOL_NAME = "reportRecommendation" +NEEDS_INPUT_TOOL_NAME = "reportNeedsInput" +UNSUPPORTED_TOOL_NAME = "reportUnsupported" +TOOL_NAME_BY_KIND = { + RECOMMENDATION_KIND: RECOMMENDATION_TOOL_NAME, + NEEDS_INPUT_KIND: NEEDS_INPUT_TOOL_NAME, + UNSUPPORTED_KIND: UNSUPPORTED_TOOL_NAME, +} +TOOL_DESCRIPTION_BY_KIND = { + RECOMMENDATION_KIND: ( + "提交只读项目分析的最终结论:推荐一种可执行的迁移方式。" + "在完成分析后调用一次;只有 summary 是必填的,其余字段能给多少给多少," + "缺失的字段会被 Studio 用已核实的事实补齐,不会被拒绝。" + ), + NEEDS_INPUT_KIND: ( + "提交只读项目分析的结论:必须先由用户补充信息才能决定迁移方式。" + "把要向用户提出的问题写入 questions(每项含 prompt)。" + ), + UNSUPPORTED_KIND: ( + "提交「该项目无法迁移」的结论。这是最后一个手段,只用于材料不足或" + "证据完整的高风险行为链;必须给出 summary 和至少两条指向项目真实文件的" + "证据,证据无法核实会被拒绝。" + ), +} +# What Codex is told when nobody answered the questions in time. +UNANSWERED_HINT = ( + "用户没有在时限内回答这些问题。请立即调用 " + f"{NEEDS_INPUT_TOOL_NAME}," + "把原始问题写入 questions(每项包含 prompt),不要重复提问。" +) + +_APP_SERVER_ENV = "AGENTKIT_MIGRATION_APP_SERVER" +_DISABLED_VALUES = {"0", "false", "no", "off"} + +# Publishes the questions to the page and returns the user's answers, or ``None`` when +# the user did not answer inside the window the caller allows. +AnalysisQuestioner = Callable[ + [tuple[dict[str, object], ...]], + Awaitable[Answers | None], +] + + +def app_server_analysis_enabled() -> bool: + """Whether route analysis should use the Sandbox Codex app-server. + + The app-server carries the analysis result in typed dynamic-tool arguments, so a + progress update or a Markdown-fenced reply can no longer be mistaken for the + result. It runs on a Studio background worker, and the scripted ``codex exec`` + path remains the fallback whenever the app-server is unreachable, so this is on by + default; set ``AGENTKIT_MIGRATION_APP_SERVER=0`` to pin the scripted path. + """ + return os.getenv(_APP_SERVER_ENV, "").strip().lower() not in _DISABLED_VALUES + + +class MigrationAnalysisUnavailable(RuntimeError): + """The Sandbox app-server could not produce a usable analysis result.""" + + +class AnalysisRecorder: + """Accept one terminal analysis submission and retain the assembled result. + + The record carries the outcome facts a caller may want to persist: which tool + landed, what acceptance had to default or drop, and why earlier submissions were + refused. None of that is a verdict about the project. + """ + + def __init__( + self, + *, + attempt: int, + input_sha256: str, + detection: dict[str, object] | None = None, + ) -> None: + self.attempt = attempt + self.input_sha256 = input_sha256 + self.detection = detection + self.result: dict[str, object] | None = None + self.kind = "" + self.notes: list[str] = [] + self.refusals: list[str] = [] + + def handler( + self, + kind: str, + ) -> Callable[[dict[str, object]], CodexDynamicToolResult]: + def handle(arguments: dict[str, object]) -> CodexDynamicToolResult: + return self._accept(kind, arguments) + + return handle + + def _accept( + self, + kind: str, + arguments: dict[str, object], + ) -> CodexDynamicToolResult: + if self.result is not None: + return CodexDynamicToolResult( + True, + "分析结果已经提交,请直接给出简短的简体中文总结。", + ) + try: + document, notes = build_analysis_result( + kind, + arguments, + attempt=self.attempt, + input_sha256=self.input_sha256, + detection=self.detection, + ) + except AnalysisAcceptanceError as error: + self.refusals.append( + "; ".join(f"{item.path}:{item.actual}" for item in error.issues) + ) + return CodexDynamicToolResult(False, acceptance_feedback(kind, error)) + except AnalysisAssemblyError: + logger.exception("Studio migration analysis assembly failed kind=%s", kind) + return CodexDynamicToolResult( + False, + "Studio 暂时无法保存这次结论,请稍后重新调用同一个工具。", + ) + self.result = document + self.kind = kind + self.notes = notes + if notes: + logger.info( + "Studio migration analysis accepted with defaults kind=%s notes=%s", + kind, + notes, + ) + return CodexDynamicToolResult( + True, + "结论已接收。请用简体中文给出简短的用户可见总结,不要重复分析过程。", + ) + + +def ask_tool_handler(questioner: AnalysisQuestioner) -> Callable[..., object]: + """Build the ``askUser`` handler that bridges one turn to the browser. + + The result carries the answers exactly like Codex' native + ``ToolRequestUserInputResponse``, and an unanswered question set is a *successful* + call: the model is told nobody answered so it can fall back to ``needs_input`` + instead of asking again. The description lives on the tool spec, so a caller that + is not a read-only analysis can promise the right thing while reusing the channel. + """ + + async def handle(arguments: dict[str, object]) -> CodexDynamicToolResult: + try: + questions = normalize_questions(arguments) + except AnalysisAskError as error: + return CodexDynamicToolResult( + False, + f"提问不符合要求({error})。请修正后重新调用 {ASK_TOOL_NAME}。", + ) + try: + answers = await questioner(questions) + except Exception: # noqa: BLE001 - a broken channel must not kill the turn + logger.exception("Studio migration askUser channel failed") + answers = None + if answers is None: + return CodexDynamicToolResult( + True, + json.dumps( + {"answers": {}, "unanswered": True, "hint": UNANSWERED_HINT}, + ensure_ascii=False, + ), + ) + return CodexDynamicToolResult( + True, + json.dumps( + { + "answers": { + question_id: {"answers": list(values)} + for question_id, values in answers.items() + } + }, + ensure_ascii=False, + ), + ) + + return handle + + +async def run_route_analysis( + *, + endpoint: str, + prompt: str, + cwd: str, + attempt: int, + input_sha256: str, + model: str = "", + timeout_seconds: float, + event_sink: Callable[[object], None] | None = None, + questioner: AnalysisQuestioner | None = None, + idle_timeout_seconds: float | None = None, + host_wait_seconds: Callable[[], float] | None = None, + detection: dict[str, object] | None = None, + diagnostics: dict[str, object] | None = None, +) -> dict[str, object] | None: + """Run one analysis turn and return the accepted analysis document, if any. + + Passing ``questioner`` registers ``askUser`` for this turn; the caller also owns + the matching window through ``idle_timeout_seconds`` and ``host_wait_seconds``. + ``detection`` carries Studio's own verified facts, which acceptance uses to fill + defaults and to check the one verdict that must cite real files. ``diagnostics``, + when given, is filled in place with what happened, so a caller can persist it. + """ + recorder = AnalysisRecorder( + attempt=attempt, + input_sha256=input_sha256, + detection=detection, + ) + extra_tools = ( + ( + DynamicTool( + name=ASK_TOOL_NAME, + description=ASK_TOOL_DESCRIPTION, + schema=ASK_TOOL_SCHEMA, + handler=ask_tool_handler(questioner), + ), + ) + if questioner is not None + else () + ) + # Counting Codex' own output is what lets a caller tell "the turn never reached + # the model" (an infrastructure fallback) from "the model worked and delivered + # nothing acceptable" (a conclusion Studio must fall back for itself). + seen = {"events": 0} + + def sink(event: object) -> None: + seen["events"] += 1 + if event_sink is not None: + event_sink(event) + + tools = tuple( + DynamicTool( + name=TOOL_NAME_BY_KIND[kind], + description=TOOL_DESCRIPTION_BY_KIND[kind], + schema=analysis_tool_schema(kind), + handler=recorder.handler(kind), + ) + for kind in (RECOMMENDATION_KIND, NEEDS_INPUT_KIND, UNSUPPORTED_KIND) + ) + try: + await run_tool_turn( + endpoint=endpoint, + prompt=prompt, + cwd=cwd, + tools=tools, + has_result=lambda: recorder.result is not None, + model=model, + timeout_seconds=timeout_seconds, + event_sink=sink, + extra_tools=extra_tools, + idle_timeout_seconds=idle_timeout_seconds, + host_wait_seconds=host_wait_seconds, + ) + except ToolTurnUnavailable as error: + raise MigrationAnalysisUnavailable(str(error)) from error + if diagnostics is not None: + diagnostics.update( + { + "accepted": recorder.result is not None, + "kind": recorder.kind, + "notes": list(recorder.notes), + "refusals": list(recorder.refusals), + "events": seen["events"], + } + ) + return recorder.result + + +__all__ = [ + "AnalysisQuestioner", + "AnalysisRecorder", + "MigrationAnalysisUnavailable", + "NEEDS_INPUT_TOOL_NAME", + "RECOMMENDATION_TOOL_NAME", + "TOOL_DESCRIPTION_BY_KIND", + "TOOL_NAME_BY_KIND", + "UNSUPPORTED_TOOL_NAME", + "app_server_analysis_enabled", + "ask_tool_handler", + "run_route_analysis", +] diff --git a/frontend/server/migration/codex_exec_shim.py b/frontend/server/migration/codex_exec_shim.py new file mode 100644 index 000000000..e98568348 --- /dev/null +++ b/frontend/server/migration/codex_exec_shim.py @@ -0,0 +1,1135 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the Sandbox migration CLI's `codex exec` on the Sandbox Codex app-server. + +Studio installs this file ahead of ``codex`` on the Sandbox ``PATH`` before the +migration CLI runs. The CLI still drives the migration, but its Codex work happens +on the app-server the Sandbox already hosts, which is what lets the page report the +same numbers the intelligent build reports: ``codex exec --json`` carries neither a +tool's ``duration_ms`` nor the turn's own ``startedAt``/``completedAt``/ +``durationMs``/``model``, and the app-server carries all of them. + +Only the CLI's own JSON exec form is intercepted. Every other invocation — and any +failure to reach the app-server before the first log line — is handed to the real +Codex binary, so the CLI keeps behaving exactly as it did before. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shlex +import subprocess +import sys +import time +from pathlib import Path +from collections.abc import Mapping +from dataclasses import dataclass +from typing import IO, Any, Callable, Iterable + +_REAL_CODEX_ENV = "STUDIO_MIGRATION_REAL_CODEX" +_APP_SERVER_ENV = "STUDIO_MIGRATION_APP_SERVER" +_STATE_ENV = "STUDIO_MIGRATION_SHIM_STATE" +_DEFAULT_APP_SERVER = "ws://127.0.0.1:8199" +_DEFAULT_REAL_CODEX = "/usr/local/libexec/codex-real" +_DEFAULT_STATE_PATH = "/tmp/studio-codex-shim-state.json" + +# The migration CLI settles an attempt with its own deterministic contract and opens +# another attempt when a finding blocks that contract, which is why a migration turn +# could claim success and still be followed by a second one. The shim runs the same +# contract inside the exec it already owns and hands the blocking findings back to the +# same thread, so the repair lands in the turn that made the claim. +_CONTRACT_OUTPUT_ENV = "AGENTKIT_MIGRATE_OUTPUT_DIR" +_CONTRACT_ASSET_ENV = "AGENTKIT_MIGRATE_ASSET_DIR" +_CONTRACT_SKILL_ENV = "AGENTKIT_MIGRATE_SKILL_PATH" +_CONTRACT_SCRIPT = "scripts/validate_runtime.sh" +_CONTRACT_SKILL_DIR = "source-to-veadk" +_DEFAULT_SKILL_PATH = "/home/gem/.codex/skills" +_CONTRACT_ROW_NAME = "确定性校验" +_CONTRACT_JUDGED_MARKER = "Validation finished:" +_MAX_CONTRACT_REPAIRS = 2 +_CONTRACT_TIMEOUT_SECONDS = 300.0 +_CONTRACT_OUTPUT_CHARS = 4_000 + +# Codex' own labels, kept identical to the ones the app-server driver writes so a +# migration turn reads like an intelligent-build turn. +_COMMAND_NAME = "运行命令" +_FILE_CHANGE_NAME = "修改文件" +_WEB_SEARCH_NAME = "网络搜索" + +_USAGE_KEYS = ( + "totalTokens", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "reasoningOutputTokens", +) + +# `codex exec` flags that pick a sandbox without naming one. +_SANDBOX_FLAGS = { + "--dangerously-bypass-approvals-and-sandbox": "danger-full-access", + "--full-auto": "workspace-write", +} + +# Flags that take the following token as their value. Only some are read; the rest +# are skipped so an unexpected flag cannot be mistaken for the prompt. +_VALUE_FLAGS = { + "--cd", + "-C", + "--model", + "-m", + "--output-last-message", + "--output-schema", + "--config", + "-c", + "--profile", + "-p", + "--sandbox", + "-s", + "--image", + "-i", + "--add-dir", + "--local-provider", + "--color", + "--disable", + "--enable", +} + + +class CodexShimFallback(RuntimeError): + """The app-server could not take this call, so the real Codex must run it.""" + + +@dataclass +class Invocation: + """The part of a `codex exec` command line this shim needs.""" + + prompt: str = "" + resume: str = "" + resume_last: bool = False + cwd: str = "" + model: str = "" + last_message_path: str = "" + output_schema_path: str = "" + sandbox: str = "" + + +def parse_exec_argv(argv: list[str]) -> Invocation | None: + """Read the CLI's `codex exec` form, or ``None`` to defer to the real Codex. + + The migration CLI runs ``codex exec [resume |resume --last] --cd DIR + --dangerously-bypass-approvals-and-sandbox --json --output-last-message FILE + --model MODEL -``. A command line this shim does not understand is not its to + reinterpret, so anything without ``exec`` and ``--json`` runs on the real Codex. + """ + if not argv or argv[0] != "exec": + return None + rest = list(argv[1:]) + invocation = Invocation() + if rest and rest[0] == "resume": + rest.pop(0) + if rest and rest[0] == "--last": + rest.pop(0) + invocation.resume_last = True + elif rest and not rest[0].startswith("-"): + invocation.resume = rest.pop(0) + json_output = False + index = 0 + while index < len(rest): + token = rest[index] + value = "" + if token in _VALUE_FLAGS: + index += 1 + value = rest[index] if index < len(rest) else "" + elif token.startswith("--") and "=" in token: + token, _, value = token.partition("=") + if token == "--json": + json_output = True + elif token in {"--cd", "-C"}: + invocation.cwd = value + elif token in {"--model", "-m"}: + invocation.model = value + elif token == "--output-last-message": + invocation.last_message_path = value + elif token == "--output-schema": + invocation.output_schema_path = value + elif token in {"--sandbox", "-s"}: + if value in {"read-only", "workspace-write", "danger-full-access"}: + invocation.sandbox = value + elif token in _SANDBOX_FLAGS: + invocation.sandbox = _SANDBOX_FLAGS[token] + elif not token.startswith("-"): + # `-` means stdin, which is what the CLI passes. + invocation.prompt = token + index += 1 + if not json_output: + return None + return invocation + + +def sandbox_policy(invocation: Invocation) -> dict[str, object]: + """The app-server sandbox that matches what the CLI asked `codex exec` for. + + `codex exec` is conservative by default, so a command line that names no sandbox + runs read-only here too: the shim must never hand out more access than the real + Codex would. + """ + mode = invocation.sandbox or "read-only" + if mode == "danger-full-access": + return {"type": "dangerFullAccess"} + if mode == "workspace-write": + return {"type": "workspaceWrite"} + return {"type": "readOnly"} + + +@dataclass(frozen=True) +class ContractVerdict: + """One run of the CLI's deterministic contract, as the shim read it.""" + + passed: bool + judged: bool + command: str + output: str + exit_code: int + duration_ms: int + findings: tuple[str, ...] = () + + +def contract_target(environ: Mapping[str, str]) -> tuple[str, str] | None: + """The CLI's validation script and the output directory it judges, if any. + + The CLI exports both to the Codex process whose prompt tells the model to run + that script, so the shim reads the same two variables instead of guessing at the + layout. A CLI that exports neither (or a script that is not there) keeps its own + attempt loop: the shim only ever adds a check it can actually run. + """ + output = str(environ.get(_CONTRACT_OUTPUT_ENV) or "").strip() + asset = str(environ.get(_CONTRACT_ASSET_ENV) or "").strip() + if not asset: + skill = ( + str(environ.get(_CONTRACT_SKILL_ENV) or "").strip() or _DEFAULT_SKILL_PATH + ) + asset = os.path.join(skill, _CONTRACT_SKILL_DIR) + if not output or not asset or not os.path.isdir(output): + return None + script = os.path.join(asset, _CONTRACT_SCRIPT) + if not os.path.isfile(script): + return None + return script, output + + +def blocking_findings(output_dir: str, *, limit: int = 6) -> tuple[str, ...]: + """The fatal and repairable findings one validation run left behind.""" + try: + with open( + os.path.join(output_dir, "validation_findings.json"), encoding="utf-8" + ) as handle: + value = json.load(handle) + except (OSError, ValueError): + return () + if not isinstance(value, dict): + return () + lines: list[str] = [] + for severity in ("fatal", "repairable"): + entries = value.get(severity) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + detail = str(entry.get("detail") or "").strip() + if name or detail: + lines.append(f"- {name} [{severity}] {detail}".strip()) + if len(lines) >= limit: + return tuple(lines) + return tuple(lines) + + +def _tail(text: str, limit: int) -> str: + """The end of a command's output, which is where a validator puts its verdict.""" + text = text.strip() + return text if len(text) <= limit else text[-limit:] + + +def run_contract(script: str, output_dir: str) -> ContractVerdict: + """Run the CLI's deterministic contract the way the migration prompt does. + + A verdict counts only when the validator reported one: a script that could not + run at all must not cost the CLI a turn, so ``judged`` gates the repair loop. + """ + command = 'bash "$AGENTKIT_MIGRATE_ASSET_DIR/scripts/validate_runtime.sh"' + started = time.monotonic() + try: + completed = subprocess.run( + ["bash", script], + cwd=output_dir, + capture_output=True, + text=True, + timeout=_CONTRACT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + return ContractVerdict( + passed=False, + judged=False, + command=command, + output=str(error), + exit_code=-1, + duration_ms=int((time.monotonic() - started) * 1000), + ) + text = "\n".join(part for part in (completed.stdout, completed.stderr) if part) + judged = _CONTRACT_JUDGED_MARKER in (completed.stdout or "") + return ContractVerdict( + passed=judged and completed.returncode == 0, + judged=judged, + command=command, + output=_tail(text, _CONTRACT_OUTPUT_CHARS), + exit_code=completed.returncode, + duration_ms=int((time.monotonic() - started) * 1000), + findings=blocking_findings(output_dir), + ) + + +def contract_row(verdict: ContractVerdict, *, index: int) -> dict[str, object]: + """One validation run as the command row the migration page already draws.""" + return { + "type": "item.completed", + "item": { + "id": f"studio-contract-{index}", + "type": "command_execution", + "name": _CONTRACT_ROW_NAME, + "command": verdict.command, + "aggregated_output": verdict.output, + "exit_code": verdict.exit_code, + "duration_ms": verdict.duration_ms, + "status": "completed" if verdict.passed else "failed", + }, + } + + +def contract_feedback(verdict: ContractVerdict) -> str: + """The repair instructions handed back into the same turn.""" + findings = "\n".join(verdict.findings) or "- 见校验输出。" + return "\n".join( + [ + "# 确定性校验未通过:在本回合内修复", + "", + "CLI 的迁移契约刚刚在这个输出目录上失败,所以这次迁移还不能结束。", + "不要重开迁移,也不要改写 CLI 初始化生成的 `.agentkit/agentkit.yaml`:", + "它的 sha256 就是 `migration_metadata.json` 里记录的配置基线,", + "应用名由已确认的迁移设置决定,不是本回合可以更改的内容。", + "在当前输出目录里修掉下面的阻断项,然后重跑校验,直到它通过。", + "", + "## 阻断项", + findings, + "", + "## 校验输出(末尾)", + "```", + verdict.output or "(校验脚本没有输出)", + "```", + "", + "## 完成条件", + '- 重跑 `bash "$AGENTKIT_MIGRATE_ASSET_DIR/scripts/validate_runtime.sh"`,', + " 退出码为 0,且 `validation_findings.json` 的 `fatal`、`repairable` 都为空", + " (`degraded` 可以保留,但要在报告里如实说明)。", + "- 没有通过校验之前,不要输出迁移完成的结论。", + "", + ] + ) + + +def read_output_schema(path: str) -> object: + """The JSON Schema the CLI pinned the model's final message to, if readable.""" + if not path: + return None + try: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return None + + +def real_codex_command() -> str: + """The Codex binary this shim defers to when the app-server is not an option.""" + configured = os.environ.get(_REAL_CODEX_ENV, "").strip() + if configured: + return configured + if os.path.exists(_DEFAULT_REAL_CODEX): + return _DEFAULT_REAL_CODEX + return "codex-real" + + +def discover_app_servers() -> list[str]: + """Every app-server URL the Sandbox is already listening on. + + The Sandbox starts its own app-server for the mounted Session, so the shim only + has to find it: the listener publishes the address on its own command line. + """ + found: list[str] = [] + try: + entries = os.listdir("/proc") + except OSError: + return found + for entry in entries: + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/cmdline", "rb") as handle: + parts = handle.read().decode("utf-8", "replace").split("\x00") + except OSError: + continue + if not any("app-server" in part for part in parts): + continue + for part in parts: + if part.startswith("ws://") or part.startswith("wss://"): + if part not in found: + found.append(part) + return found + + +def app_server_candidates() -> list[str]: + """The app-server URLs to try, in order of how specific they are.""" + candidates: list[str] = [] + configured = os.environ.get(_APP_SERVER_ENV, "").strip() + for url in (configured, _DEFAULT_APP_SERVER, *discover_app_servers()): + if url and url not in candidates: + candidates.append(url) + return candidates + + +def _text(value: object, limit: int = 20_000) -> str: + if not isinstance(value, str): + return "" + value = value.strip() + return value if len(value) <= limit else value[:limit] + + +def _status(item: dict[str, object], completed: bool) -> str: + raw = str(item.get("status") or "").strip().lower() + if raw in {"failed", "declined", "cancelled"}: + return "failed" + if raw == "completed" or completed: + return "completed" + return "running" + + +def _duration(item: dict[str, object]) -> int | None: + value = item.get("durationMs") + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + return None + + +def convert_item( + item: dict[str, object], *, completed: bool +) -> dict[str, object] | None: + """One app-server item as the activity-log item the migration page reads. + + The page renders whatever the app-server reported, so the fields it reads are + carried over unchanged: ``duration_ms`` is the tool's own time, ``name`` selects + Codex' own row, and the item keeps the identity the app-server gave it. + """ + raw_type = str(item.get("type") or "") + entry: dict[str, object] = { + "id": str(item.get("id") or ""), + "status": _status(item, completed), + } + if raw_type == "commandExecution": + entry["type"] = "command_execution" + entry["name"] = _COMMAND_NAME + command = _text(item.get("command")) + if command: + entry["command"] = command + actions = item.get("commandActions") + if isinstance(actions, list) and actions: + entry["command_actions"] = actions + output = item.get("aggregatedOutput") + if isinstance(output, str) and output: + entry["aggregated_output"] = _text(output, 200_000) + exit_code = item.get("exitCode") + if isinstance(exit_code, int) and not isinstance(exit_code, bool): + entry["exit_code"] = exit_code + elif raw_type == "fileChange": + entry["type"] = "file_change" + entry["name"] = _FILE_CHANGE_NAME + changes = item.get("changes") + entry["changes"] = changes if isinstance(changes, list) else [] + elif raw_type == "mcpToolCall": + server = _text(item.get("server"), 200) + tool = _text(item.get("tool"), 200) + entry["type"] = "mcp_tool_call" + entry["name"] = "MCP · " + "/".join(part for part in (server, tool) if part) + entry["server"] = server + entry["tool"] = tool + if isinstance(item.get("arguments"), dict): + entry["arguments"] = item["arguments"] + if completed and isinstance(item.get("result"), dict): + entry["result"] = item["result"] + elif raw_type == "webSearch": + entry["type"] = "web_search" + entry["name"] = _WEB_SEARCH_NAME + query = _text(item.get("query")) + if query: + entry["query"] = query + elif raw_type == "dynamicToolCall": + entry["type"] = "dynamic_tool_call" + entry["name"] = _text(item.get("tool"), 200) + if isinstance(item.get("arguments"), dict): + entry["arguments"] = item["arguments"] + if completed: + entry["result"] = { + "success": item.get("success"), + "contentItems": item.get("contentItems"), + } + elif raw_type == "agentMessage": + text = _text(item.get("text"), 100_000) + if not text: + return None + entry["type"] = "agent_message" + entry["text"] = text + elif raw_type == "reasoning": + summary = item.get("summary") + text = ( + "\n".join(part for part in summary if isinstance(part, str)) + if isinstance(summary, list) + else _text(item.get("text"), 4_000) + ) + text = _text(text, 4_000) + if not text: + return None + entry["type"] = "reasoning" + entry["text"] = text + else: + return None + duration = _duration(item) + if duration is not None: + entry["duration_ms"] = duration + return entry + + +def convert_plan(params: dict[str, object]) -> dict[str, object] | None: + """`turn/plan/updated` as the todo list the migration page draws.""" + todos: list[dict[str, object]] = [] + for step in params.get("plan") or []: + if not isinstance(step, dict): + continue + text = _text(step.get("step"), 4_000) + if not text: + continue + todos.append( + { + "text": text, + "completed": str(step.get("status") or "").strip().lower() + in {"completed", "done"}, + } + ) + if not todos: + return None + turn_id = str(params.get("turnId") or "") + return { + "id": f"plan-{turn_id}" if turn_id else "plan", + "type": "todo_list", + "items": todos, + } + + +def usage_breakdown(value: object) -> dict[str, int]: + """One app-server token breakdown, in the naming the page reads.""" + if not isinstance(value, dict): + return {} + counts: dict[str, int] = {} + for key in _USAGE_KEYS: + count = value.get(key) + if isinstance(count, int) and not isinstance(count, bool) and count >= 0: + counts[key] = count + return counts + + +def subtract_usage( + current: dict[str, int], previous: dict[str, int] +) -> dict[str, int] | None: + """The tokens one call added, or ``None`` when the totals went backwards.""" + difference: dict[str, int] = {} + for key, count in current.items(): + before = previous.get(key, 0) + if count < before: + return None + difference[key] = count - before + return difference + + +def add_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]: + total = dict(left) + for key, count in right.items(): + total[key] = total.get(key, 0) + count + return total + + +def _second(value: object) -> float | None: + """An app-server timestamp in seconds, or ``None`` when it is not one.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + number = float(value) + return number if number >= 0 else None + + +def span_turns(turns: list[dict[str, object]], turn: dict[str, object]) -> None: + """Report a whole exec as one turn when the contract kept it open. + + A repaired contract makes one `codex exec` carry several app-server turns, but + the migration page draws one turn per exec, so its elapsed time has to cover the + model work of every sub-turn and the validation between them. + """ + if len(turns) < 2: + return + started = _second(turns[0].get("startedAt")) + completed = _second(turns[-1].get("completedAt")) + if started is None or completed is None or completed < started: + return + turn["startedAt"] = turns[0]["startedAt"] + turn["completedAt"] = turns[-1]["completedAt"] + turn["durationMs"] = int(round((completed - started) * 1000)) + + +def turn_line( + turn: dict[str, object], + *, + usage: dict[str, int], + model: str, +) -> dict[str, object]: + """The turn's own cost, in the shape the migration page already settles. + + This is the one line `codex exec --json` cannot write: the CLI's stream has no + turn object at all, so its reader has no elapsed time, model or usage to report. + """ + raw_status = turn.get("status") + if isinstance(raw_status, dict): + raw_status = raw_status.get("type") + status = str(raw_status or "").strip().lower() or "completed" + event_type = { + "failed": "turn.failed", + "interrupted": "turn.interrupted", + }.get(status, "turn.completed") + summary: dict[str, object] = { + "id": str(turn.get("id") or ""), + "status": status, + } + for key in ("startedAt", "completedAt", "durationMs"): + value = turn.get(key) + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and value >= 0 + ): + summary[key] = value + if model: + summary["model"] = model + line: dict[str, object] = {"type": event_type, "turn": summary} + if usage: + line["usage"] = usage + return line + + +class _Emitter: + """The append-only log the CLI redirects to ``codex-attempt-N.jsonl``. + + Lines are flushed as they happen, exactly like Codex' own stream, because the + page polls this file while the turn runs. + """ + + def __init__(self, stream: IO[str]) -> None: + self._stream = stream + self.wrote = False + + def line(self, value: dict[str, object]) -> None: + self._stream.write(json.dumps(value, ensure_ascii=False) + "\n") + self._stream.flush() + self.wrote = True + + +class _AppServerTurn: + """One JSON-RPC connection to the Sandbox app-server, for one `codex exec`.""" + + def __init__(self, socket: Any, emit: Callable[[dict[str, object]], None]) -> None: + self._socket = socket + self._emit = emit + self._pending: dict[int, asyncio.Future[dict[str, object]]] = {} + self._next_id = 1 + self.turn_id = "" + self.model = "" + self.status = "" + self.final_text = "" + self.failure = "" + self._completed = asyncio.Event() + self._turn: dict[str, object] = {} + self._turns: list[dict[str, object]] = [] + self._usage: dict[str, int] = {} + self._thread_total: dict[str, int] | None = None + + async def send(self, message: dict[str, object]) -> None: + await self._socket.send(json.dumps(message)) + + async def request( + self, method: str, params: dict[str, object] + ) -> dict[str, object]: + identifier = self._next_id + self._next_id += 1 + future: asyncio.Future[dict[str, object]] = ( + asyncio.get_running_loop().create_future() + ) + self._pending[identifier] = future + await self.send({"id": identifier, "method": method, "params": params}) + return await future + + async def notify(self, method: str) -> None: + await self.send({"method": method}) + + async def start(self) -> asyncio.Task[None]: + """Start reading this connection: every request needs the reader running.""" + return asyncio.create_task(self._read()) + + def begin_turn(self) -> None: + """Arm the connection for another turn on the thread it already holds.""" + self._completed = asyncio.Event() + + async def run_turn( + self, + *, + thread_id: str, + prompt: str, + model: str, + sandbox: dict[str, object], + output_schema: object = None, + ) -> None: + await self.request( + "turn/start", + { + "threadId": thread_id, + "input": [{"type": "text", "text": prompt}], + "approvalPolicy": "never", + "approvalsReviewer": "user", + "sandboxPolicy": sandbox, + **({"model": model} if model else {}), + **({"outputSchema": output_schema} if output_schema else {}), + }, + ) + self._emit({"type": "turn.started"}) + await self._completed.wait() + + async def _read(self) -> None: + async for raw in self._socket: + try: + message = json.loads(raw) + except ValueError: + continue + if not isinstance(message, dict): + continue + if "id" in message and ("result" in message or "error" in message): + future = self._pending.pop(message["id"], None) + if future is not None and not future.done(): + if "error" in message: + future.set_exception( + CodexShimFallback(f"app-server error {message['error']}") + ) + else: + result = message.get("result") + future.set_result(result if isinstance(result, dict) else {}) + continue + if "id" in message: + await self._answer(message) + continue + self._notification( + str(message.get("method") or ""), + message.get("params"), + ) + self._fail("app-server connection closed") + + def _fail(self, reason: str) -> None: + """Hand every waiter its answer when the app-server stops answering. + + A request that was still in flight would otherwise wait forever, and the CLI + would wait with it, so the connection going away has to unblock them all. + """ + error = CodexShimFallback(reason) + for identifier, future in list(self._pending.items()): + self._pending.pop(identifier, None) + if not future.done(): + future.set_exception(error) + if not self._completed.is_set(): + self.status = "failed" + self.failure = self.failure or reason + self._completed.set() + + async def _answer(self, message: dict[str, object]) -> None: + method = str(message.get("method") or "") + if method in { + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + }: + await self.send({"id": message["id"], "result": {"decision": "accept"}}) + return + await self.send( + { + "id": message["id"], + "error": { + "code": -32601, + "message": f"unsupported server request {method}", + }, + } + ) + + def _notification(self, method: str, params: object) -> None: + payload = params if isinstance(params, dict) else {} + if method in {"item/started", "item/completed"}: + item = payload.get("item") + if not isinstance(item, dict): + return + completed = method == "item/completed" + if completed and item.get("type") == "agentMessage": + text = _text(item.get("text"), 100_000) + if text and str(item.get("phase") or "") != "commentary": + self.final_text = text + converted = convert_item(item, completed=completed) + if converted is not None: + self._emit( + { + "type": "item.completed" if completed else "item.started", + "item": converted, + } + ) + return + if method == "turn/plan/updated": + item = convert_plan(payload) + if item is not None: + self._emit({"type": "item.updated", "item": item}) + return + if method == "thread/tokenUsage/updated": + self._observe_usage(payload.get("tokenUsage")) + return + if method == "turn/completed": + turn = payload.get("turn") + if isinstance(turn, dict): + self._turn = turn + self._turns.append(dict(turn)) + raw_status = turn.get("status") + if isinstance(raw_status, dict): + raw_status = raw_status.get("type") + self.status = str(raw_status or "").strip().lower() + error = turn.get("error") + if isinstance(error, dict) and error.get("message"): + self.failure = str(error["message"]) + self._completed.set() + return + if method == "error": + message = payload.get("message") + if isinstance(message, str) and message: + self._emit({"type": "error", "message": message}) + + def _observe_usage(self, value: object) -> None: + """Accumulate this turn's tokens the way the app-server driver does. + + The thread total is cumulative, so the turn's own cost is the first update's + ``last`` plus every later increase of ``total``. + """ + if not isinstance(value, dict): + return + total = usage_breakdown(value.get("total")) + last = usage_breakdown(value.get("last")) + if not total and not last: + return + increment: dict[str, int] | None + if self._thread_total is None: + increment = last + else: + increment = subtract_usage(total, self._thread_total) + if not increment: + increment = last + if total: + self._thread_total = total + if increment: + self._usage = add_usage(self._usage, increment) + + def summary_line(self, model: str) -> dict[str, object]: + """The turn's closing line, with the numbers the page shows. + + The app-server names the model it actually ran, which is the only source for + a `codex exec` that did not name one itself. + """ + turn = dict(self._turn) + if self.turn_id: + turn.setdefault("id", self.turn_id) + reported = turn.get("model") + if not model and isinstance(reported, str): + model = reported + span_turns(self._turns, turn) + return turn_line(turn, usage=self._usage, model=model or self.model) + + +async def serve_turn( + invocation: Invocation, + prompt_for: Callable[[], str], + *, + emit: Callable[[dict[str, object]], None], + read_state: Callable[[], dict[str, object]], + write_state: Callable[[dict[str, object]], None], +) -> int: + """Run one `codex exec` on the Sandbox app-server and log it for the page.""" + import websockets + + socket = None + failure = "" + for url in app_server_candidates(): + try: + socket = await websockets.connect( + url, max_size=None, open_timeout=10, proxy=None + ) + break + except Exception as error: # noqa: BLE001 - try the next address + failure = f"{url} ({type(error).__name__})" + if socket is None: + raise CodexShimFallback(f"app-server unreachable at {failure or 'no address'}") + # The CLI feeds the prompt on stdin, so it is read only once the app-server is + # going to take the turn: a fallback to the real Codex still has to find it there. + prompt = prompt_for() + turn = _AppServerTurn(socket, emit) + reader = await turn.start() + try: + return await _drive_turn( + turn, + invocation, + prompt, + emit=emit, + read_state=read_state, + write_state=write_state, + ) + except CodexShimFallback: + raise + except Exception as trouble: # noqa: BLE001 - a lost connection is a fallback + raise CodexShimFallback( + f"app-server connection failed ({type(trouble).__name__})" + ) from trouble + finally: + reader.cancel() + await close_socket(socket) + + +async def close_socket(socket: Any) -> None: + """Let go of the connection without letting the goodbye become the answer.""" + try: + await socket.close() + except Exception: # noqa: BLE001 - the turn is over either way + return + + +async def _drive_turn( + turn: _AppServerTurn, + invocation: Invocation, + prompt: str, + *, + emit: Callable[[dict[str, object]], None], + read_state: Callable[[], dict[str, object]], + write_state: Callable[[dict[str, object]], None], +) -> int: + """Drive one turn over a live app-server connection and log it for the page.""" + await turn.request( + "initialize", + { + "clientInfo": {"name": "studio-migration-shim", "version": "1"}, + "capabilities": {"experimentalApi": True}, + }, + ) + await turn.notify("initialized") + mode = invocation.sandbox or "read-only" + thread_id = invocation.resume + if not thread_id and invocation.resume_last: + thread_id = str(read_state().get("thread_id") or "") + if thread_id: + result = await turn.request( + "thread/resume", + { + "threadId": thread_id, + **({"cwd": invocation.cwd} if invocation.cwd else {}), + **({"model": invocation.model} if invocation.model else {}), + }, + ) + else: + result = await turn.request( + "thread/start", + { + **({"cwd": invocation.cwd} if invocation.cwd else {}), + **({"model": invocation.model} if invocation.model else {}), + "approvalPolicy": "never", + "approvalsReviewer": "user", + "sandbox": mode, + }, + ) + thread = result.get("thread") + if isinstance(thread, dict): + reported = result.get("model") or thread.get("model") + if isinstance(reported, str): + turn.model = reported + resume_id = str((thread or {}).get("id") or "") if isinstance(thread, dict) else "" + if not resume_id: + raise CodexShimFallback("app-server did not return a thread id") + turn.turn_id = resume_id + write_state({"thread_id": resume_id}) + emit({"type": "thread.started", "thread_id": resume_id}) + await turn.run_turn( + thread_id=resume_id, + prompt=prompt, + model=invocation.model, + sandbox=sandbox_policy(invocation), + output_schema=read_output_schema(invocation.output_schema_path), + ) + await settle_contract(turn, invocation, emit=emit) + emit(turn.summary_line(invocation.model)) + if invocation.last_message_path and turn.final_text: + try: + with open(invocation.last_message_path, "w", encoding="utf-8") as handle: + handle.write(turn.final_text) + except OSError as error: + print(f"studio codex shim: {error}", file=sys.stderr, flush=True) + if turn.failure: + print(f"studio codex shim: {turn.failure}", file=sys.stderr, flush=True) + return 0 if turn.status in {"", "completed"} else 1 + + +async def settle_contract( + turn: _AppServerTurn, + invocation: Invocation, + *, + emit: Callable[[dict[str, object]], None], +) -> None: + """Hold one exec inside a single turn until the CLI's own contract passes. + + The migration CLI validates the output after Codex exits and opens a new attempt + when a finding blocks the delivery, so a turn could claim the migration was done + and still be followed by another one. The contract is the deterministic script + the migration prompt already tells the model to run, so the shim runs that same + script inside the exec and hands the blocking findings back into the same thread: + the repair lands in the turn that made the claim, and the CLI's attempt loop + stays a backstop. + + Every bail-out is deliberate. A contract the shim cannot run or cannot read a + verdict from, a sub-turn that failed, and the repair budget all end the loop + without touching the turn, because a turn must never be held open by the shim. + """ + target = contract_target(os.environ) + if target is None or turn.status not in {"", "completed"}: + return + script, output_dir = target + for repair in range(_MAX_CONTRACT_REPAIRS + 1): + verdict = await asyncio.to_thread(run_contract, script, output_dir) + emit(contract_row(verdict, index=repair + 1)) + if verdict.passed or not verdict.judged or repair == _MAX_CONTRACT_REPAIRS: + return + turn.begin_turn() + await turn.run_turn( + thread_id=turn.turn_id, + prompt=contract_feedback(verdict), + model=invocation.model, + sandbox=sandbox_policy(invocation), + ) + if turn.status not in {"", "completed"}: + return + + +def read_state(path: str) -> dict[str, object]: + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + except (OSError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def write_state(path: str, value: dict[str, object]) -> None: + try: + with open(path, "w", encoding="utf-8") as handle: + json.dump(value, handle) + except OSError: + return + + +def run_real_codex(argv: list[str]) -> int: + """Hand this call to the real Codex binary, exactly as the CLI issued it.""" + parts = [*shlex.split(real_codex_command()), *argv] + try: + os.execvp(parts[0], parts) + except OSError as error: + print( + f"studio codex shim: real Codex is unavailable ({error})", + file=sys.stderr, + ) + return 127 + return 0 + + +def main(argv: Iterable[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + invocation = parse_exec_argv(arguments) + if invocation is None: + return run_real_codex(arguments) + + def prompt_for() -> str: + return invocation.prompt or sys.stdin.read() + + state_path = os.environ.get(_STATE_ENV, "").strip() or _DEFAULT_STATE_PATH + emitter = _Emitter(sys.stdout) + try: + return asyncio.run( + serve_turn( + invocation, + prompt_for, + emit=emitter.line, + read_state=lambda: read_state(state_path), + write_state=lambda value: write_state(state_path, value), + ) + ) + except CodexShimFallback as error: + print( + f"studio codex shim: {error}; running the real Codex instead", + file=sys.stderr, + flush=True, + ) + if emitter.wrote: + # This turn already announced a thread; running Codex as well would give + # the CLI two sessions in one attempt. + return 1 + return run_real_codex(arguments) + except Exception as error: # noqa: BLE001 - the CLI must not fail on shim trouble + print( + f"studio codex shim: {type(error).__name__}: {error}", + file=sys.stderr, + flush=True, + ) + return 1 if emitter.wrote else run_real_codex(arguments) + + +def shim_source() -> str: + """The shim program Studio installs into the Sandbox. + + Studio writes this module's own source into the Sandbox so the installed shim is + exactly the code the tests exercise, with no second copy to keep in step. + """ + return Path(__file__).read_text(encoding="utf-8") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/server/migration/codex_tool_turn.py b/frontend/server/migration/codex_tool_turn.py new file mode 100644 index 000000000..dbfd929f9 --- /dev/null +++ b/frontend/server/migration/codex_tool_turn.py @@ -0,0 +1,271 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""One Codex app-server turn that reports its result through a dynamic tool. + +Studio drives every structured Codex turn the same way: dynamic tools registered on +``thread/start`` carry the result as typed JSON-RPC arguments, a rejected call returns +``success: false`` so the same turn can correct itself, and the turn ends as soon as the +contract has been delivered. Callers own the contract: they pass the tool specs, the +validators, and the check that says the result has arrived. A turn may register several +tools, because one analysis can both ask the user a question and report its result. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass + +from veadk.cli.codex_app_server import ( + CodexAppServerError, + CodexAppServerSession, + CodexDynamicToolResult, +) + +ToolHandler = Callable[ + [dict[str, object]], + "CodexDynamicToolResult | Awaitable[CodexDynamicToolResult]", +] + +logger = logging.getLogger(__name__) + +# A turn that is interrupted the moment its result lands needs its own settlement, and +# the app-server needs a moment to record the interruption before the turn reads back +# as terminal. +_TURN_SETTLE_ATTEMPTS = 6 +_TURN_SETTLE_SECONDS = 0.5 +_TERMINAL_TURN_STATUSES = {"completed", "failed", "interrupted", "cancelled"} + +__all__ = [ + "DynamicTool", + "ToolHandler", + "ToolTurnDeadlineExceeded", + "ToolTurnUnavailable", + "run_tool_turn", +] + + +class ToolTurnUnavailable(RuntimeError): + """The Sandbox app-server could not start or finish the tool turn.""" + + +class ToolTurnDeadlineExceeded(ToolTurnUnavailable): + """The turn kept making progress but ran past the caller's wall-clock window. + + Callers treat this differently from a transport failure: the app-server works, + the batch is simply too slow for the window that was granted. + """ + + +@dataclass(frozen=True) +class DynamicTool: + """One host-provided tool that Codex may call inside the turn.""" + + name: str + description: str + schema: dict[str, object] + handler: ToolHandler + + +async def run_tool_turn( + *, + endpoint: str, + prompt: str, + cwd: str, + tool_name: str = "", + tool_description: str = "", + tool_schema: dict[str, object] | None = None, + handler: ToolHandler | None = None, + tools: tuple[DynamicTool, ...] | None = None, + has_result: Callable[[], bool], + thread_id: str = "", + model: str = "", + timeout_seconds: float, + event_sink: Callable[[object], None] | None = None, + extra_tools: Sequence[DynamicTool] = (), + idle_timeout_seconds: float | None = None, + host_wait_seconds: Callable[[], float] | None = None, +) -> str: + """Run one turn and return the thread id that carried it. + + ``thread_id`` resumes a durable thread instead of starting a new one; the + app-server restores that thread's dynamic tools, so the same contract keeps + arriving. ``timeout_seconds`` is a wall-clock budget for Codex' own work, not just + the app-server's inactivity window: callers that must answer inside a caller-owned + window cannot rely on a turn that keeps making progress, so this interrupts it at + the deadline. ``host_wait_seconds`` excludes time a tool handler spent waiting on + a human, which is host latency and not Codex progress, from that budget. + + ``idle_timeout_seconds`` is the inactivity window handed to the app-server client. + A handler that blocks on a human produces no events at all while it waits, so a + caller with an interactive tool must pass an window longer than the longest wait it + allows, or the turn is cancelled under the waiting user. + + Any protocol or transport failure becomes ``ToolTurnUnavailable`` so the caller can + fall back, unless the result already arrived. + """ + session = CodexAppServerSession(endpoint) + session.cwd = cwd + if model: + session.model = model + if tools is None: + if not tool_name or tool_schema is None or handler is None: + raise ValueError("run_tool_turn needs either tools or one primary tool") + tools = ( + DynamicTool( + name=tool_name, + description=tool_description, + schema=tool_schema, + handler=handler, + ), + ) + for tool in (*tools, *extra_tools): + session.register_dynamic_tool( + tool.name, + tool.description, + tool.schema, + tool.handler, + ) + used_thread = thread_id + turn_id = "" + loop = asyncio.get_running_loop() + try: + try: + if thread_id: + await session.attach_thread(thread_id) + else: + await session.connect() + except CodexAppServerError as error: + raise ToolTurnUnavailable(str(error)) from error + started_at = loop.time() + deadline = started_at + timeout_seconds + idle_timeout = ( + timeout_seconds if idle_timeout_seconds is None else idle_timeout_seconds + ) + try: + async for event in session.stream_turn( + prompt, + timeout_seconds=idle_timeout, + # 本轮耗时/模型要跟智能构建一样报给页面,所以即使这不是 Studio + # 任务回合也要收生命周期事件。 + emit_turn_lifecycle=True, + ): + turn_id = str(getattr(event, "turn_id", "") or "") or turn_id + if event_sink is not None: + event_sink(event) + if has_result(): + # 结果已经到手:终止本轮,避免继续消耗 token 和沙箱时间。 + await session.interrupt() + break + if host_wait_seconds is not None: + deadline = ( + started_at + timeout_seconds + max(0.0, host_wait_seconds()) + ) + if loop.time() >= deadline: + # 回合一直在产生进度,但已经超出调用方的窗口:主动收尾。 + await session.interrupt() + raise ToolTurnDeadlineExceeded("Codex 回合超出时间预算。") + except TimeoutError as error: + # app-server 客户端自己的空闲超时:同样是「没在窗口内交付」。 + if not has_result(): + raise ToolTurnDeadlineExceeded( + str(error) or "Codex 回合超时。" + ) from error + except CodexAppServerError as error: + if not has_result(): + raise ToolTurnUnavailable(str(error)) from error + used_thread = session.thread_id or thread_id + finally: + # 结果一到手就打断的回合(以及超时收尾的回合)都走不到 app-server 的 + # turn_completed,而本轮耗时/模型只挂在那条事件上:会话还在的时候回读这一轮, + # 替它补一条结算,页面才能像智能构建那样报出本轮的成本。 + await _settle_turn( + session, + event_sink, + turn_id=turn_id or str(getattr(session, "active_turn_id", "") or ""), + accepted=has_result(), + ) + await session.close() + return used_thread + + +def _turn_status(turn: dict[str, object]) -> str: + status = turn.get("status") + if isinstance(status, dict): + status = status.get("type") + return str(status or "").strip().lower() + + +async def _settle_turn( + session: CodexAppServerSession, + event_sink: Callable[[object], None] | None, + *, + turn_id: str, + accepted: bool, +) -> None: + """Report a turn's own timing when the caller stopped it before the app-server did. + + Breaking out of the stream once the result arrives (or at the caller's deadline) + leaves the turn without a ``turn_completed`` event, so codex' native timing + (``startedAt`` / ``completedAt`` / ``durationMs`` / ``model``) never reaches the + page. Reading the turn back keeps those numbers, and ``accepted`` says the caller + took the result: the turn delivered what it was asked for, whatever codex calls the + interruption the caller requested. + + Settlement is decoration on top of the turn's real outcome, so it never raises. + """ + if event_sink is None or not turn_id: + return + try: + read_turn = getattr(session, "read_turn", None) + lifecycle = getattr(session, "turn_lifecycle_event", None) + if not callable(read_turn) or not callable(lifecycle): + return + turn: dict[str, object] | None = None + for attempt in range(_TURN_SETTLE_ATTEMPTS): + candidate = await read_turn(turn_id) + if not isinstance(candidate, dict): + return + turn = candidate + if _turn_status(turn) in _TERMINAL_TURN_STATUSES: + break + if attempt + 1 < _TURN_SETTLE_ATTEMPTS: + await asyncio.sleep(_TURN_SETTLE_SECONDS) + if turn is None: + return + status = _turn_status(turn) + if accepted: + turn = {**turn, "status": "completed"} + elif status not in _TERMINAL_TURN_STATUSES: + # 既没拿到结果、这一轮又还在跑:没有可以报的终态,不编一个。 + return + if "durationMs" not in turn: + # 回合自己的时间戳就是权威值,缺 durationMs 时由它俩相减得出。 + started, completed = turn.get("startedAt"), turn.get("completedAt") + if ( + isinstance(started, (int, float)) + and not isinstance(started, bool) + and isinstance(completed, (int, float)) + and not isinstance(completed, bool) + and completed >= started + ): + turn = {**turn, "durationMs": completed - started} + event_sink(lifecycle("turn_completed", turn)) + except Exception as error: # noqa: BLE001 - 读数是装饰,不能改变回合结果 + logger.warning( + "Studio migration turn settlement failed error_type=%s", + type(error).__name__, + ) diff --git a/frontend/server/migration/contracts.py b/frontend/server/migration/contracts.py index 93a9f6eb9..564ec85c6 100644 --- a/frontend/server/migration/contracts.py +++ b/frontend/server/migration/contracts.py @@ -161,6 +161,21 @@ def _reject_path_collisions(paths: set[str]) -> None: raise MigrationContractError("file and directory paths collide") +def _evidence_list(value: object, *, maximum: int = 100) -> list[dict[str, object]]: + if not isinstance(value, list) or len(value) > maximum: + raise MigrationContractError("invalid evidence list") + evidence: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, dict): + raise MigrationContractError("invalid evidence") + _exact_keys(item, required={"path", "line", "reason"}) + _relative_path(item.get("path")) + _bounded_integer(item.get("line"), minimum=1, maximum=10_000_000) + _text(item.get("reason"), allow_empty=False, maximum=4_000) + evidence.append(item) + return evidence + + def _framework(value: object) -> str: if value not in MIGRATION_FRAMEWORKS: raise MigrationContractError("unsupported framework") @@ -266,6 +281,98 @@ def validate_source_status(value: object) -> dict[str, object]: return {str(key): item for key, item in value.items()} +def validate_detection_report(value: object) -> dict[str, object]: + """Validate the model-free detection report written before analysis runs. + + Studio authors this document, so it is checked strictly; the analysis verdict is + later measured against the file inventory it carries. + """ + if not isinstance(value, dict): + raise MigrationContractError("detection report must be an object") + _exact_keys( + value, + required={ + "schema_version", + "files", + "documents", + "candidates", + "unreadable", + "degraded", + "degraded_reason", + }, + ) + if value.get("schema_version") != 1: + raise MigrationContractError("unsupported detection report schema") + files = value.get("files") + if not isinstance(files, dict): + raise MigrationContractError("invalid detection files") + _exact_keys(files, required={"count", "listed"}) + _bounded_integer(files.get("count"), maximum=_MAX_DELIVERY_FILES) + listed = files.get("listed") + if not isinstance(listed, list) or len(listed) > 1_000: + raise MigrationContractError("invalid detection file list") + for item in listed: + _relative_path(item) + + documents = value.get("documents") + if not isinstance(documents, list) or len(documents) > 1_000: + raise MigrationContractError("invalid detection documents") + for item in documents: + if not isinstance(item, dict): + raise MigrationContractError("invalid detection document") + _exact_keys( + item, + required={"path", "format", "status", "dsl", "signals"}, + optional={"parse_error"}, + ) + _relative_path(item.get("path")) + if item.get("status") not in {"parsed", "unparsed"}: + raise MigrationContractError("invalid detection document status") + _text(item.get("format"), allow_empty=False, maximum=32) + _text(item.get("dsl"), maximum=64) + if "parse_error" in item: + _text(item.get("parse_error"), maximum=64) + signals = item.get("signals") + if not isinstance(signals, list) or len(signals) > 100: + raise MigrationContractError("invalid detection signals") + for signal in signals: + if not isinstance(signal, dict): + raise MigrationContractError("invalid detection signal") + _exact_keys(signal, required={"path", "line", "reason"}) + _text(signal.get("path"), maximum=_MAX_PATH_BYTES) + _bounded_integer(signal.get("line"), minimum=1, maximum=10_000_000) + _text(signal.get("reason"), allow_empty=False, maximum=4_000) + + candidates = value.get("candidates") + if not isinstance(candidates, list) or len(candidates) > 20: + raise MigrationContractError("invalid detection candidates") + for item in candidates: + if not isinstance(item, dict): + raise MigrationContractError("invalid detection candidate") + _exact_keys(item, required={"id", "confidence", "evidence"}) + _framework(item.get("id")) + if item.get("confidence") not in {"high", "medium", "low"}: + raise MigrationContractError("invalid detection candidate") + _evidence_list(item.get("evidence")) + + unreadable = value.get("unreadable") + if not isinstance(unreadable, list) or len(unreadable) > 1_000: + raise MigrationContractError("invalid detection unreadable list") + for item in unreadable: + if not isinstance(item, dict): + raise MigrationContractError("invalid detection unreadable entry") + _exact_keys(item, required={"path", "reason"}) + path = item.get("path") + if path != "": + _relative_path(path) + _text(item.get("reason"), allow_empty=False, maximum=64) + + if not isinstance(value.get("degraded"), bool): + raise MigrationContractError("invalid detection degraded flag") + _text(value.get("degraded_reason"), maximum=128) + return {str(key): item for key, item in value.items()} + + def validate_analysis_status(value: object) -> dict[str, object]: if not isinstance(value, dict): raise MigrationContractError("analysis status must be an object") @@ -367,6 +474,118 @@ def validate_process_exit(value: object) -> dict[str, object]: return {str(key): item for key, item in value.items()} +def validate_migration_driver( + value: object, + *, + expected_run_id: str, +) -> dict[str, object]: + """Validate the delivery driver lease, including the published artifact. + + The record is written inside the Sandbox by the launch script that supervises the + migration CLI. It lets Studio tell a driver that is still working from one whose + process disappeared, and it carries the artifact digest computed right after the + CLI exited, so the bytes Studio later pulls can be checked against it. + + ``lost`` is the heartbeat's own verdict that the CLI it was watching is gone: the + run will never write its result, but the agent's work may still be on disk. + """ + if not isinstance(value, dict): + raise MigrationContractError("driver lease must be an object") + _exact_keys( + value, + required={ + "schema_version", + "run_id", + "state", + "heartbeat_at", + "finished_at", + "exit_code", + "artifact", + }, + ) + state = value.get("state") + if ( + value.get("schema_version") != 1 + or value.get("run_id") != expected_run_id + or state not in {"running", "lost", "finished"} + ): + raise MigrationContractError("invalid driver lease identity") + _bounded_integer(value.get("heartbeat_at"), maximum=10**12) + finished_at = value.get("finished_at") + exit_code = value.get("exit_code") + artifact = value.get("artifact") + if state != "finished": + if finished_at is not None or exit_code is not None or artifact is not None: + raise MigrationContractError("unfinished driver lease published a result") + else: + _bounded_integer(finished_at, maximum=10**12) + _bounded_integer(exit_code, maximum=255) + if artifact is not None: + if not isinstance(artifact, dict): + raise MigrationContractError("invalid artifact descriptor") + _exact_keys(artifact, required={"path", "sha256", "size"}) + if artifact.get("path") != "migration-result.zip": + raise MigrationContractError("invalid artifact path") + _bounded_integer(artifact.get("size"), maximum=_MAX_ARTIFACT_BYTES) + _sha256(artifact.get("sha256")) + return {str(key): item for key, item in value.items()} + + +def validate_delivery_report( + value: object, + *, + expected_run_id: str, + expected_state: str, +) -> dict[str, object]: + """Validate the verdict the closing delivery turn published. + + The turn explains a delivery; it never decides one. ``expected_state`` is the + state Studio derived from the Sandbox, and a report that disagrees with it is + rejected here as well as inside the turn, so a damaged or replayed record cannot + describe a delivery other than the one the CLI settled. + """ + if not isinstance(value, dict): + raise MigrationContractError("delivery report must be an object") + _exact_keys( + value, + required={ + "schema_version", + "run_id", + "driver", + "state", + "message", + "warnings", + "artifact", + "created_at", + }, + ) + state = value.get("state") + if ( + value.get("schema_version") != 1 + or value.get("run_id") != expected_run_id + or value.get("driver") != "app-server" + or state != expected_state + or state not in {"succeeded", "succeeded_with_warnings", "partial", "failed"} + ): + raise MigrationContractError("delivery report identity does not match") + _text(value.get("message"), allow_empty=False, maximum=4_000) + _string_list(value.get("warnings"), maximum_items=8) + artifact = value.get("artifact") + if state == "failed": + if artifact is not None: + raise MigrationContractError("failed delivery report published an artifact") + else: + if not isinstance(artifact, dict): + raise MigrationContractError("invalid delivery report artifact") + _exact_keys(artifact, required={"path", "sha256", "size"}) + if artifact.get("path") != "migration-result.zip": + raise MigrationContractError("invalid delivery report artifact path") + _bounded_integer(artifact.get("size"), maximum=_MAX_ARTIFACT_BYTES) + _sha256(artifact.get("sha256")) + _timestamp_text(value.get("created_at")) + return {str(key): item for key, item in value.items()} + + def validate_stopped_status(value: object) -> dict[str, object]: if not isinstance(value, dict): raise MigrationContractError("stopped status must be an object") @@ -809,10 +1028,13 @@ def validate_delivery_result( __all__ = [ "MigrationContractError", "validate_analysis_result", + "validate_detection_report", "validate_analysis_status", "validate_confirmation", "validate_delivery_result", + "validate_delivery_report", "validate_delivery_status", + "validate_migration_driver", "validate_migration_request", "validate_process_exit", "validate_source_status", diff --git a/frontend/server/migration/delivery_recovery.py b/frontend/server/migration/delivery_recovery.py new file mode 100644 index 000000000..66870871d --- /dev/null +++ b/frontend/server/migration/delivery_recovery.py @@ -0,0 +1,807 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Re-package a migration delivery whose Sandbox CLI never finished. + +The AgentKit CLI settles an agentic delivery in three steps: it reads the terminal +state the agent wrote (``work/agentic/state/status.json``), turns the validation +findings into a verification record, and packages ``output/veadk`` into +``delivery/migration-result.zip`` plus ``delivery/migration-result.json``. Only the +first third of that chain needs the model: once the Codex turn ends in a successful +terminal state, the rest is a pure function of the files on disk. + +A run whose launch shell disappears between those two points therefore leaves a +finished project and no artifact at all: the delivery driver lease keeps beating, the +CLI never writes ``process-exit.json``, and Studio has nothing to publish. This module +rebuilds what the CLI would have written, by the CLI's own rules, so the delivery can +still be closed instead of waiting for a task that will never settle. + +Two things keep that honest. The program is a mirror: same file selection, same +archive layout, same verification mapping, same terminal status document, so every +reader downstream — the delivery report, the artifact download, the deploy path — sees +a delivery it already knows how to read. And it only runs on the narrow window it was +written for: a successful terminal agent state, an existing output directory, and no +delivery the CLI already settled. Anything else is refused, which leaves the task on +the ordinary "the migration process was interrupted" path. + +One field is not a mirror. The CLI records ``source_sha256`` as a fingerprint of the +source tree it was pointed at, hashed in its own locale-aware file order, which Python +cannot reproduce byte for byte. The caller therefore hands in the uploaded source +archive's digest instead -- the value Studio already binds the migration confirmation +to -- so the field still names the source the delivery came from, by a measure both +sides agree on. + +Studio ships this module's own source into the Sandbox, so the program that runs is the +code the tests exercise. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat as stat_module +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA_VERSION = 1 +ARTIFACT_NAME = "migration-result.zip" +RESULT_NAME = "migration-result.json" +STATUS_NAME = "migration-status.json" +REPORT_NAME = "convert_report.md" +FINDINGS_NAME = "validation_findings.json" +CLI_NAME = "agentkit-cli" +DELIVERY_PHASE = "completed" +DELIVERY_MESSAGE = "Migration artifact is ready" +DELIVERY_SCHEMA_VERSION = 1 + +# The agent's terminal states and the delivery state each one settles into, mirrored +# from the CLI's own projection in `runAgenticMigrationCore`. +DELIVERY_STATE_BY_AGENT_STATE = { + "Succeed": "succeeded", + "SucceedWithWarnings": "succeeded_with_warnings", + "Partial": "partial", +} +_AGENT_STATE_BY_RAW = { + "running": "Runnning", + "succeeded": "Succeed", + "succeeded_with_warnings": "SucceedWithWarnings", + "partial": "Partial", + "failed": "Failed", +} +SETTLED_DELIVERY_STATES = frozenset( + {"succeeded", "succeeded_with_warnings", "partial", "failed"} +) + +# File selection, mirrored from the CLI's `collectDeliveryFiles` and `shouldExclude`. +EXCLUDED_PATHS = frozenset( + {".env", ".codex", ".agentkit/migrate", ".agentkit/artifacts"} +) +EXCLUDED_DIRECTORIES = frozenset( + { + ".git", + ".venv", + "venv", + "__pycache__", + "node_modules", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + } +) +EXCLUDED_FILE_SUFFIXES = (".pyc", ".pyo", ".DS_Store", "~") +SECRET_FILE_SUFFIXES = (".key", ".pem", ".p12", ".pfx", ".jks") +ENVIRONMENT_EXAMPLE_NAMES = (".env.example", ".env.sample", ".env.template") +FINDING_SEVERITIES = ("fatal", "repairable", "degraded", "info") +MAX_FILES = 20_000 +MAX_BYTES = 512 * 1024 * 1024 +MAX_FILE_BYTES = 128 * 1024 * 1024 +MAX_PATH_BYTES = 4 * 1024 +MAX_DEPTH = 64 + +_PATH_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +_SECRET_ENVIRONMENT_NAME = re.compile( + r"(?:API_KEY|ACCESS_KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY)", + re.IGNORECASE, +) +_SECRET_ENVIRONMENT_REFERENCE = re.compile( + r"(?:API_KEY|ACCESS_KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY)_ENV$", + re.IGNORECASE, +) +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_ENVIRONMENT_LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$") +_INSECURE_ENVIRONMENT_VALUE = re.compile( + r"API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL", + re.IGNORECASE, +) + + +class RecoveryError(RuntimeError): + """The Sandbox state cannot be turned into the delivery the CLI would have made.""" + + +class LifecycleConfigError(RecoveryError): + """agentkit.yaml exists but cannot be read as a YAML mapping at all.""" + + +def recovery_source() -> str: + """The recovery program Studio installs into the Sandbox.""" + return Path(__file__).read_text(encoding="utf-8") + + +def manifest_digest(files: object) -> str: + """Fingerprint one delivery file list, independent of the order it was walked in. + + Studio compares this against the digest of the manifest the CLI itself wrote, so a + recovered delivery can be shown to describe exactly the same bytes. + """ + if not isinstance(files, list): + raise RecoveryError("delivery files must be a list") + lines: list[str] = [] + for item in files: + if not isinstance(item, dict): + raise RecoveryError("delivery file must be an object") + lines.append( + "\0".join( + ( + str(item.get("path") or ""), + str(item.get("size") or ""), + str(item.get("sha256") or ""), + str(item.get("mode") or ""), + ) + ) + ) + digest = hashlib.sha256() + digest.update("\n".join(sorted(lines)).encode("utf-8")) + return digest.hexdigest() + + +def _now() -> str: + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f") + return f"{stamp[:-3]}Z" + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _atomic_write_json(path: Path, value: object) -> None: + temporary = path.with_name(f"{path.name}.tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + os.replace(temporary, path) + + +def _read_json(path: Path) -> dict[str, object] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _agent_state(value: object) -> str | None: + """Mirror the CLI's `processState`: names, plus the short spellings it accepts.""" + if not isinstance(value, str): + return None + if value in {"Analysing", "Runnning", "Validating"} | set( + DELIVERY_STATE_BY_AGENT_STATE + ) | {"Failed"}: + return value + return _AGENT_STATE_BY_RAW.get(value) + + +def _read_agent_state(path: Path) -> tuple[dict[str, object], str]: + state = _read_json(path) + if state is None: + raise RecoveryError(f"the agent left no readable terminal state at {path}") + resolved = _agent_state(state.get("state")) + if resolved is None: + raise RecoveryError("the agent left no usable terminal state") + return state, resolved + + +def _should_exclude(relative: str, name: str, is_directory: bool) -> bool: + if relative in EXCLUDED_PATHS: + return True + if is_directory and name in EXCLUDED_DIRECTORIES: + return True + return relative.endswith(EXCLUDED_FILE_SUFFIXES) + + +def _is_secret_environment_file(relative: str) -> bool: + name = Path(relative).name.lower() + return name.startswith(".env") and name not in ENVIRONMENT_EXAMPLE_NAMES + + +def _configured_secrets() -> list[tuple[str, bytes]]: + """Environment values that must never leave the Sandbox inside a delivery.""" + secrets: list[tuple[str, bytes]] = [] + for name, value in os.environ.items(): + if not _SECRET_ENVIRONMENT_NAME.search(name) or len(value) < 8: + continue + if _SECRET_ENVIRONMENT_REFERENCE.search(name) and _ENVIRONMENT_NAME.fullmatch( + value + ): + continue + secrets.append((name, value.encode("utf-8"))) + return secrets + + +def _assert_no_secret(path: Path, secrets: list[tuple[str, bytes]]) -> None: + try: + content = path.read_bytes() + except OSError as error: + raise RecoveryError(f"cannot read {path.name}: {error}") from error + for name, value in secrets: + if value in content: + raise RecoveryError(f"the project contains a real secret from {name}") + + +def collect_delivery_files(output_dir: Path) -> list[dict[str, object]]: + """Walk the delivered project the way the CLI walks it.""" + root = output_dir.resolve() + if not root.is_dir(): + raise RecoveryError(f"the migration output directory does not exist: {root}") + secrets = _configured_secrets() + files: list[dict[str, object]] = [] + total_bytes = 0 + + def walk(directory: Path) -> None: + nonlocal total_bytes + try: + names = sorted(os.listdir(directory)) + except OSError as error: + raise RecoveryError(f"cannot list {directory}: {error}") from error + for name in names: + absolute = directory / name + relative = absolute.relative_to(root).as_posix() + info = absolute.lstat() + if stat_module.S_ISLNK(info.st_mode): + raise RecoveryError(f"the project contains a symbolic link: {relative}") + is_directory = stat_module.S_ISDIR(info.st_mode) + if _should_exclude(relative, name, is_directory): + continue + if is_directory: + walk(absolute) + continue + if not stat_module.S_ISREG(info.st_mode): + raise RecoveryError( + f"the project contains a non-regular file: {relative}" + ) + if ( + _PATH_CONTROL_CHARACTERS.search(relative) + or relative.startswith("/") + or ".." in relative.split("/") + or len(relative.encode("utf-8")) > MAX_PATH_BYTES + or len(relative.split("/")) > MAX_DEPTH + ): + raise RecoveryError(f"the project contains an unsafe path: {relative}") + if _is_secret_environment_file(relative) or relative.lower().endswith( + SECRET_FILE_SUFFIXES + ): + raise RecoveryError( + f"the project contains a secret-bearing file: {relative}" + ) + if info.st_size > MAX_FILE_BYTES: + raise RecoveryError(f"{relative} exceeds the delivery file size limit") + _assert_no_secret(absolute, secrets) + total_bytes += info.st_size + if len(files) + 1 > MAX_FILES or total_bytes > MAX_BYTES: + raise RecoveryError("the project exceeds the delivery size limits") + files.append( + { + "path": relative, + "size": info.st_size, + "sha256": _sha256_file(absolute), + "mode": f"0{info.st_mode & 0o777:o}", + } + ) + + walk(root) + if not files: + raise RecoveryError("the migration output contains no deliverable files") + return files + + +def create_verified_zip( + output_dir: Path, + delivery_dir: Path, + paths: list[str], +) -> dict[str, object]: + """Write the artifact exactly as the CLI writes it, then verify what it wrote.""" + for executable in ("zip", "unzip"): + if subprocess.run( + [executable, "-v"], capture_output=True, check=False + ).returncode not in (0, 1): + raise RecoveryError(f"{executable} is required to package a delivery") + final = delivery_dir / ARTIFACT_NAME + temporary = ( + delivery_dir / f".migration-result-{os.getpid()}-{int(time.time() * 1000)}.zip" + ) + try: + packaged = subprocess.run( + ["zip", "-q", "-X", str(temporary), "-@"], + cwd=str(output_dir), + input="".join(f"{path}\n" for path in paths), + capture_output=True, + text=True, + check=False, + ) + if packaged.returncode != 0: + raise RecoveryError(f"packaging failed: {packaged.stderr.strip()}") + listed = subprocess.run( + ["unzip", "-Z1", str(temporary)], + capture_output=True, + text=True, + check=False, + ) + if listed.returncode != 0: + raise RecoveryError( + f"verifying the archive failed: {listed.stderr.strip()}" + ) + entries = sorted(entry for entry in listed.stdout.splitlines() if entry) + if entries != sorted(paths): + raise RecoveryError("the archive does not match the delivery file list") + os.replace(temporary, final) + finally: + if temporary.exists(): + temporary.unlink() + info = final.lstat() + if not stat_module.S_ISREG(info.st_mode): + raise RecoveryError("the artifact was not written as a regular file") + return { + "path": ARTIFACT_NAME, + "size": info.st_size, + "sha256": _sha256_file(final), + } + + +def _read_findings(output_dir: Path) -> dict[str, list[dict[str, str]]] | None: + """Read the validation findings the way the CLI reads them. + + The CLI parses this file whole or not at all: when it cannot be parsed the run has + no findings, so the delivery carries no check and no warning for any severity. + Entries are normalized rather than rejected, so a finding that is not even an object + still reaches the verification record instead of voiding the rest of the file. + """ + path = output_dir / FINDINGS_NAME + try: + if not path.is_file(): + return None + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return None + if not isinstance(raw, dict): + return None + findings: dict[str, list[dict[str, str]]] = {} + for severity in FINDING_SEVERITIES: + entries = raw.get(severity) + findings[severity] = [ + _normalized_finding(entry, severity) + for entry in (entries if isinstance(entries, list) else []) + ] + return findings + + +def _normalized_finding(value: object, severity: str) -> dict[str, str]: + """One finding, in the shape the CLI's own parser produces.""" + if not isinstance(value, dict): + return { + "name": "(invalid)", + "status": "invalid", + "severity": severity, + "detail": "validation finding is not an object", + } + name = value.get("name") + status = value.get("status") + detail = value.get("detail") + return { + "name": ( + name.strip() if isinstance(name, str) and name.strip() else "(unnamed)" + ), + "status": ( + status.strip() if isinstance(status, str) and status.strip() else severity + ), + "severity": severity, + "detail": detail if isinstance(detail, str) else "", + } + + +def _verification(state: str, output_dir: Path) -> dict[str, object]: + findings = _read_findings(output_dir) + checks: list[dict[str, object]] = [] + if findings is not None: + for severity in FINDING_SEVERITIES: + for finding in findings[severity]: + check: dict[str, object] = { + "name": finding["name"], + "status": ( + "failed" + if finding["severity"] in {"fatal", "repairable"} + else "passed" + ), + } + if finding["detail"]: + check["detail"] = finding["detail"] + checks.append(check) + return { + "status": ( + "passed" + if state == "Succeed" + else "degraded" + if state in {"SucceedWithWarnings", "Partial"} + else "failed" + ), + "checks": checks, + "warnings": _findings_warnings(findings), + } + + +def _findings_warnings(findings: dict[str, list[dict[str, str]]] | None) -> list[str]: + if not findings: + return [] + return [ + f"{finding['name']}: {finding['detail'] or finding['status']}" + for severity in ("repairable", "degraded") + for finding in findings[severity] + ] + + +def _yaml_body(line: str) -> str: + """The content of one YAML line, with any trailing comment cut off.""" + quote = "" + for index, character in enumerate(line): + if quote: + if character == quote: + quote = "" + continue + if character in "\"'": + quote = character + continue + if character == "#" and (index == 0 or line[index - 1] in " \t"): + return line[:index].rstrip() + return line.rstrip() + + +def _yaml_scalar(value: str) -> str: + """One plain or quoted scalar, without the quotes that carried it.""" + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + inner = value[1:-1] + if value[0] == '"': + return inner.replace('""', '"') + return inner.replace("''", "'") + return value + + +def _lifecycle_entry_point(config_path: Path) -> str | None: + """Read ``common.entry_point`` out of agentkit.yaml. + + The Sandbox interpreter carries no YAML library, and the CLI writes this file from + its own template, so this reader understands exactly what that template emits: a + block mapping under a top-level ``common:`` key. Anything else reports "no usable + entry point", which is how the CLI itself treats a lifecycle config that does not + declare one -- the delivery still comes out, marked a non-deployable partial result. + Since the reader is deliberately narrower than YAML, a file it cannot follow is + reported as undeclared rather than as invalid. + """ + try: + text = config_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + raise LifecycleConfigError(f"{error}") from error + common_indent: int | None = None + child_indent: int | None = None + for line in re.split(r"\r?\n", text): + body = _yaml_body(line) + if not body: + continue + indentation = line[: len(line) - len(line.lstrip(" \t"))] + if "\t" in indentation: + # YAML forbids a tab where indentation belongs, and so does the CLI's parser. + raise LifecycleConfigError("tab characters must not be used in indentation") + indent = len(indentation) + key, separator, value = body.partition(":") + if not separator: + continue + key = key.strip() + value = value.strip() + if common_indent is None: + if indent or key != "common": + continue + if value: + return None + common_indent = indent + continue + if indent <= common_indent: + return None + if child_indent is None: + child_indent = indent + if key != "entry_point" or indent != child_indent: + continue + if not value or value[0] in "{[": + return None + return _yaml_scalar(value).strip() or None + return None + + +def _deliverable_module(output_dir: Path, module: str) -> str | None: + """Return the module's path inside the project, or None when it is not there.""" + if not module: + return None + candidate = (output_dir / module).resolve() + root = output_dir.resolve() + if candidate == root or not candidate.is_relative_to(root): + return None + try: + info = candidate.lstat() + except OSError: + return None + if not stat_module.S_ISREG(info.st_mode) or stat_module.S_ISLNK(info.st_mode): + return None + return candidate.relative_to(root).as_posix() + + +def resolve_startup(output_dir: Path) -> tuple[dict[str, object], list[str]]: + """Resolve the startup module the CLI would package, warnings included.""" + legacy = _deliverable_module(output_dir, "main.py") + config_path = output_dir / "agentkit.yaml" + if not config_path.is_file(): + return {"module": legacy or "main.py", "object": "app"}, [] + try: + entry = _lifecycle_entry_point(config_path) or "" + except LifecycleConfigError as error: + if legacy: + return ( + {"module": legacy, "object": "app"}, + [ + "Invalid agentkit.yaml; packaged " + f"{legacy} as a non-deployable partial result: {error}" + ], + ) + raise RecoveryError(f"invalid migration lifecycle config: {error}") from error + configured_module = _deliverable_module(output_dir, entry) if entry else None + if configured_module: + return {"module": configured_module, "object": "app"}, [] + if legacy: + reason = ( + "agentkit.yaml common.entry_point does not identify a deliverable file; " + if entry + else "agentkit.yaml does not declare common.entry_point; " + ) + return ( + {"module": legacy, "object": "app"}, + [f"{reason}packaged {legacy} as a non-deployable partial result."], + ) + raise RecoveryError( + "the lifecycle entry point is not part of the deliverable project: " + f"{entry or 'agentkit.yaml'}" + ) + + +def environment_requirements(output_dir: Path) -> dict[str, list[str]]: + """Mirror the CLI's `.env.example` reading exactly, name classification included.""" + example = output_dir / ".env.example" + if not example.is_file(): + return {"required": [], "optional": []} + required: set[str] = set() + optional: set[str] = set() + try: + content = example.read_text(encoding="utf-8") + except OSError: + return {"required": [], "optional": []} + for line in re.split(r"\r?\n", content): + match = _ENVIRONMENT_LINE.match(line) + if not match: + continue + name, value = match.group(1), match.group(2).strip() + if not value or _INSECURE_ENVIRONMENT_VALUE.search(name): + required.add(name) + else: + optional.add(name) + return { + "required": sorted(required), + "optional": sorted(name for name in optional if name not in required), + } + + +def _settled_status(delivery_dir: Path) -> dict[str, object] | None: + status = _read_json(delivery_dir / STATUS_NAME) + if status is None: + return None + if status.get("state") in SETTLED_DELIVERY_STATES: + return status + return None + + +def mirror_delivery(config: dict[str, object]) -> dict[str, object]: + """Write the delivery the CLI would have written, and report what it wrote.""" + output_dir = Path(str(config.get("output_dir") or "")) + delivery_dir = Path(str(config.get("delivery_dir") or "")) + status_path = Path(str(config.get("status_path") or "")) + run_id = str(config.get("run_id") or "") + framework = str(config.get("framework") or "") + source_sha256 = str(config.get("source_sha256") or "") + provenance_sha256 = str(config.get("provenance_sha256") or "") + if ( + not run_id + or not framework + or len(source_sha256) != 64 + or len(provenance_sha256) != 64 + ): + raise RecoveryError("the recovery request is incomplete") + if not output_dir.is_dir(): + raise RecoveryError( + f"the migration output directory does not exist: {output_dir}" + ) + if ( + delivery_dir.resolve() == output_dir.resolve() + or delivery_dir.resolve().is_relative_to(output_dir.resolve()) + ): + raise RecoveryError("the delivery directory must sit outside the project") + settled = _settled_status(delivery_dir) + if settled is not None: + raise RecoveryError("the migration CLI already settled this delivery") + _, agent_state = _read_agent_state(status_path) + delivery_state = DELIVERY_STATE_BY_AGENT_STATE.get(agent_state) + if delivery_state is None: + raise RecoveryError( + f"the agent stopped in {agent_state}, which is not a delivered project" + ) + files = collect_delivery_files(output_dir) + known = {str(item["path"]) for item in files} + if REPORT_NAME not in known: + raise RecoveryError( + f"the migration report is not part of the project: {REPORT_NAME}" + ) + startup, startup_warnings = resolve_startup(output_dir) + startup_module = str(startup["module"]) + if startup_module not in known: + raise RecoveryError( + f"the startup module is not part of the project: {startup_module}" + ) + verification = _verification(agent_state, output_dir) + warnings = list(verification["warnings"]) # type: ignore[arg-type] + if startup_warnings: + verification["status"] = "degraded" + verification["checks"].append( # type: ignore[union-attr] + *( + { + "name": "startup:lifecycle_config", + "status": "failed", + "detail": detail, + } + for detail in startup_warnings + ) + ) + warnings.extend(startup_warnings) + settled_delivery_state = ( + "partial" + if startup_warnings or agent_state == "Partial" + else DELIVERY_STATE_BY_AGENT_STATE[agent_state] + ) + delivery_dir.mkdir(parents=True, exist_ok=True) + artifact = create_verified_zip( + output_dir, + delivery_dir, + [str(item["path"]) for item in files], + ) + result = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "cli": { + "name": CLI_NAME, + "version": str(config.get("cli_version") or "unknown"), + }, + "migration": { + "engine": "agentic", + "framework": framework, + "source_sha256": source_sha256, + "provenance_sha256": provenance_sha256, + }, + "status": settled_delivery_state, + "files": files, + "startup": startup, + "environment": environment_requirements(output_dir), + "verification": { + "status": verification["status"], + "checks": verification["checks"], + }, + "warnings": warnings, + "report": {"path": REPORT_NAME}, + "artifact": artifact, + "created_at": _now(), + } + _atomic_write_json(delivery_dir / RESULT_NAME, result) + previous = _read_json(delivery_dir / STATUS_NAME) or {} + sequence = previous.get("sequence") + sequence = ( + sequence + 1 + if isinstance(sequence, int) and not isinstance(sequence, bool) + else 1 + ) + _atomic_write_json( + delivery_dir / STATUS_NAME, + { + "schema_version": DELIVERY_SCHEMA_VERSION, + "run_id": run_id, + "sequence": sequence, + "state": settled_delivery_state, + "phase": DELIVERY_PHASE, + "message": DELIVERY_MESSAGE, + "artifact": { + "state": "ready", + "preview_ready": True, + "download_ready": True, + "deploy_ready": ( + settled_delivery_state != "partial" + and verification["status"] != "failed" + ), + }, + "updated_at": _now(), + }, + ) + return { + "agent_state": agent_state, + "status": settled_delivery_state, + "files": len(files), + "bytes": sum(int(item["size"]) for item in files), + "manifest_sha256": manifest_digest(files), + "artifact": artifact, + } + + +def main(argv: list[str] | None = None) -> int: + """Run one recovery from a JSON request file, and answer in JSON.""" + arguments = list(sys.argv[1:] if argv is None else argv) + if len(arguments) != 1: + print( + json.dumps({"ok": False, "error": "usage: delivery_recovery.py "}) + ) + return 2 + try: + config = json.loads(Path(arguments[0]).read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError) as error: + print(json.dumps({"ok": False, "error": f"unreadable request: {error}"})) + return 2 + if not isinstance(config, dict): + print(json.dumps({"ok": False, "error": "the request must be an object"})) + return 2 + try: + outcome = mirror_delivery(config) + except RecoveryError as error: + print(json.dumps({"ok": False, "error": str(error)}, ensure_ascii=False)) + return 1 + except Exception as error: # noqa: BLE001 - the caller only reads this report + print( + json.dumps( + {"ok": False, "error": f"{type(error).__name__}: {error}"}, + ensure_ascii=False, + ) + ) + return 1 + print(json.dumps({"ok": True, **outcome}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/server/migration/delivery_turn.py b/frontend/server/migration/delivery_turn.py new file mode 100644 index 000000000..3c7a17f32 --- /dev/null +++ b/frontend/server/migration/delivery_turn.py @@ -0,0 +1,412 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Close one migration delivery on a Studio-owned Codex app-server turn. + +The Sandbox runs the migration CLI and writes the delivery triple itself, so the +delivery phase used to end the moment the launch script published its status: a run +that stopped early reached the page as a generic "the command did not finish", and a +run that produced warnings reached it as one fixed sentence with the details left in a +log nobody opens. + +The closing turn keeps the AgentKit CLI as the authority on *what happened* and makes +Studio the authority on *what the user is told*: + +* the artifact is published through a dynamic tool, so Studio reads the bytes back and + checks them against the manifest the CLI wrote before the delivery may complete; +* the verdict arrives as typed arguments and is validated against the state Studio + derived from the Sandbox, so a turn that misreads a log can explain a delivery but + never upgrade or downgrade it; +* a failed run is diagnosed inside the turn, which can also ask for information only a + human has instead of leaving an unusable "see the logs" behind. + +``publishArtifact`` is why this turn has to live on the Studio side at all: dynamic +tools are registered on a Studio-driven app-server turn, and the Sandbox has no return +path to Studio, so an in-Sandbox ``codex exec`` can never call one. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +from veadk.cli.codex_app_server import CodexDynamicToolResult + +from .codex_tool_turn import DynamicTool, ToolTurnUnavailable, run_tool_turn + +DELIVERY_TOOL_NAME = "reportDelivery" +DELIVERY_TOOL_DESCRIPTION = ( + "提交本次迁移交付的最终结论。必须调用一次,参数严格遵循给定的 JSON Schema;" + "被拒绝时按返回的错误修正后重新调用。" +) +ARTIFACT_TOOL_NAME = "publishArtifact" +ARTIFACT_TOOL_DESCRIPTION = ( + "发布本次迁移交付的产物文件,由 Studio 读取并核对字节。" + "参数 path 固定为 migration-result.zip。" +) + +# ``askUser`` inside the closing turn: the analysis wording promises a read-only +# analysis, and this turn is about a delivery that already happened. +DELIVERY_ASK_TOOL_DESCRIPTION = ( + "在交付收尾过程中向用户提出必须由用户决定的问题,并等待用户回答。" + "只在交付结论依赖用户才知道的信息时提问(例如缺失的密钥、部署目标、" + "是否接受降级交付),一次提出 1-3 个问题,每个问题给出简短 header 和完整 question;" + "有自然选择时给出 2-3 个 options(每个含 label 和 description,第一项为推荐项)。" +) + +# The states the AgentKit CLI can settle a delivery in. The turn may only report the +# one Studio derived from the Sandbox evidence. +DELIVERY_STATES = frozenset( + {"succeeded", "succeeded_with_warnings", "partial", "failed"} +) +ARTIFACT_PATH = "migration-result.zip" +_MAX_MESSAGE_CHARS = 600 +_MIN_MESSAGE_CHARS = 4 +_MAX_WARNINGS = 8 +_MAX_WARNING_CHARS = 400 + + +DELIVERY_APP_SERVER_ENV = "AGENTKIT_MIGRATION_DELIVERY_APP_SERVER" +_DISABLED_VALUES = {"0", "false", "no", "off"} + + +def delivery_app_server_enabled() -> bool: + """Whether a finished delivery is closed on a Studio Codex app-server turn. + + The turn explains the delivery the AgentKit CLI already settled and publishes its + artifact, so it is additive: when the app-server is unreachable, or when this is + switched off, the task keeps the CLI's own delivery state. Set + ``AGENTKIT_MIGRATION_DELIVERY_APP_SERVER=0`` to pin that scripted behaviour. + """ + return ( + os.getenv(DELIVERY_APP_SERVER_ENV, "").strip().lower() not in _DISABLED_VALUES + ) + + +class DeliveryContractError(ValueError): + """One delivery turn payload did not satisfy the delivery contract.""" + + +class DeliveryTurnUnavailable(ToolTurnUnavailable): + """The delivery turn ended without a verdict Studio can publish.""" + + +@dataclass(frozen=True) +class PublishedArtifact: + """An artifact Studio read back from the Sandbox and checked against the manifest.""" + + path: str + sha256: str + size: int + + def public(self) -> dict[str, object]: + return {"path": self.path, "sha256": self.sha256, "size": self.size} + + +# Reads the artifact inside the Sandbox and returns its verified descriptor, or raises +# ``DeliveryContractError`` when the bytes disagree with what the CLI manifest says. +ArtifactPublisher = Callable[[str], PublishedArtifact] + + +def _exact_arguments( + arguments: dict[str, object], + *, + expected: set[str], + tool: str, +) -> None: + """Reject arguments the tool schema does not describe. + + A dynamic tool result is typed JSON-RPC, and an unexpected field means the model is + answering a different contract than the one Studio registered. + """ + unknown = sorted(set(arguments) - expected) + if unknown: + raise DeliveryContractError(f"{tool} 不接受参数 " + "、".join(unknown)) + + +def _bounded_text(value: object, field: str, limit: int, *, minimum: int = 1) -> str: + if not isinstance(value, str): + raise DeliveryContractError(f"{field} 必须是字符串") + text = value.strip() + if len(text) < minimum: + raise DeliveryContractError( + f"{field} 必须说明具体情况(至少 {minimum} 个字符)" + ) + return text[:limit] + + +def _warnings(value: object) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise DeliveryContractError("warnings 必须是字符串数组") + warnings: list[str] = [] + for entry in value[:_MAX_WARNINGS]: + if not isinstance(entry, str): + raise DeliveryContractError("warnings 必须是字符串数组") + text = entry.strip() + if text: + warnings.append(text[:_MAX_WARNING_CHARS]) + return warnings + + +class DeliveryRecorder: + """Validate and retain the artifact and the verdict of one delivery turn. + + ``expected_state`` is the state Studio derived from the Sandbox evidence, and any + other state is rejected: the turn explains the delivery, it does not decide it. + """ + + def __init__( + self, + *, + run_id: str, + expected_state: str, + publisher: ArtifactPublisher, + ) -> None: + self.run_id = run_id + self.expected_state = expected_state + self.artifact: PublishedArtifact | None = None + self.verdict: dict[str, object] | None = None + self.rejections: list[str] = [] + self._publisher = publisher + + @property + def ready(self) -> bool: + return self.verdict is not None + + def publish(self, arguments: dict[str, object]) -> CodexDynamicToolResult: + try: + _exact_arguments( + arguments, + expected={"path"}, + tool=ARTIFACT_TOOL_NAME, + ) + except DeliveryContractError as error: + self.rejections.append(str(error)) + return CodexDynamicToolResult(False, f"参数不符合协议({error})。") + path = arguments.get("path") + if path != ARTIFACT_PATH: + self.rejections.append("artifact path") + return CodexDynamicToolResult( + False, + f"产物路径必须是 {ARTIFACT_PATH},而不是 {path!r}。", + ) + try: + artifact = self._publisher(ARTIFACT_PATH) + except DeliveryContractError as error: + self.rejections.append(str(error)) + return CodexDynamicToolResult(False, f"产物核对失败({error})。") + except Exception as error: # noqa: BLE001 - a Sandbox read must not kill the turn + self.rejections.append(type(error).__name__) + return CodexDynamicToolResult( + False, + "产物读取失败,请确认迁移产物已经生成后重新调用。", + ) + self.artifact = artifact + return CodexDynamicToolResult( + True, + f"产物已核对:path={artifact.path} sha256={artifact.sha256} " + f"size={artifact.size}。请继续调用 {DELIVERY_TOOL_NAME} 提交结论。", + ) + + def report(self, arguments: dict[str, object]) -> CodexDynamicToolResult: + try: + _exact_arguments( + arguments, + expected={"state", "message", "warnings"}, + tool=DELIVERY_TOOL_NAME, + ) + except DeliveryContractError as error: + self.rejections.append(str(error)) + return CodexDynamicToolResult(False, f"参数不符合协议({error})。") + state = arguments.get("state") + if state not in DELIVERY_STATES: + self.rejections.append("state") + return CodexDynamicToolResult( + False, + "state 必须是 " + "、".join(sorted(DELIVERY_STATES)) + " 之一。", + ) + if state != self.expected_state: + self.rejections.append("state mismatch") + return CodexDynamicToolResult( + False, + f"这次交付的 state 已经确定为 {self.expected_state}," + "请按沙箱里的交付证据重新调用。", + ) + try: + message = _bounded_text( + arguments.get("message"), + "message", + _MAX_MESSAGE_CHARS, + minimum=_MIN_MESSAGE_CHARS, + ) + warnings = _warnings(arguments.get("warnings")) + except DeliveryContractError as error: + self.rejections.append(str(error)) + return CodexDynamicToolResult(False, f"结论不符合协议({error})。") + if state == "failed" and self.artifact is not None: + self.rejections.append("failed delivery published an artifact") + return CodexDynamicToolResult( + False, + "这次交付已经失败,不应该有产物;请确认后重新调用。", + ) + if state != "failed" and self.artifact is None: + self.rejections.append("missing artifact") + return CodexDynamicToolResult( + False, + f"请先调用 {ARTIFACT_TOOL_NAME} 发布产物,再提交结论。", + ) + self.verdict = {"state": state, "message": message, "warnings": warnings} + return CodexDynamicToolResult(True, "交付结论已接收,请结束本轮。") + + +async def run_delivery_turn( + *, + endpoint: str, + prompt: str, + cwd: str, + run_id: str, + expected_state: str, + publisher: ArtifactPublisher, + model: str = "", + timeout_seconds: float, + event_sink: Callable[[object], None] | None = None, + extra_tools: Sequence[DynamicTool] = (), + idle_timeout_seconds: float | None = None, + host_wait_seconds: Callable[[], float] | None = None, +) -> dict[str, object] | None: + """Publish the artifact and the verdict of one finished delivery. + + Returns the validated report, or ``None`` when the turn ended without one; the + delivery state itself stays whatever the AgentKit CLI published. + """ + recorder = DeliveryRecorder( + run_id=run_id, + expected_state=expected_state, + publisher=publisher, + ) + try: + await run_tool_turn( + endpoint=endpoint, + prompt=prompt, + cwd=cwd, + tool_name=DELIVERY_TOOL_NAME, + tool_description=DELIVERY_TOOL_DESCRIPTION, + tool_schema=delivery_schema(expected_state), + handler=recorder.report, + has_result=lambda: recorder.ready, + model=model, + timeout_seconds=timeout_seconds, + event_sink=event_sink, + extra_tools=( + DynamicTool( + name=ARTIFACT_TOOL_NAME, + description=ARTIFACT_TOOL_DESCRIPTION, + schema=artifact_schema(), + handler=recorder.publish, + ), + *extra_tools, + ), + idle_timeout_seconds=idle_timeout_seconds, + host_wait_seconds=host_wait_seconds, + ) + except ToolTurnUnavailable as error: + # 任何回合故障都要变成调用方认识的那一种,否则它的兜底分支不会触发。 + raise DeliveryTurnUnavailable(str(error)) from error + if recorder.verdict is None: + return None + return { + "schema_version": 1, + "run_id": run_id, + "driver": "app-server", + **recorder.verdict, + "artifact": recorder.artifact.public() if recorder.artifact else None, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + + +def artifact_schema() -> dict[str, object]: + return { + "type": "object", + "additionalProperties": False, + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": f"产物文件名,固定为 {ARTIFACT_PATH}。", + } + }, + } + + +def delivery_schema(expected_state: str = "") -> dict[str, object]: + """The verdict schema, narrowed to the state the Sandbox already published. + + Offering all four states invites a wrong pick - a successful delivery that carries a + warning looks like ``succeeded_with_warnings`` - and a rejected verdict costs the + whole attempt. The handler keeps checking the state as the server-side guard. + """ + known = expected_state in DELIVERY_STATES + return { + "type": "object", + "additionalProperties": False, + "required": ["state", "message", "warnings"], + "properties": { + "state": { + "type": "string", + "enum": [expected_state] if known else sorted(DELIVERY_STATES), + "description": ( + f"本次交付的终态已经确定为 {expected_state},只能是这个值。" + if known + else "本次交付的终态,必须与沙箱里的交付状态一致。" + ), + }, + "message": { + "type": "string", + "description": ( + "给用户看的中文结论:成功时说明产物内容;失败时说明失败在哪一步、" + "关键证据是什么、用户下一步可以做什么。" + ), + }, + "warnings": { + "type": "array", + "items": {"type": "string"}, + "description": "用户需要知道的迁移提示,没有就留空数组。", + }, + }, + } + + +__all__ = [ + "ARTIFACT_PATH", + "DELIVERY_APP_SERVER_ENV", + "DELIVERY_ASK_TOOL_DESCRIPTION", + "ARTIFACT_TOOL_DESCRIPTION", + "ARTIFACT_TOOL_NAME", + "DELIVERY_STATES", + "DELIVERY_TOOL_DESCRIPTION", + "DELIVERY_TOOL_NAME", + "ArtifactPublisher", + "DeliveryContractError", + "DeliveryRecorder", + "DeliveryTurnUnavailable", + "PublishedArtifact", + "artifact_schema", + "delivery_app_server_enabled", + "delivery_schema", + "run_delivery_turn", +] diff --git a/frontend/server/migration/detection.py b/frontend/server/migration/detection.py new file mode 100644 index 000000000..f14ca5b61 --- /dev/null +++ b/frontend/server/migration/detection.py @@ -0,0 +1,243 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Deterministic, model-free detection of an uploaded migration source. + +Studio runs this inside its own process, on the archived bytes, before Codex ever +sees the project. Two things depend on it: + +* the analysis prompt receives the file inventory and the DSL families that were + recognised on disk, so Codex does not have to rediscover them by running shell + commands, and +* the destructive ``unsupported`` verdict is checked against a file inventory that + no model produced. + +It is deliberately coarse. It recognises the DSL families the migration CLI can +replay, reports the file inventory verbatim, and records everything it could not +read instead of dropping it silently. When a parser is unavailable the report says +so, so a missing dependency can never look like "the project contains nothing". +""" + +from __future__ import annotations + +import io +import zipfile +from typing import Any + +SCHEMA_VERSION = 1 + +# A report is an input to a prompt and to a verdict check, never a data transfer, so +# the inventory is capped while ``count`` stays exact. +_MAX_LISTED_FILES = 200 +_MAX_INSPECTED_FILES = 200 +_MAX_INSPECT_BYTES = 256 * 1024 +_INSPECT_SUFFIXES = (".yml", ".yaml") +_SKIP_DIRECTORIES = { + ".git", + ".hg", + ".svn", + "__pycache__", + "node_modules", + ".venv", + "venv", + "dist", + "build", +} +_METADATA_PREFIXES = ("__MACOSX/",) +_METADATA_NAMES = (".DS_Store",) + +_DIFY_DSL = "dify" + + +def detect_source(content: bytes) -> dict[str, object]: + """Return the detection report for one validated source archive.""" + unreadable: list[dict[str, str]] = [] + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile: + # Upload validation runs first, so this is an internal inconsistency rather + # than user input; report it instead of raising so analysis can continue. + return _report( + count=0, + listed=[], + documents=[], + candidates=[], + unreadable=[{"path": "", "reason": "archive_unreadable"}], + degraded_reason="archive_unreadable", + ) + with archive: + names = _file_names(archive) + count = len(names) + listed = names[:_MAX_LISTED_FILES] + documents: list[dict[str, object]] = [] + candidates: list[dict[str, object]] = [] + for name in names[:_MAX_INSPECTED_FILES]: + if not name.lower().endswith(_INSPECT_SUFFIXES): + continue + try: + info = archive.getinfo(name) + except KeyError: + continue + if info.file_size > _MAX_INSPECT_BYTES: + unreadable.append({"path": name, "reason": "too_large"}) + continue + try: + text = archive.read(name).decode("utf-8-sig") + except (UnicodeDecodeError, OSError, zipfile.BadZipFile): + unreadable.append({"path": name, "reason": "decode_failed"}) + continue + document = _inspect_document(name, text) + documents.append(document) + candidate = _candidate(document) + if candidate is not None: + candidates.append(candidate) + return _report( + count=count, + listed=listed, + documents=documents, + candidates=candidates, + unreadable=unreadable, + degraded_reason="", + ) + + +def _report( + *, + count: int, + listed: list[str], + documents: list[dict[str, object]], + candidates: list[dict[str, object]], + unreadable: list[dict[str, str]], + degraded_reason: str, +) -> dict[str, object]: + return { + "schema_version": SCHEMA_VERSION, + "files": {"count": count, "listed": listed}, + "documents": documents, + "candidates": candidates, + "unreadable": unreadable, + "degraded": bool(degraded_reason), + "degraded_reason": degraded_reason, + } + + +def _file_names(archive: zipfile.ZipFile) -> list[str]: + names: list[str] = [] + for info in archive.infolist(): + name = info.filename + if info.is_dir() or not name: + continue + if ( + name.startswith(_METADATA_PREFIXES) + or name.rsplit("/", 1)[-1] in _METADATA_NAMES + ): + continue + parts = name.split("/") + if any(part in _SKIP_DIRECTORIES for part in parts[:-1]): + continue + names.append(name) + names.sort() + return names + + +def _inspect_document(path: str, text: str) -> dict[str, object]: + document: dict[str, object] = { + "path": path, + "format": "yaml", + "status": "parsed", + "dsl": "", + "signals": [], + } + payload: Any = None + parse_error = "" + try: + import yaml + except ImportError: + document["status"] = "unparsed" + document["parse_error"] = "yaml_unavailable" + return document + try: + payload = yaml.safe_load(text) + except Exception: # noqa: BLE001 - any parser failure is a report fact, never fatal + parse_error = "yaml_invalid" + if parse_error or not isinstance(payload, dict): + document["status"] = "unparsed" + document["parse_error"] = parse_error or "not_a_mapping" + return document + signals = _dsl_signals(payload, text) + if signals: + document["dsl"] = _DIFY_DSL + document["signals"] = signals + return document + + +def _dsl_signals(payload: dict[object, object], text: str) -> list[dict[str, object]]: + """Recognise the Dify/Bailian DSL export the migration CLI can replay.""" + if str(payload.get("kind") or "").strip().lower() != "app": + return [] + app = payload.get("app") + workflow = payload.get("workflow") + if not isinstance(app, dict) or not isinstance(workflow, dict): + return [] + mode = str(app.get("mode") or "").strip() + graph = workflow.get("graph") + if not mode or not isinstance(graph, dict): + return [] + nodes = graph.get("nodes") + edges = graph.get("edges") + if not isinstance(nodes, list) or not isinstance(edges, list): + return [] + return [ + { + "path": "", + "line": _line_of(text, "kind:"), + "reason": "顶层 kind: app", + }, + { + "path": "", + "line": _line_of(text, "mode:"), + "reason": f"app.mode: {mode}", + }, + { + "path": "", + "line": _line_of(text, "graph:"), + "reason": f"workflow.graph 含 {len(nodes)} 个节点、{len(edges)} 条边", + }, + ] + + +def _candidate(document: dict[str, object]) -> dict[str, object] | None: + if document.get("dsl") != _DIFY_DSL: + return None + path = str(document.get("path") or "") + signals = document.get("signals") + evidence = [ + { + "path": path, + "line": int(signal.get("line") or 1), + "reason": str(signal.get("reason") or ""), + } + for signal in signals + if isinstance(signal, dict) + ] + return {"id": _DIFY_DSL, "confidence": "high", "evidence": evidence} + + +def _line_of(text: str, token: str) -> int: + for index, line in enumerate(text.splitlines(), start=1): + if token in line: + return index + return 1 + + +__all__ = ["SCHEMA_VERSION", "detect_source"] diff --git a/frontend/server/migration/evaluation/judge_app_server.py b/frontend/server/migration/evaluation/judge_app_server.py new file mode 100644 index 000000000..aae659b79 --- /dev/null +++ b/frontend/server/migration/evaluation/judge_app_server.py @@ -0,0 +1,271 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Judge one evaluation batch through the Sandbox app-server and its dynamic tool. + +The legacy judge ran ``codex exec --output-schema`` and parsed the last agent message. +This contract carries the same verdicts as typed ``reportEvaluation`` arguments, so a +progress note or a fenced code block can no longer be mistaken for the batch result, and +a rejected batch is answered inside the same turn so Codex can correct itself. +""" + +from __future__ import annotations + +from veadk.cli.codex_app_server import CodexDynamicToolResult + +from ..codex_tool_turn import ToolTurnUnavailable, run_tool_turn +from .judge_channel import JUDGE_TOOL_NAME +from .runner import judge_schema + +JUDGE_TOOL_DESCRIPTION = ( + "提交本批迁移效果评测的判定结果。必须一次性提交全部用例与全部维度," + "参数严格遵循给定的 JSON Schema;被拒绝时按返回的错误修正后重新调用。" +) + +EVIDENCE_SOURCES = frozenset( + { + "user_reference", + "user_criteria", + "source_contract", + "observed_output", + "runtime_observation", + "deterministic_assertion", + } +) +SEVERITIES = frozenset({"none", "low", "medium", "high", "critical", "unknown"}) +_WORKFLOW_DIMENSION = "workflow_tool_fidelity" +_MAX_EVIDENCE_ITEMS = 20 +_MAX_REASON_CHARS = 4 * 1024 +_MAX_EVIDENCE_CHARS = 2 * 1024 + + +class JudgeContractError(ValueError): + """One ``reportEvaluation`` payload did not satisfy the judging contract.""" + + +class JudgeTurnUnavailable(ToolTurnUnavailable): + """The Sandbox app-server could not deliver a judge batch. + + The turn ran but produced nothing the runner can validate: no verdict, or a verdict + that is not a case list. It is a ``ToolTurnUnavailable`` so the driver answers every + request that never delivered with an error envelope, whatever went wrong. + """ + + +def _required_text(value: object, field: str) -> str: + if not isinstance(value, str): + raise JudgeContractError(f"{field} 必须是字符串") + return value + + +def _bounded_text(value: object, field: str, limit: int) -> str: + text = _required_text(value, field).strip() + if not text: + raise JudgeContractError(f"{field} 不能为空") + return text[:limit] + + +def validate_judge_cases( + arguments: dict[str, object], + *, + case_context: list[dict[str, object]], + dimensions: list[str], +) -> dict[str, object]: + """Validate one batch verdict against the cases and dimensions it must cover. + + The runner repeats these checks before caching the batch, so a rejection here is + advice to Codex rather than the last line of defence. + """ + if not dimensions: + raise JudgeContractError("评测维度不能为空") + cases = arguments.get("cases") + if not isinstance(cases, list) or len(cases) != len(case_context): + raise JudgeContractError(f"cases 必须恰好包含 {len(case_context)} 个用例") + expected_ids = [str(item.get("case_id") or "") for item in case_context] + returned_ids = [ + item.get("case_id") if isinstance(item, dict) else None for item in cases + ] + if returned_ids != expected_ids: + raise JudgeContractError( + "cases 必须按给定顺序包含全部用例:" + "、".join(expected_ids) + ) + for index, (case, context) in enumerate(zip(cases, case_context)): + assert isinstance(case, dict) + results = case.get("dimensions") + if not isinstance(results, list) or len(results) != len(dimensions): + raise JudgeContractError( + f"cases[{index}].dimensions 必须恰好包含 {len(dimensions)} 个维度" + ) + returned_dimension_ids = [ + item.get("id") if isinstance(item, dict) else None for item in results + ] + if returned_dimension_ids != dimensions: + raise JudgeContractError( + f"cases[{index}].dimensions 必须按给定顺序包含全部维度:" + + "、".join(dimensions) + ) + failed_execution = str(context.get("state") or "") == "failed" + for dimension, result in zip(dimensions, results): + assert isinstance(result, dict) + field = f"cases[{index}].dimensions[{dimension}]" + score = result.get("score") + if score is not None and ( + isinstance(score, bool) + or not isinstance(score, (int, float)) + or not 0 <= score <= 1 + ): + raise JudgeContractError( + f"{field}.score 必须在 0 到 1 之间,证据不足时必须为 null" + ) + severity = result.get("severity") + if severity not in SEVERITIES: + raise JudgeContractError( + f"{field}.severity 只能是 " + "、".join(sorted(SEVERITIES)) + ) + if (score is None) is not (severity == "unknown"): + raise JudgeContractError( + f"{field} 的 score 与 severity 必须同时表示 N/A:" + "score 为 null 时 severity 必须是 unknown,反之亦然" + ) + result["reason"] = _bounded_text( + result.get("reason"), f"{field}.reason", _MAX_REASON_CHARS + ) + evidence = result.get("evidence") + if not isinstance(evidence, list) or any( + not isinstance(entry, str) for entry in evidence + ): + raise JudgeContractError(f"{field}.evidence 必须是字符串数组") + result["evidence"] = [ + entry.strip()[:_MAX_EVIDENCE_CHARS] + for entry in evidence[:_MAX_EVIDENCE_ITEMS] + if entry.strip() + ] + sources = result.get("evidence_sources") + if ( + not isinstance(sources, list) + or any(not isinstance(source, str) for source in sources) + or len(sources) != len(set(sources)) + or any(source not in EVIDENCE_SOURCES for source in sources) + ): + raise JudgeContractError( + f"{field}.evidence_sources 只能不重复地使用 " + + "、".join(sorted(EVIDENCE_SOURCES)) + ) + if failed_execution and score is not None: + raise JudgeContractError( + f"{field} 的执行用例失败,全部维度必须为 N/A(score 为 null、" + "severity 为 unknown)" + ) + if ( + dimension == _WORKFLOW_DIMENSION + and score is not None + and context.get("contract") is not True + and context.get("criteria") is not True + and context.get("runtime_observation") is not True + ): + raise JudgeContractError( + f"{field} 缺少 Runtime 原始可观察数据、用户标准和源行为契约," + "必须为 N/A" + ) + return {"cases": cases} + + +class JudgeRecorder: + """Validate and retain the first judge batch delivered by a dynamic tool call. + + ``result`` holds the validated tool arguments, mirroring ``RouteRecorder``; the + batch verdict itself is the ``cases`` list inside them. + """ + + def __init__( + self, + *, + case_context: list[dict[str, object]], + dimensions: list[str], + ) -> None: + self.case_context = case_context + self.dimensions = dimensions + self.result: dict[str, object] | None = None + self.rejections: list[str] = [] + + def submit(self, arguments: dict[str, object]) -> CodexDynamicToolResult: + try: + validated = validate_judge_cases( + arguments, + case_context=self.case_context, + dimensions=self.dimensions, + ) + except JudgeContractError as error: + self.rejections.append(str(error)) + return CodexDynamicToolResult( + False, + f"判定结果不符合协议({error})。请修正后重新调用 {JUDGE_TOOL_NAME}。", + ) + if self.result is None: + self.result = validated + return CodexDynamicToolResult( + True, + "本批判定已接收,请直接结束本轮,不要再输出其他内容。", + ) + + +async def run_judge_turn( + *, + endpoint: str, + prompt: str, + cwd: str, + case_context: list[dict[str, object]], + dimensions: list[str], + thread_id: str = "", + model: str = "", + timeout_seconds: float, +) -> tuple[list[dict[str, object]], str]: + """Judge one batch and return ``(cases, thread_id)``. + + The verdict is the ``cases`` list, not the whole tool call: the runner validates + that list against the batch it asked about, so Studio must not wrap it again. + """ + recorder = JudgeRecorder(case_context=case_context, dimensions=dimensions) + used_thread = await run_tool_turn( + endpoint=endpoint, + prompt=prompt, + cwd=cwd, + tool_name=JUDGE_TOOL_NAME, + tool_description=JUDGE_TOOL_DESCRIPTION, + tool_schema=judge_schema(), + handler=recorder.submit, + has_result=lambda: recorder.result is not None, + thread_id=thread_id, + model=model, + timeout_seconds=timeout_seconds, + ) + if recorder.result is None: + raise JudgeTurnUnavailable("裁判回合没有提交判定结果。") + cases = recorder.result.get("cases") + if not isinstance(cases, list): + raise JudgeTurnUnavailable("裁判回合没有提交可用的判定结果。") + return cases, used_thread + + +__all__ = [ + "EVIDENCE_SOURCES", + "JUDGE_TOOL_DESCRIPTION", + "JUDGE_TOOL_NAME", + "JudgeContractError", + "JudgeRecorder", + "JudgeTurnUnavailable", + "SEVERITIES", + "run_judge_turn", + "validate_judge_cases", +] diff --git a/frontend/server/migration/evaluation/judge_channel.py b/frontend/server/migration/evaluation/judge_channel.py new file mode 100644 index 000000000..ebdf146a9 --- /dev/null +++ b/frontend/server/migration/evaluation/judge_channel.py @@ -0,0 +1,121 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Durable request/response channel between the in-Sandbox judge and Studio. + +The evaluation runner stays the orchestrator: it owns batching, the thread record, the +batch cache, and the report. Studio owns the one capability the Sandbox cannot host - +a Codex app-server turn that delivers its verdict through a dynamic tool - so the two +sides meet on a single-slot file channel inside the Sandbox: + +* the runner writes ``request.json`` for the batch it is judging and waits for the + ``response.json`` that names the same request id; +* Studio reads the request, runs one app-server turn, and writes that response. + +The channel is a pure contract: it holds the file names, the enable switch, and the +dynamic tool name so both sides cannot drift, and it imports nothing from the rest of +the migration package. + +Request:: + + {"schema_version": 1, "request_id": "batch-001-010-attempt-1", + "batch_start": 0, "batch_end": 10, "case_ids": ["case-1"], + "dimensions": ["semantic_fidelity"], "prompt_version": 4, + "thread_id": "", "created_at": "2025-01-01T00:00:00Z", "prompt": "...", + "budget_seconds": 360, + "case_context": [{"case_id": "case-1", "state": "succeeded", + "criteria": True, "contract": False, + "runtime_observation": True}]} + +Response on success:: + + {"schema_version": 1, "request_id": "batch-001-010-attempt-1", "ok": True, + "thread_id": "thread-1", "cases": [...], "created_at": "..."} + +Response on failure, so the runner falls back to ``codex exec`` instead of waiting for +a turn that will never arrive:: + + {"schema_version": 1, "request_id": "batch-001-010-attempt-1", "ok": False, + "thread_id": "", "error": {"code": "judge_turn_unavailable", + "message": "..."}} + +``budget_seconds`` is how long the runner will wait for the response. It is the only +deadline in the protocol: Studio sizes its turn to answer inside that window, so a slow +turn reaches the runner as ``ok: false`` while the runner is still listening instead of +as a verdict nobody reads. + +Both sides key on the request id, so a response that arrives late, out of order, or +twice is either ignored or replayed for the same batch verdict - never applied to a +different batch. +""" + +from __future__ import annotations + +import os + +JUDGE_CHANNEL_SCHEMA_VERSION = 1 +JUDGE_CHANNEL_DIRECTORY = "judge" +JUDGE_REQUEST_NAME = "request.json" +JUDGE_RESPONSE_NAME = "response.json" + +JUDGE_TOOL_NAME = "reportEvaluation" +JUDGE_APP_SERVER_ENV = "AGENTKIT_MIGRATION_JUDGE_APP_SERVER" +_DISABLED_VALUES = {"0", "false", "no", "off"} + +EVALUATION_RESULTS_DIRECTORY = "results" +EVALUATION_ATTEMPT_DIRECTORY_PREFIX = "attempt-" + +__all__ = [ + "EVALUATION_ATTEMPT_DIRECTORY_PREFIX", + "EVALUATION_RESULTS_DIRECTORY", + "JUDGE_APP_SERVER_ENV", + "JUDGE_CHANNEL_DIRECTORY", + "JUDGE_CHANNEL_SCHEMA_VERSION", + "JUDGE_REQUEST_NAME", + "JUDGE_RESPONSE_NAME", + "JUDGE_TOOL_NAME", + "evaluation_result_root", + "judge_app_server_enabled", + "judge_channel_paths", +] + + +def judge_app_server_enabled() -> bool: + """Whether batch judging runs through the app-server instead of ``codex exec``. + + The runner still owns batching, the thread record, the batch cache, and the report; + Studio only answers the judge request. Set ``AGENTKIT_MIGRATION_JUDGE_APP_SERVER=0`` + to pin the scripted judge, which the runner also falls back to whenever this path + cannot deliver a batch. + """ + return os.getenv(JUDGE_APP_SERVER_ENV, "").strip().lower() not in _DISABLED_VALUES + + +def evaluation_result_root(evaluation_root: str, attempt: int) -> str: + """Return the directory that holds one evaluation attempt's durable results.""" + return ( + f"{evaluation_root.rstrip('/')}/{EVALUATION_RESULTS_DIRECTORY}/" + f"{EVALUATION_ATTEMPT_DIRECTORY_PREFIX}{attempt}" + ) + + +def judge_channel_paths(evaluation_root: str, attempt: int) -> tuple[str, str]: + """Return the ``(request_path, response_path)`` of one evaluation attempt.""" + base = ( + f"{evaluation_result_root(evaluation_root, attempt)}/{JUDGE_CHANNEL_DIRECTORY}" + ) + return ( + f"{base}/{JUDGE_REQUEST_NAME}", + f"{base}/{JUDGE_RESPONSE_NAME}", + ) diff --git a/frontend/server/migration/evaluation/judge_driver.py b/frontend/server/migration/evaluation/judge_driver.py new file mode 100644 index 000000000..b082c4acc --- /dev/null +++ b/frontend/server/migration/evaluation/judge_driver.py @@ -0,0 +1,432 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Studio side of the judge channel: one app-server turn per Sandbox request. + +The runner keeps owning batching, the thread record, the batch cache, and the report; +this driver only answers the request the runner writes. It is driven by the evaluation +watcher, so every property that matters comes from being replayable: + +* the request stays on disk until it has an answer, so a Studio restart re-runs the + same turn instead of losing the batch; +* one turn per request id runs at a time, and the answer is written once, so a slow + turn is never started twice; +* a turn that cannot deliver is answered with an error envelope, which sends the + runner straight back to ``codex exec`` instead of leaving it waiting. + +The request declares how long the runner will wait (``budget_seconds``); the turn is +sized to answer inside that window, so the runner always reads an answer - a verdict or +a reason - instead of timing out on work that was still running. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import json +import threading +import time + +from veadk.utils.logger import get_logger + +from ..gateway import ( + MigrationGateway, + MigrationGatewayError, + MigrationRemoteFileNotFound, + MigrationSandboxSession, +) +from ..codex_tool_turn import ( + ToolTurnDeadlineExceeded, + ToolTurnUnavailable, +) +from .judge_app_server import run_judge_turn +from .judge_channel import ( + JUDGE_CHANNEL_SCHEMA_VERSION, + judge_app_server_enabled, + judge_channel_paths, +) + +logger = get_logger(__name__) + +# 单条请求可能内嵌整批用例与 Runtime 观测,读取上限与评测报告保持一致。 +JUDGE_REQUEST_MAX_BYTES = 16 * 1024 * 1024 +# 单次回合的上限(不依赖 runner 给多少预算时的兜底值)。 +# 实测一次成功判定约 130s,慢回合会超过 240s,因此这里留到 300s。 +JUDGE_TURN_TIMEOUT_SECONDS = 300.0 +# 续跑既有线程的预算;剩余预算留给「线程已失效,改用新线程」的第二次尝试。 +JUDGE_RESUME_TIMEOUT_SECONDS = 90.0 +# 从 runner 给的窗口里预留给「取件 + 落盘 + runner 轮询」的时间。 +# runner 必须早于它自己的等待上限拿到信封,否则会先超时降级。 +JUDGE_TURN_RESERVE_SECONDS = 30.0 +_CASE_CONTEXT_FLAGS = ("criteria", "contract", "runtime_observation") + +__all__ = [ + "JUDGE_REQUEST_MAX_BYTES", + "JUDGE_RESUME_TIMEOUT_SECONDS", + "JUDGE_TURN_RESERVE_SECONDS", + "JUDGE_TURN_TIMEOUT_SECONDS", + "JudgeRequest", + "JudgeRequestError", + "SandboxJudgeDriver", + "answer_judge_request", + "judge_turn_budget", + "parse_judge_request", +] + + +class JudgeRequestError(RuntimeError): + """One Sandbox judge request is not a valid channel message.""" + + +@dataclass(frozen=True) +class JudgeRequest: + """One batch the Sandbox is asking Studio to judge.""" + + request_id: str + prompt: str + case_context: tuple[dict[str, object], ...] + dimensions: tuple[str, ...] + thread_id: str + # runner 授予的应答窗口(秒);0 表示请求方没有声明,用本地上限兜底。 + budget_seconds: float = 0.0 + + @property + def case_ids(self) -> tuple[str, ...]: + return tuple(str(entry["case_id"]) for entry in self.case_context) + + +def parse_judge_request(value: object) -> JudgeRequest: + """Parse and bound one ``request.json`` payload.""" + if ( + not isinstance(value, dict) + or value.get("schema_version") != JUDGE_CHANNEL_SCHEMA_VERSION + ): + raise JudgeRequestError("评测裁判请求的协议版本不匹配") + request_id = value.get("request_id") + if not isinstance(request_id, str) or not request_id or len(request_id) > 128: + raise JudgeRequestError("评测裁判请求缺少有效的 request_id") + prompt = value.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise JudgeRequestError("评测裁判请求缺少提示词") + dimensions = value.get("dimensions") + if ( + not isinstance(dimensions, list) + or not dimensions + or len(set(dimensions)) != len(dimensions) + or any(not isinstance(item, str) or not item for item in dimensions) + ): + raise JudgeRequestError("评测裁判请求的维度无效") + entries = value.get("case_context") + if not isinstance(entries, list) or not entries or len(entries) > 64: + raise JudgeRequestError("评测裁判请求的用例上下文无效") + case_context: list[dict[str, object]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise JudgeRequestError(f"评测裁判请求的用例上下文 {index} 无效") + case_id = entry.get("case_id") + if not isinstance(case_id, str) or not case_id: + raise JudgeRequestError(f"评测裁判请求的用例上下文 {index} 缺少 case_id") + state = entry.get("state") + context: dict[str, object] = { + "case_id": case_id, + "state": state if isinstance(state, str) else "", + } + for flag in _CASE_CONTEXT_FLAGS: + context[flag] = entry.get(flag) is True + case_context.append(context) + thread_id = value.get("thread_id") + if thread_id is None: + thread_id = "" + if not isinstance(thread_id, str) or len(thread_id) > 256: + raise JudgeRequestError("评测裁判请求的线程标识无效") + budget = value.get("budget_seconds") + if budget is None: + budget_seconds = 0.0 + elif ( + isinstance(budget, bool) + or not isinstance(budget, (int, float)) + or budget <= 0 + or budget > 24 * 3600 + ): + raise JudgeRequestError("评测裁判请求的时间预算无效") + else: + budget_seconds = float(budget) + return JudgeRequest( + request_id=request_id, + prompt=prompt, + case_context=tuple(case_context), + dimensions=tuple(dimensions), + thread_id=thread_id, + budget_seconds=budget_seconds, + ) + + +def judge_turn_budget(request: JudgeRequest) -> float: + """How long this turn may run before it must answer the runner. + + The runner owns the deadline: it declares how long it will wait, and the turn has + to finish early enough for its answer to reach the runner before that window + closes. Without a declared window the local ceiling applies. + """ + if request.budget_seconds <= 0: + return JUDGE_TURN_TIMEOUT_SECONDS + return min( + JUDGE_TURN_TIMEOUT_SECONDS, + request.budget_seconds - JUDGE_TURN_RESERVE_SECONDS, + ) + + +def _judge_response( + request: JudgeRequest, + *, + cases: dict[str, object] | None = None, + thread_id: str = "", + error: dict[str, str] | None = None, +) -> dict[str, object]: + value: dict[str, object] = { + "schema_version": JUDGE_CHANNEL_SCHEMA_VERSION, + "request_id": request.request_id, + "ok": error is None, + "thread_id": thread_id, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + if error is None: + value["cases"] = cases + else: + value["error"] = error + return value + + +def answer_judge_request( + request: JudgeRequest, + *, + endpoint: str, + cwd: str, + model: str = "", + timeout_seconds: float | None = None, +) -> dict[str, object]: + """Run the app-server turn for one batch and return the response envelope. + + A resumed thread that can no longer be attached is retried once on a fresh thread: + losing the judge thread costs continuity, while failing the batch costs the report. + Every step of that plan shares one deadline, so the envelope is written while the + runner is still waiting for it. + """ + if timeout_seconds is None: + timeout_seconds = judge_turn_budget(request) + started = time.monotonic() + deadline = started + timeout_seconds + plan: list[tuple[str, float]] = [] + if request.thread_id: + plan.append((request.thread_id, JUDGE_RESUME_TIMEOUT_SECONDS)) + plan.append(("", 0.0)) + failure: Exception | None = None + for thread_id, resume_budget in plan: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + if resume_budget > 0: + remaining = min(resume_budget, remaining) + try: + cases, used_thread = asyncio.run( + run_judge_turn( + endpoint=endpoint, + prompt=request.prompt, + cwd=cwd, + case_context=[dict(entry) for entry in request.case_context], + dimensions=list(request.dimensions), + thread_id=thread_id, + model=model, + timeout_seconds=remaining, + ) + ) + except ToolTurnUnavailable as error: + failure = error + continue + except Exception as error: # noqa: BLE001 - 任何回合故障都必须变成信封 + # 不认识的问题也必须有答案,否则 runner 会一直等到自己的窗口结束。 + logger.exception( + "Studio judge turn raised request_id=%s error_type=%s", + request.request_id, + type(error).__name__, + ) + failure = error + continue + logger.info( + "Studio judge turn delivered request_id=%s elapsed=%.1fs thread_id=%s", + request.request_id, + time.monotonic() - started, + used_thread, + ) + return _judge_response(request, cases=cases, thread_id=used_thread) + ran_out_of_time = failure is None or isinstance(failure, ToolTurnDeadlineExceeded) + detail = str(failure) if failure is not None else "评测裁判回合超出时间预算" + logger.warning( + "Studio judge turn unavailable request_id=%s error_type=%s budget=%.0fs " + "elapsed=%.1fs", + request.request_id, + type(failure).__name__ if failure is not None else "timeout", + timeout_seconds, + time.monotonic() - started, + ) + return _judge_response( + request, + error={ + "code": ( + "judge_turn_timeout" if ran_out_of_time else "judge_turn_unavailable" + ), + "message": detail[:512], + }, + ) + + +class SandboxJudgeDriver: + """Answer pending judge requests for the evaluation attempts of one Studio run.""" + + def __init__( + self, + gateway: MigrationGateway, + *, + cwd: str, + model: str = "", + enabled: Callable[[], bool] = judge_app_server_enabled, + ) -> None: + self._gateway = gateway + self._cwd = cwd + self._model = model + self._enabled = enabled + self._turns: dict[tuple[str, int], threading.Thread] = {} + self._answered: dict[tuple[str, int], set[str]] = {} + + def drive( + self, + session: MigrationSandboxSession, + *, + evaluation_root: str, + attempt: int, + ) -> None: + """Answer the pending request of this attempt, if there is an unanswered one. + + Cheap to call on every watcher tick: it reads one small file and returns unless + the runner is waiting for a batch Studio has not answered yet. + """ + if attempt < 1 or not self._enabled(): + return + key = (session.session_id, attempt) + running = self._turns.get(key) + if running is not None and running.is_alive(): + return + request_path, _ = judge_channel_paths(evaluation_root, attempt) + try: + content = self._gateway.get_file( + session, + request_path, + max_bytes=JUDGE_REQUEST_MAX_BYTES, + ) + except MigrationRemoteFileNotFound: + return + except MigrationGatewayError as error: + logger.warning( + "Studio judge channel read failed task_id=%s attempt=%s code=%s", + session.task_id, + attempt, + error.code, + ) + return + try: + request = parse_judge_request(json.loads(content)) + except (UnicodeDecodeError, ValueError, JudgeRequestError) as error: + logger.warning( + "Studio judge channel request rejected task_id=%s attempt=%s " + "error_type=%s", + session.task_id, + attempt, + type(error).__name__, + ) + return + if request.request_id in self._answered.get(key, frozenset()): + return + logger.info( + "Studio judge channel turn started task_id=%s attempt=%s request_id=%s " + "budget=%.0fs thread=%s", + session.task_id, + attempt, + request.request_id, + judge_turn_budget(request), + "resumed" if request.thread_id else "new", + ) + worker = threading.Thread( + target=self._answer, + args=(session, attempt, evaluation_root, key, request), + name=f"migration-judge-{attempt}", + daemon=True, + ) + self._turns[key] = worker + try: + worker.start() + except Exception: # noqa: BLE001 - a failed start must not break the tick + self._turns.pop(key, None) + logger.exception( + "Studio judge worker could not start task_id=%s attempt=%s", + session.task_id, + attempt, + ) + + def _answer( + self, + session: MigrationSandboxSession, + attempt: int, + evaluation_root: str, + key: tuple[str, int], + request: JudgeRequest, + ) -> None: + try: + response = answer_judge_request( + request, + endpoint=session.endpoint, + cwd=self._cwd, + model=self._model, + ) + _, response_path = judge_channel_paths(evaluation_root, attempt) + self._gateway.put_file( + session, + response_path, + json.dumps( + response, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8"), + media_type="application/json", + ) + self._answered.setdefault(key, set()).add(request.request_id) + logger.info( + "Studio judge channel answered task_id=%s attempt=%s request_id=%s " + "ok=%s", + session.task_id, + attempt, + request.request_id, + str(response.get("ok")).lower(), + ) + except Exception: # noqa: BLE001 - the watcher tick must survive this + logger.exception( + "Studio judge channel answer failed task_id=%s attempt=%s " + "request_id=%s", + session.task_id, + attempt, + request.request_id, + ) + finally: + if self._turns.get(key) is threading.current_thread(): + self._turns.pop(key, None) diff --git a/frontend/server/migration/evaluation/runner.py b/frontend/server/migration/evaluation/runner.py index 561fea4e2..18d67e418 100644 --- a/frontend/server/migration/evaluation/runner.py +++ b/frontend/server/migration/evaluation/runner.py @@ -34,6 +34,12 @@ MigrationSandboxSession, ) from ..service import MIGRATION_ROOT, MigrationError +from .judge_channel import ( + JUDGE_TOOL_NAME, + evaluation_result_root, + judge_app_server_enabled, + judge_channel_paths, +) from .service import ( EVALUATION_DATASET_PATH, EVALUATION_REPORT_PATH, @@ -51,6 +57,8 @@ _RUNNER_PATH = f"{EVALUATION_ROOT}/assets/evaluation_runner.py" _JUDGE_SCHEMA_PATH = f"{EVALUATION_ROOT}/assets/judge-schema.json" +# 评委回合也在迁移产物目录里执行,与旧版 codex exec 裁判的 --cd 保持一致。 +EVALUATION_PROJECT_PATH = f"{MIGRATION_ROOT}/output/veadk" _PROJECT_CONFIG_PATHS = ( f"{MIGRATION_ROOT}/output/veadk/agentkit.yaml", f"{MIGRATION_ROOT}/output/veadk/.agentkit/agentkit.yaml", @@ -250,8 +258,18 @@ def runner_source() -> str: RUNTIME_OBSERVATION_LIMIT = 16 * 1024 RAW_LIMIT = 16 * 1024 * 1024 INVOKE_TIMEOUT = 120 - JUDGE_TIMEOUT = 300 - JUDGE_PROMPT_VERSION = 3 + # 单批总预算:先请 Studio 判定,失败时剩下的时间留给 codex exec 兜底。 + # 必须盖住「通道等待上限 + 一次 codex exec(实测约 140s)」。 + JUDGE_TIMEOUT = 540 + # 等待 Studio 应答的上限。必须大于 Studio 回合上限加上它的落盘预留 + # (judge_driver.JUDGE_TURN_TIMEOUT_SECONDS + JUDGE_TURN_RESERVE_SECONDS), + # 否则 runner 会先超时,Studio 递回来的信封就白写了。 + JUDGE_CHANNEL_TIMEOUT = 360 + JUDGE_CHANNEL_POLL_SECONDS = 3 + JUDGE_CHANNEL_RESPONSE_LIMIT = 16 * 1024 * 1024 + JUDGE_CHANNEL_SCHEMA_VERSION = 1 + JUDGE_CHANNEL_DISABLED_RUNS = set() + JUDGE_PROMPT_VERSION = 4 EXECUTION_RESULT_LIMIT = 12 * 1024 * 1024 EVIDENCE_SOURCES = { "user_reference", @@ -1039,6 +1057,187 @@ def codex_events(events): return thread_id, message + def judge_channel(config): + channel = config.get("judge_channel") + if not isinstance(channel, dict): + return None + if config.get("batch_root_path") in JUDGE_CHANNEL_DISABLED_RUNS: + return None + request_path = channel.get("request_path") + response_path = channel.get("response_path") + tool_name = channel.get("tool_name") + if not isinstance(request_path, str) or not request_path: + return None + if not isinstance(response_path, str) or not response_path: + return None + if not isinstance(tool_name, str) or not tool_name: + return None + return request_path, response_path, tool_name + + + def disable_judge_channel(config): + # 通道交付失败后,本轮剩余批次直接使用 codex exec,不再逐批白等预算。 + JUDGE_CHANNEL_DISABLED_RUNS.add(config.get("batch_root_path")) + + + def judge_request_id(batch_start, cases, judge_attempt): + return "batch-{:03d}-{:03d}-attempt-{}".format( + batch_start + 1, + batch_start + len(cases), + judge_attempt, + ) + + + def judge_case_context(cases, observations, contract): + # 只描述每个用例允许裁判依据什么,不重复携带用例负载。 + context = [] + for case in cases: + observation = observations.get(case["case_id"]) + runtime_observation = ( + observation.get("runtime_observation") + if isinstance(observation, dict) + else None + ) + context.append( + { + "case_id": case["case_id"], + "state": ( + str(observation.get("state") or "") + if isinstance(observation, dict) + else "" + ), + "criteria": bool(case.get("criteria")), + "contract": contract is not None, + "runtime_observation": bool( + isinstance(runtime_observation, dict) + and str(runtime_observation.get("text") or "").strip() + ), + } + ) + return context + + + def read_judge_response(response_path, request_id): + # 返回绑定到 request_id 的响应,尚未写好时返回 None。 + # 撕裂或残留的响应与「还没写好」无法区分,两者都只是继续等待; + # 只有带同一 request_id 的完整响应才会被采用。 + target = Path(response_path) + try: + if not target.is_file(): + return None + raw = target.read_bytes() + except OSError: + return None + if len(raw) > JUDGE_CHANNEL_RESPONSE_LIMIT: + return None + try: + value = json.loads(raw) + except ValueError: + return None + if not isinstance(value, dict) or value.get("request_id") != request_id: + return None + return value + + + def await_judge_response(response_path, request_id, deadline): + while True: + response = read_judge_response(response_path, request_id) + if response is not None: + return response + if time.monotonic() >= deadline: + return None + time.sleep(JUDGE_CHANNEL_POLL_SECONDS) + + + def judge_through_channel( + config, + channel, + prompt, + batch_start, + cases, + judge_attempt, + case_context, + thread_id, + observations, + contract, + remaining, + ): + # 请 Studio 裁判这一批;返回 None 表示本批要回退到 codex exec。 + # 请求落盘即可重放:Studio 重启后会重新执行同一个回合,而不是丢掉这一批。 + request_path, response_path, _tool_name = channel + request_id = judge_request_id(batch_start, cases, judge_attempt) + try: + atomic_json( + request_path, + { + "schema_version": JUDGE_CHANNEL_SCHEMA_VERSION, + "request_id": request_id, + "batch_start": batch_start, + "batch_end": batch_start + len(cases), + "case_ids": [case["case_id"] for case in cases], + "dimensions": config["dimensions"], + "prompt_version": JUDGE_PROMPT_VERSION, + # 把 runner 的等待窗口交给 Studio:回合必须早于它给出信封。 + "budget_seconds": round( + min(remaining, JUDGE_CHANNEL_TIMEOUT), 3 + ), + "thread_id": thread_id or "", + "prompt": prompt, + "case_context": case_context, + "created_at": now(), + }, + ) + except OSError as error: + diagnostic( + config, + "judge_channel_write_failed", + error_type=type(error).__name__, + ) + return None + deadline = time.monotonic() + min(remaining, JUDGE_CHANNEL_TIMEOUT) + response = await_judge_response(response_path, request_id, deadline) + if response is None: + diagnostic(config, "judge_channel_timeout", detail=request_id) + return None + if response.get("ok") is not True: + error = response.get("error") + diagnostic( + config, + "judge_channel_unavailable", + detail=( + str(error.get("code") or "unknown") + if isinstance(error, dict) + else "unknown" + ), + ) + return None + response_thread_id = response.get("thread_id") + if ( + not isinstance(response_thread_id, str) + or not response_thread_id + or len(response_thread_id) > 256 + ): + diagnostic(config, "judge_channel_thread_invalid") + return None + try: + returned = validate_judged_cases( + config, + cases, + response.get("cases"), + observations, + contract, + ) + except (ValueError, RuntimeError) as error: + diagnostic( + config, + "judge_channel_output_rejected", + error_type=type(error).__name__, + detail=str(error), + ) + return None + return response_thread_id, returned + + def judge_binding(config): return { "schema_version": 1, @@ -1348,6 +1547,24 @@ def judge_batch(config, batch_start, cases, observations, contract, env): thread_id = load_judge_thread(config) if thread_id is None and batch_start > 0: raise RuntimeError("judge thread record is missing") + channel = judge_channel(config) + case_context = judge_case_context(cases, observations, contract) + if channel is not None: + tool_name = channel[2] + prompt = "\n".join( + [ + localized( + config, + "必须通过调用 " + + tool_name + + " 工具一次性提交本批判定结果;不要用任何其他方式输出判定结果。", + "Submit the batch verdict by calling the " + + tool_name + + " tool exactly once; do not report the verdict in any other way.", + ), + prompt, + ] + ) last_error = None deadline = time.monotonic() + JUDGE_TIMEOUT batch_number = batch_start // 10 + 1 @@ -1367,6 +1584,34 @@ def judge_batch(config, batch_start, cases, observations, contract, env): diagnostic(config, "judge_time_budget_exhausted") last_error = RuntimeError("evaluation judge time budget exhausted") break + if channel is not None: + answer = judge_through_channel( + config, + channel, + prompt, + batch_start, + cases, + judge_attempt, + case_context, + thread_id, + observations, + contract, + remaining, + ) + if answer is not None: + response_thread_id, returned = answer + if response_thread_id != thread_id: + # Studio 可能因为线程失效而换了新线程,跟随它继续对话。 + thread_id = response_thread_id + save_judge_thread(config, thread_id) + save_batch_result(config, batch_start, cases, returned) + return returned + # 通道这次没有交付:本批和本轮剩下的次数都走 codex exec 兜底。 + disable_judge_channel(config) + channel = None + # 通道已经消耗掉一段预算,兜底必须按剩余时间重算: + # 沿用进入本批时的 remaining 会让一次慢通道把本批拖过 JUDGE_TIMEOUT。 + remaining = deadline - time.monotonic() command = [ "codex", "exec", @@ -1972,7 +2217,11 @@ def start( ) -> None: config_path = f"{EVALUATION_ROOT}/control/runner-{attempt}.json" work_path = f"{EVALUATION_ROOT}/attempts/{attempt}" - result_path = f"{EVALUATION_ROOT}/results/attempt-{attempt}" + result_path = evaluation_result_root(EVALUATION_ROOT, attempt) + judge_request_path, judge_response_path = judge_channel_paths( + EVALUATION_ROOT, + attempt, + ) cloud_credential_path = self._cloud_credential_path(attempt) agentkit_config_protocol, agentkit_config = self._agentkit_config(session) access_key, secret_key, session_token, cloud_credentials = ( @@ -2034,10 +2283,20 @@ def start( "status_path": EVALUATION_STATUS_PATH, "report_path": EVALUATION_REPORT_PATH, "judge_schema_path": _JUDGE_SCHEMA_PATH, - "project_path": f"{MIGRATION_ROOT}/output/veadk", + "project_path": EVALUATION_PROJECT_PATH, "work_path": work_path, "thread_path": f"{result_path}/thread.json", "batch_root_path": f"{result_path}/batches", + # 评测裁判的通道由 Studio 决定:关闭时脚本直接使用旧版 codex exec 裁判。 + "judge_channel": ( + { + "request_path": judge_request_path, + "response_path": judge_response_path, + "tool_name": JUDGE_TOOL_NAME, + } + if judge_app_server_enabled() + else None + ), "execution_results_path": f"{result_path}/execution-results.jsonl", "diagnostic_path": f"{EVALUATION_ROOT}/diagnostics/evaluation.log", "secret_path": secret_path, @@ -2397,6 +2656,7 @@ def _expiry_epoch(session: MigrationSandboxSession) -> float: __all__ = [ + "EVALUATION_PROJECT_PATH", "SandboxMigrationEvaluationRunner", "judge_schema", "runner_source", diff --git a/frontend/server/migration/evaluation/service.py b/frontend/server/migration/evaluation/service.py index 02fad571f..03263cd30 100644 --- a/frontend/server/migration/evaluation/service.py +++ b/frontend/server/migration/evaluation/service.py @@ -196,6 +196,18 @@ def stop( ) -> None: ... +class EvaluationJudgeDriver(Protocol): + """Answers the judge requests the Sandbox runner writes for one attempt.""" + + def drive( + self, + session: MigrationSandboxSession, + *, + evaluation_root: str, + attempt: int, + ) -> None: ... + + class MigrationEvaluationService: def __init__( self, @@ -204,12 +216,14 @@ def __init__( *, repository: EvaluationAssetRepository | None, runner: EvaluationRunner | None, + judge_driver: EvaluationJudgeDriver | None = None, clock: Callable[[], float] = time.time, ) -> None: self._migration = migration self._gateway = gateway self._repository = repository self._runner = runner + self._judge_driver = judge_driver self._clock = clock @property @@ -545,6 +559,7 @@ def advance( ) return if state in _ACTIVE_EVALUATION_STATES: + self._drive_judge_channel(session, task_id, status) self._reconcile_active_runner(session, task_id, status) return if state in { @@ -1032,6 +1047,36 @@ def _persist_report( report_asset=metadata.public(), ) + def _drive_judge_channel( + self, + session: MigrationSandboxSession, + task_id: str, + status: _EvaluationStatus | None, + ) -> None: + """Let Studio answer a pending Sandbox judge request. + + A cheap read that never raises, so it runs before the exit-code check: a request + on disk means the runner is waiting for a verdict, and the turn itself runs on a + driver-owned worker instead of blocking the watcher tick. + """ + if status is None or self._judge_driver is None: + return + attempt = int(status.get("attempt") or 0) + if attempt < 1: + return + try: + self._judge_driver.drive( + session, + evaluation_root=EVALUATION_ROOT, + attempt=attempt, + ) + except Exception: # noqa: BLE001 - a channel hiccup must not stop the watcher + logger.exception( + "Migration evaluation judge channel drive failed task_id=%s attempt=%s", + task_id, + attempt, + ) + def _reconcile_active_runner( self, session: MigrationSandboxSession, @@ -1611,6 +1656,7 @@ def _json_bytes(value: object) -> bytes: "EVALUATION_DATASET_PATH", "EVALUATION_REPORT_PATH", "EVALUATION_ROOT", + "EvaluationJudgeDriver", "EVALUATION_RUNNER_DIAGNOSTICS_ROOT", "EVALUATION_SECRET_PATH", "EVALUATION_STATUS_PATH", diff --git a/frontend/server/migration/events.py b/frontend/server/migration/events.py new file mode 100644 index 000000000..696c08a8f --- /dev/null +++ b/frontend/server/migration/events.py @@ -0,0 +1,388 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Append-only event log that turns the migration page from polling into a stream. + +The Sandbox stays the source of truth. One poller per task runs the very same service +calls the page used to issue, and appends an event only when a payload actually changed; +subscribers replay from their cursor and then follow the log. A refresh, a second tab, +or a reconnecting client therefore resumes from a sequence number instead of driving its +own Sandbox reads, and progress no longer depends on a page being open. + +Payloads are whole snapshots rather than deltas: each event replaces the previous value +of its kind, so replaying a bounded window is always safe and a client that fell behind +the retained history simply re-syncs instead of corrupting its view. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import json +import time +from collections import deque +from collections.abc import AsyncIterator, Awaitable, Callable, Hashable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import datetime, timezone + +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + +TASK_EVENT = "task" +ACTIVITY_EVENT = "activity" +ERROR_EVENT = "error" +DONE_EVENT = "done" + +DEFAULT_HISTORY_LIMIT = 256 +DEFAULT_POLL_SECONDS = 1.5 +DEFAULT_HEARTBEAT_SECONDS = 15.0 +DEFAULT_IDLE_SECONDS = 120.0 +DEFAULT_FAILURE_BACKOFF_SECONDS = 5.0 +DEFAULT_MAX_STREAMS = 64 + + +def _timestamp() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _digest(value: object) -> str: + serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class MigrationEvent: + """One immutable log entry; ``seq`` is the cursor the client resumes from.""" + + seq: int + type: str + payload: dict[str, object] + + +@dataclass(frozen=True, slots=True) +class MigrationSnapshot: + """One read of a task, and whether it can still change without user action.""" + + task: dict[str, object] | None = None + activity: dict[str, object] | None = None + error: dict[str, object] | None = None + settled: bool = False + + +class MigrationEventLog: + """Monotonic, bounded, append-only log with a per-waiter wake-up.""" + + def __init__(self, *, history_limit: int = DEFAULT_HISTORY_LIMIT) -> None: + if history_limit < 1: + raise ValueError("history_limit must be positive") + self._events: deque[MigrationEvent] = deque(maxlen=history_limit) + self._seq = 0 + self._waiters: set[asyncio.Event] = set() + + @property + def last_seq(self) -> int: + return self._seq + + @property + def earliest_seq(self) -> int: + """First sequence still retained; one past the end while the log is empty.""" + return self._events[0].seq if self._events else self._seq + 1 + + @property + def size(self) -> int: + return len(self._events) + + def append(self, event_type: str, payload: dict[str, object]) -> MigrationEvent: + self._seq += 1 + event = MigrationEvent(self._seq, event_type, payload) + self._events.append(event) + waiters, self._waiters = self._waiters, set() + for waiter in waiters: + waiter.set() + return event + + def replay(self, after: int) -> list[MigrationEvent]: + """Retained events after ``after``; a stale cursor simply re-syncs.""" + return [event for event in self._events if event.seq > after] + + def wake(self) -> None: + """Release every waiter so it can re-read state that is not in the log.""" + waiters, self._waiters = self._waiters, set() + for waiter in waiters: + waiter.set() + + async def wait(self, after: int, timeout: float) -> bool: + """Wait for an event newer than ``after``; ``False`` when the wait timed out.""" + if self._seq > after: + return True + waiter = asyncio.Event() + self._waiters.add(waiter) + try: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(waiter.wait(), timeout) + finally: + self._waiters.discard(waiter) + return self._seq > after + + +class MigrationStream: + """One poller and one log for a single task. + + The poller is the only writer, so sequence numbers, change detection, and the + ``done`` marker cannot race with a request handler. + """ + + def __init__( + self, + read: Callable[[], Awaitable[MigrationSnapshot]], + *, + poll_seconds: float = DEFAULT_POLL_SECONDS, + heartbeat_seconds: float = DEFAULT_HEARTBEAT_SECONDS, + idle_seconds: float = DEFAULT_IDLE_SECONDS, + failure_backoff_seconds: float = DEFAULT_FAILURE_BACKOFF_SECONDS, + history_limit: int = DEFAULT_HISTORY_LIMIT, + ) -> None: + self.log = MigrationEventLog(history_limit=history_limit) + self._read = read + self._poll_seconds = poll_seconds + self._heartbeat_seconds = heartbeat_seconds + self._idle_seconds = idle_seconds + self._failure_backoff_seconds = failure_backoff_seconds + self._digests: dict[str, str] = {} + self._subscribers = 0 + self._retire_at: float | None = None + self._settled = False + self._retired = False + self._runner: asyncio.Task[None] | None = None + self._failures = 0 + + @property + def settled(self) -> bool: + return self._settled + + @property + def subscribers(self) -> int: + return self._subscribers + + @property + def idle(self) -> bool: + """No subscriber is attached and the resume window has elapsed.""" + return ( + self._subscribers == 0 + and self._retire_at is not None + and time.monotonic() - self._retire_at >= self._idle_seconds + ) + + def attach(self) -> None: + self._subscribers += 1 + self._retire_at = None + if self._settled: + # Nothing left to poll: the follower replays the log and returns on ``done``. + return + if self._runner is None or self._runner.done(): + self._runner = asyncio.create_task(self._run()) + + def detach(self) -> None: + self._subscribers = max(0, self._subscribers - 1) + if self._subscribers == 0: + self._retire_at = time.monotonic() + + def retire(self) -> None: + """Stop polling and make every follower return. + + Retirement is not settlement: the task may still be running, the hub just has no + reason to keep a stream for it. A later subscriber builds a fresh stream. + """ + self._retired = True + runner = self._runner + if runner is not None and not runner.done(): + runner.cancel() + self.log.wake() + + async def follow(self, after: int) -> AsyncIterator[MigrationEvent | None]: + """Replay from ``after`` and then follow; ``None`` marks a heartbeat.""" + cursor = after + while True: + for event in self.log.replay(cursor): + cursor = event.seq + yield event + if event.type == DONE_EVENT: + return + if self._settled or self._retired: + return + if not await self.log.wait(cursor, self._heartbeat_seconds): + yield None + + async def _run(self) -> None: + try: + while True: + try: + snapshot = await self._read() + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - the page must survive a read fault + self._failures += 1 + if self._failures == 1 or self._failures % 10 == 0: + logger.warning( + "Studio migration event read failed attempts=%d error_type=%s", + self._failures, + type(error).__name__, + ) + self._publish( + MigrationSnapshot( + error={ + "code": "MIGRATION_EVENT_READ_FAILED", + "message": "迁移状态暂时无法读取,正在重试。", + "retryable": True, + } + ) + ) + if self.idle: + return + await asyncio.sleep(self._failure_backoff_seconds) + continue + self._failures = 0 + self._publish(snapshot) + if self._settled or self._retired: + return + if self.idle: + return + await asyncio.sleep(self._poll_seconds) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - a dead poller must not kill the loop silently + logger.exception("Studio migration event poller failed") + + def _publish(self, snapshot: MigrationSnapshot) -> None: + if snapshot.error is None: + # Clearing is a change too: a follower must drop a stale read-failure + # banner even when nothing else about the task moved. An ``error`` event + # without a code or a message is that "readable again" frame. + if self._digests.pop(ERROR_EVENT, None) is not None: + self.log.append(ERROR_EVENT, {}) + else: + self._append_changed(ERROR_EVENT, dict(snapshot.error)) + if snapshot.task is not None: + self._append_changed(TASK_EVENT, snapshot.task) + if snapshot.activity is not None: + self._append_changed(ACTIVITY_EVENT, snapshot.activity) + if snapshot.settled: + self._settle(snapshot.task) + + def _settle(self, task: dict[str, object] | None) -> None: + if self._settled: + return + self._settled = True + state = str(task.get("state") or "") if isinstance(task, dict) else "" + self.log.append(DONE_EVENT, {"state": state, "at": _timestamp()}) + + def _append_changed(self, event_type: str, payload: dict[str, object]) -> None: + digest = _digest(payload) + if self._digests.get(event_type) == digest: + return + self._digests[event_type] = digest + self.log.append(event_type, payload) + + +class MigrationEventHub: + """One stream per task, created on demand and dropped once nobody listens.""" + + def __init__( + self, + read: Callable[[Hashable], Callable[[], Awaitable[MigrationSnapshot]]], + *, + poll_seconds: float = DEFAULT_POLL_SECONDS, + heartbeat_seconds: float = DEFAULT_HEARTBEAT_SECONDS, + idle_seconds: float = DEFAULT_IDLE_SECONDS, + history_limit: int = DEFAULT_HISTORY_LIMIT, + max_streams: int = DEFAULT_MAX_STREAMS, + ) -> None: + self._read = read + self._poll_seconds = poll_seconds + self._heartbeat_seconds = heartbeat_seconds + self._idle_seconds = idle_seconds + self._history_limit = history_limit + self._max_streams = max(1, max_streams) + self._streams: dict[Hashable, MigrationStream] = {} + + def peek(self, key: Hashable) -> MigrationStream | None: + return self._streams.get(key) + + def stream(self, key: Hashable) -> MigrationStream: + """The live stream for ``key``, or a fresh one once it settled or went idle. + + A settled stream must never be handed out again: its log stops growing, so the + next subscriber would be told the task is over without anyone re-reading it. + """ + current = self._streams.get(key) + if current is not None and not current.idle and not current.settled: + return current + if current is not None: + current.retire() + self._trim(extra=1) + stream = MigrationStream( + self._read(key), + poll_seconds=self._poll_seconds, + heartbeat_seconds=self._heartbeat_seconds, + idle_seconds=self._idle_seconds, + history_limit=self._history_limit, + ) + self._streams[key] = stream + return stream + + @asynccontextmanager + async def subscription(self, key: Hashable) -> AsyncIterator[MigrationStream]: + """Attach for the lifetime of the block, then drop the stream once idle.""" + stream = self.stream(key) + stream.attach() + try: + yield stream + finally: + stream.detach() + self._reap() + + def close(self) -> None: + """Stop every poller; used on application shutdown.""" + for stream in self._streams.values(): + stream.retire() + self._streams.clear() + + def _reap(self) -> None: + for key, stream in list(self._streams.items()): + if stream.idle: + self._streams.pop(key, None) + stream.retire() + + def _trim(self, *, extra: int) -> None: + while len(self._streams) + extra > self._max_streams: + idle = [key for key, stream in self._streams.items() if stream.idle] + candidates = idle or list(self._streams) + key = candidates[0] + self._streams.pop(key, None).retire() + + +__all__ = [ + "ACTIVITY_EVENT", + "DONE_EVENT", + "ERROR_EVENT", + "TASK_EVENT", + "MigrationEvent", + "MigrationEventHub", + "MigrationEventLog", + "MigrationSnapshot", + "MigrationStream", +] diff --git a/frontend/server/migration/models.py b/frontend/server/migration/models.py index 3e4417cdd..fc0edc3ae 100644 --- a/frontend/server/migration/models.py +++ b/frontend/server/migration/models.py @@ -22,6 +22,7 @@ from pydantic import BaseModel, Field, model_validator +from .analysis_input import MAX_ANSWER_LENGTH, MAX_QUESTIONS from .evaluation.models import MigrationEvaluationConfig MigrationFramework = Literal[ @@ -169,6 +170,38 @@ def normalize(self) -> SubmitAnalysisAnswersBody: return self +class SubmitAnalysisInputBody(BaseModel): + """Answers for the questions a running analysis turn asked the user.""" + + request_id: str = Field(alias="requestId", min_length=1, max_length=64) + answers: dict[str, str] + + model_config = {"populate_by_name": True, "extra": "forbid"} + + @model_validator(mode="after") + def normalize(self) -> SubmitAnalysisInputBody: + self.request_id = self.request_id.strip() + if not self.request_id: + raise ValueError("分析问题标识无效") + if not self.answers: + raise ValueError("请先回答全部分析问题") + if len(self.answers) > MAX_QUESTIONS: + raise ValueError(f"分析问题不能超过 {MAX_QUESTIONS} 个") + normalized_answers: dict[str, str] = {} + for key, value in self.answers.items(): + normalized_key = key.strip() + normalized_value = value.strip() + if not normalized_key or len(normalized_key) > 64: + raise ValueError("分析问题 ID 无效") + if not normalized_value: + raise ValueError("分析问题的回答不能为空") + if len(normalized_value) > MAX_ANSWER_LENGTH: + raise ValueError(f"单个分析回答不能超过 {MAX_ANSWER_LENGTH} 个字符") + normalized_answers[normalized_key] = normalized_value + self.answers = normalized_answers + return self + + __all__ = [ "MIGRATION_FRAMEWORKS", "STRUCTURED_ENTRY_PATTERN", @@ -177,5 +210,6 @@ def normalize(self) -> SubmitAnalysisAnswersBody: "CreateMigrationTaskBody", "MigrationFramework", "SubmitAnalysisAnswersBody", + "SubmitAnalysisInputBody", "is_valid_structured_entry", ] diff --git a/frontend/server/migration/routes.py b/frontend/server/migration/routes.py index 4f3bb3f1e..f1ddefd91 100644 --- a/frontend/server/migration/routes.py +++ b/frontend/server/migration/routes.py @@ -17,20 +17,23 @@ from __future__ import annotations import asyncio +import json import logging -from collections.abc import Callable +from collections.abc import AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any from fastapi import HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import Response +from fastapi.responses import Response, StreamingResponse from .evaluation.models import EvaluationDatasetBody, ResumeEvaluationBody from .evaluation.service import MigrationEvaluationService +from .events import MigrationEventHub, MigrationSnapshot from .models import ( ConfirmMigrationBody, CreateMigrationTaskBody, SubmitAnalysisAnswersBody, + SubmitAnalysisInputBody, ) from .service import ( MIGRATION_UPLOAD_MAX_BYTES, @@ -42,11 +45,54 @@ if TYPE_CHECKING: from frontend.server.source_projects import SourceProjectService + +def migration_activity_visible(task: dict[str, object]) -> bool: + """Whether the page shows the Codex activity feed for this task.""" + if task.get("state") == "analyzing": + return True + if task.get("analysisRef") or task.get("confirmation"): + return True + error = task.get("error") + code = str(error.get("code") or "") if isinstance(error, dict) else "" + return code.startswith("MIGRATION_ANALYSIS_") + + +def migration_task_settled(task: dict[str, object]) -> bool: + """Whether the task stops changing until the user acts on it. + + The page used to decide this on its own and stop polling; owning the rule here lets + the stream close at the same moment instead of polling a parked task. + """ + if str(task.get("state") or "") in _ACTIVE_TASK_STATES: + return False + persistence = task.get("persistence") + if isinstance(persistence, dict) and persistence.get("state") == "saving": + return False + evaluation = task.get("evaluation") + if ( + isinstance(evaluation, dict) + and evaluation.get("enabled") is True + and str(evaluation.get("state") or "") in _POLLING_EVALUATION_STATES + ): + return False + return True + + _ZIP_CONTENT_TYPES = { "application/zip", "application/x-zip-compressed", "application/octet-stream", } +# 这两组状态决定事件流什么时候可以收尾;页面以前自己复制了一份,现在由服务端拥有。 +_ACTIVE_TASK_STATES = {"analyzing", "migrating", "validating", "packaging"} +_POLLING_EVALUATION_STATES = { + "pending", + "preparing", + "deploying", + "executing", + "judging", + "aggregating", +} def mount_migration_routes( @@ -62,6 +108,60 @@ def mount_migration_routes( persistence_tasks: dict[tuple[str, str], asyncio.Task[dict[str, object]]] = {} watchers: dict[tuple[str, str], asyncio.Task[None]] = {} + def migration_stream_reader( + key: tuple[str, str], + ) -> Callable[[], Awaitable[MigrationSnapshot]]: + """Read the same payloads the page used to poll for, once per stream tick.""" + owner_id, task_id = key + + async def read() -> MigrationSnapshot: + try: + task = await run_in_threadpool(service.get_task, task_id, owner_id) + # A background app-server analysis dies with the Studio process; the + # stream owns this recovery now that the page no longer polls. + await run_in_threadpool( + service.recover_stalled_analysis, + task_id, + owner_id, + ) + # 交付收尾回合同样活在 Studio 进程里:读任务的这条路径负责在交付落定后 + # 补一次收尾,Studio 重启丢掉的那一轮也能在这里接回来。 + await run_in_threadpool( + service.drive_delivery_turn, + task_id, + owner_id, + task=task, + ) + decorated = await with_evaluation(task, owner_id) + activity = None + if migration_activity_visible(decorated): + activity = await run_in_threadpool( + service.activity, + task_id, + owner_id, + ) + settled = migration_task_settled(decorated) + evaluation = decorated.get("evaluation") + if ( + not settled + and isinstance(evaluation, dict) + and evaluation.get("enabled") is True + ): + start_watcher(task_id, owner_id) + return MigrationSnapshot( + task=decorated, + activity=activity, + settled=settled, + ) + except MigrationError as error: + if error.retryable: + raise + return MigrationSnapshot(error=error.detail(), settled=True) + + return read + + event_hub = MigrationEventHub(migration_stream_reader) + async def invoke( operation: str, call: Callable[[], Any], @@ -316,6 +416,14 @@ async def watch() -> None: continue return state = task.get("state") + # 交付落定后由 Studio 收尾一次,所以看护循环也要给收尾回合机会, + # 不能只在页面打开的时候才收尾。 + await run_in_threadpool( + service.drive_delivery_turn, + task_id, + owner_id, + task=task, + ) if state in { "succeeded", "succeeded_with_warnings", @@ -503,6 +611,20 @@ async def get_task( lambda: service.get_task(task_id, owner_id), task_id=task_id, ) + # A background app-server analysis dies with the Studio process; while a + # client keeps polling, hand its attempt back to the in-Sandbox script. + await invoke( + "recover_stalled_analysis", + lambda: service.recover_stalled_analysis(task_id, owner_id), + task_id=task_id, + ) + # 交付落定以后由 Studio 收尾一次:成功时核对并发布产物,失败时把日志读成 + # 一句能解释的结论。回合跑在后台线程里,所以这次调用只做一次廉价判断。 + await invoke( + "drive_delivery_turn", + lambda: service.drive_delivery_turn(task_id, owner_id, task=task), + task_id=task_id, + ) decorated = await with_evaluation(task, owner_id) evaluation = decorated.get("evaluation") if isinstance(evaluation, dict) and evaluation.get("enabled") is True: @@ -523,6 +645,20 @@ async def submit_answers( ) return await with_evaluation(task, owner_id) + @app.post("/web/agent-migrations/tasks/{task_id}/input") + async def submit_analysis_input( + task_id: str, + body: SubmitAnalysisInputBody, + request: Request, + ) -> dict[str, object]: + owner_id = owner_resolver(request) + task = await invoke( + "submit_analysis_input", + lambda: service.submit_analysis_input(task_id, owner_id, body), + task_id=task_id, + ) + return await with_evaluation(task, owner_id) + @app.post("/web/agent-migrations/tasks/{task_id}/confirm") async def confirm( task_id: str, @@ -733,6 +869,46 @@ async def activity( task_id=task_id, ) + @app.get("/web/agent-migrations/tasks/{task_id}/events") + async def task_events( + task_id: str, + request: Request, + after: int = Query(0, ge=0), + ) -> StreamingResponse: + owner_id = owner_resolver(request) + # Authorize before the streaming response commits its headers, so a stranger + # gets a normal error instead of an empty 200 stream. + await invoke( + "get_task", + lambda: service.get_task(task_id, owner_id), + task_id=task_id, + ) + + async def frames() -> AsyncIterator[str]: + async with event_hub.subscription((owner_id, task_id)) as stream: + # A cursor from a stream that is gone (Studio restarted) would wait for + # events that will never come; the retained history re-syncs instead. + start = after if after <= stream.log.last_seq else 0 + async for event in stream.follow(start): + if event is None: + yield ": heartbeat\n\n" + continue + payload = { + **event.payload, + "seq": event.seq, + "taskId": task_id, + } + yield ( + f"id: {event.seq}\nevent: {event.type}\n" + f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + ) + + return StreamingResponse( + frames(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + @app.get("/web/agent-migrations/tasks/{task_id}/artifact") async def artifact( task_id: str, diff --git a/frontend/server/migration/service.py b/frontend/server/migration/service.py index 6b8fa1d5a..1155dbcd1 100644 --- a/frontend/server/migration/service.py +++ b/frontend/server/migration/service.py @@ -16,14 +16,16 @@ from __future__ import annotations +import asyncio +import contextlib import hashlib import io import json -import logging import mimetypes import re import shlex import stat +import threading import time import uuid import zipfile @@ -37,6 +39,7 @@ from veadk.cli.studio_model_catalog import ( provider_allows_studio_development_model, ) +from veadk.utils.logger import get_logger from frontend.server.deployment_source import ( DeploymentSourceError, @@ -51,10 +54,13 @@ from .contracts import ( MigrationContractError, validate_analysis_result, + validate_detection_report, validate_analysis_status, validate_confirmation, + validate_delivery_report, validate_delivery_result, validate_delivery_status, + validate_migration_driver, validate_migration_request, validate_process_exit, validate_source_status, @@ -68,13 +74,53 @@ MigrationRemoteFileNotFound, MigrationSandboxSession, ) +from .activity import AnalysisActivityLog +from .analysis_contract import ( + KIND_BY_STATUS, + RECOMMENDATION_KIND, + analysis_document_schema, + build_analysis_result, + detection_candidates, + is_model_document, +) +from .codex_exec_shim import shim_source as _codex_shim_source +from .detection import detect_source +from .analysis_input import ( + ASK_TOOL_NAME, + ASK_TOOL_SCHEMA, + AnalysisAskError, + AnalysisInputRegistry, + ask_payload, + normalize_answers, +) +from .app_server import ( + MigrationAnalysisUnavailable, + app_server_analysis_enabled, + ask_tool_handler, + run_route_analysis, +) +from .codex_tool_turn import DynamicTool +from .delivery_recovery import ( + recovery_source as delivery_recovery_source, +) +from .delivery_turn import ( + ARTIFACT_PATH, + ARTIFACT_TOOL_NAME, + DELIVERY_ASK_TOOL_DESCRIPTION, + DELIVERY_TOOL_NAME, + DeliveryContractError, + DeliveryTurnUnavailable, + PublishedArtifact, + delivery_app_server_enabled, + run_delivery_turn, +) from .models import ( MIGRATION_FRAMEWORKS, - STRUCTURED_ENTRY_PATTERN, STRUCTURED_MIGRATION_FRAMEWORKS, ConfirmMigrationBody, CreateMigrationTaskBody, SubmitAnalysisAnswersBody, + SubmitAnalysisInputBody, ) MIGRATION_ROOT = "/home/gem/.studio/migration/v1" @@ -116,12 +162,48 @@ _SOURCE_PATH = f"{MIGRATION_ROOT}/input/source.zip" _PROJECT_PATH = f"{MIGRATION_ROOT}/workspace/source" _SOURCE_STATUS_PATH = f"{MIGRATION_ROOT}/request/source.json" +_DETECTION_PATH = f"{MIGRATION_ROOT}/request/detection.json" _CAPABILITIES_PATH = f"{MIGRATION_ROOT}/control/capabilities.json" _ANALYSIS_STATUS_PATH = f"{MIGRATION_ROOT}/control/task-status.json" _ANALYSIS_RESULT_PATH = f"{MIGRATION_ROOT}/analysis/route.json" _ANALYSIS_PROMPT_PATH = f"{MIGRATION_ROOT}/analysis/prompt.md" +_ANALYSIS_RETRY_PROMPT_PATH = f"{MIGRATION_ROOT}/analysis/retry-prompt.md" _ANALYSIS_SCHEMA_PATH = f"{MIGRATION_ROOT}/analysis/route-schema.json" _ANALYSIS_PROCESS_EXIT_PATH = f"{MIGRATION_ROOT}/diagnostics/analysis/process-exit.json" +_ANALYSIS_EXTRACTION_DIAGNOSTICS_PATH = ( + f"{MIGRATION_ROOT}/diagnostics/analysis/result-extraction.json" +) + + +def _analysis_activity_path(attempt: int) -> str: + """Where an analysis attempt's Codex event log lives inside the Sandbox.""" + return f"{MIGRATION_ROOT}/diagnostics/analysis/attempt-{attempt}.log" + + +# The scripted ``codex exec`` fallback has no dynamic tools, so Codex writes one JSON +# document. It carries the judgement only: protocol bookkeeping is added by Studio. +_ANALYSIS_CONTRACT_KEYS = ("status", "summary") +_ANALYSIS_CONTRACT_STATUSES = ("needs_input", "recommendation_ready", "unsupported") +_ANALYSIS_TURN_TIMEOUT_SECONDS = 600.0 +# 分析回合等待用户回答的窗口:等待期间没有 app-server 事件,所以空闲窗口必须 +# 覆盖它,否则客户端的空闲计时器会在用户作答前取消整个回合。 +_ANALYSIS_INPUT_WINDOW_SECONDS = 300.0 +_ANALYSIS_INPUT_IDLE_MARGIN_SECONDS = 60.0 +_ANALYSIS_DRIVER_PATH = f"{MIGRATION_ROOT}/control/analysis-driver.json" +_ANALYSIS_DRIVER_APP_SERVER = "app-server" +_ANALYSIS_DRIVER_SCRIPT = "codex-exec" +_ANALYSIS_DRIVER_RUNNING = "running" +_ANALYSIS_DRIVER_DONE = "done" +# 后台驱动的租约:心跳过期说明持有它的 Studio 进程已经不在了。 +_ANALYSIS_DRIVER_HEARTBEAT_SECONDS = 20.0 +_ANALYSIS_DRIVER_STALE_SECONDS = 90.0 +# 标识写入租约的后台驱动属于哪个 Studio 进程,便于诊断跨进程接管。 +_STUDIO_PROCESS_ID = uuid.uuid4().hex +_ANALYSIS_STATUS_MESSAGES = { + "ready": "项目分析完成,请确认迁移方式", + "needs_input": "需要补充少量信息后继续分析", +} +_ANALYSIS_UNSUPPORTED_MESSAGE = "当前项目不适用于已支持的迁移方式" _CONFIRMATION_PATH = f"{MIGRATION_ROOT}/control/route-selection.json" _INSTRUCTION_PATH = f"{MIGRATION_ROOT}/control/instruction.txt" _STOPPED_PATH = f"{MIGRATION_ROOT}/control/stopped.json" @@ -129,6 +211,84 @@ _DELIVERY_STATUS_PATH = f"{MIGRATION_ROOT}/delivery/migration-status.json" _DELIVERY_RESULT_PATH = f"{MIGRATION_ROOT}/delivery/migration-result.json" _DELIVERY_ARTIFACT_PATH = f"{MIGRATION_ROOT}/delivery/migration-result.zip" +# The delivery driver lease: the Sandbox launch script refreshes its heartbeat while +# the migration CLI works and publishes the artifact digest once it exits. Studio +# reads it to tell "still working" from "the process is gone", so a driver that died +# without writing any delivery state fails the task instead of hanging in migrating. +_MIGRATION_DRIVER_PATH = f"{MIGRATION_ROOT}/control/migration-driver.json" +_MIGRATION_CLI_PID_PATH = f"{MIGRATION_ROOT}/control/migration-cli.pid" +_MIGRATION_DRIVER_SCRIPT_PATH = f"{MIGRATION_ROOT}/control/migration-driver.py" +_MIGRATION_DRIVER_HEARTBEAT_SECONDS = 15.0 +_MIGRATION_DRIVER_STALE_SECONDS = 90.0 +# 迁移主回合的 Codex 事件源:沙箱里的迁移 CLI 自己调 `codex exec`,Studio 在这条 +# 命令的 PATH 前面装一个垫片,把那次 exec 接到沙箱已经托管的 Codex app-server 上。 +# `codex exec --json` 既不报工具耗时,也不报回合耗时和模型,app-server 两者都有, +# 页面因此和智能构建一致。CLI 本身不动:垫片只接它认识的那条命令行,其余照旧。 +_MIGRATION_CODEX_SHIM_DIR = f"{MIGRATION_ROOT}/control/bin" +_MIGRATION_CODEX_SHIM_PATH = f"{_MIGRATION_CODEX_SHIM_DIR}/studio-codex-shim.py" +_MIGRATION_CODEX_SHIM_WRAPPER_PATH = f"{_MIGRATION_CODEX_SHIM_DIR}/codex" +_MIGRATION_CODEX_SHIM_PYTHON_PATH = f"{_MIGRATION_CODEX_SHIM_DIR}/python" +_MIGRATION_CODEX_SHIM_STATE_PATH = f"{MIGRATION_ROOT}/control/codex-shim-state.json" +# 交付收尾回合:迁移 CLI 结束以后,Studio 驱动一个 app-server 回合核对并发布这次交付。 +# 产物由 Studio 自己读回、自己算摘要,失败也在这里变成一句能解释、能追问的结论。 +_DELIVERY_REPORT_PATH = f"{MIGRATION_ROOT}/delivery/delivery-report.json" +_DELIVERY_TURN_PATH = f"{MIGRATION_ROOT}/control/delivery-turn.json" +_DELIVERY_TURN_CWD = f"{MIGRATION_ROOT}/work/delivery" +_DELIVERY_TURN_ACTIVITY_PATH = f"{MIGRATION_ROOT}/work/agentic/logs/delivery-turn.jsonl" +_DELIVERY_TURN_DRIVER = "app-server" +_DELIVERY_TURN_RUNNING = "running" +_DELIVERY_TURN_DONE = "done" +_DELIVERY_TURN_TIMEOUT_SECONDS = 300.0 +# 等待用户回答期间没有 app-server 事件,空闲窗口必须覆盖它,否则回合会被取消。 +_DELIVERY_TURN_INPUT_WINDOW_SECONDS = 300.0 +_DELIVERY_TURN_INPUT_IDLE_MARGIN_SECONDS = 60.0 +# 收尾回合的租约:心跳过期说明持有它的 Studio 进程已经不在了,可以重新收尾。 +_DELIVERY_TURN_HEARTBEAT_SECONDS = 20.0 +_DELIVERY_TURN_STALE_SECONDS = 90.0 +# 一个交付最多收尾几次:失败后每次读任务都重开回合会白白烧 token。 +_DELIVERY_TURN_MAX_ATTEMPTS = 2 +_DELIVERY_DIR = f"{MIGRATION_ROOT}/delivery" +_DELIVERY_OUTPUT_DIR = f"{MIGRATION_ROOT}/output/veadk" +# agent 自己写完项目时留下的终态,与 CLI 的 processState 一致。 +_AGENT_STATUS_PATH = f"{MIGRATION_ROOT}/work/agentic/state/status.json" +_DELIVERED_AGENT_STATES = frozenset( + { + "Succeed", + "SucceedWithWarnings", + "Partial", + "succeeded", + "succeeded_with_warnings", + "partial", + } +) +# 交付复原:CLI 不在了,但 agent 已经把项目做完时,Studio 按 CLI 自己的规则重新打包 +# 一次,而不是让用户再等一个 15 分钟的 Codex 回合。打包是磁盘上文件的纯函数。 +_DELIVERY_RECOVERY_SCRIPT_PATH = f"{MIGRATION_ROOT}/control/delivery-recovery.py" +_DELIVERY_RECOVERY_REQUEST_PATH = f"{MIGRATION_ROOT}/control/delivery-recovery.json" +_DELIVERY_RECOVERY_RESULT_PATH = ( + f"{MIGRATION_ROOT}/control/delivery-recovery-result.json" +) +_DELIVERY_RECOVERY_LOG_PATH = ( + f"{MIGRATION_ROOT}/diagnostics/migration/delivery-recovery.log" +) +_DELIVERY_RECOVERY_LEASE_PATH = f"{MIGRATION_ROOT}/control/delivery-recovery-lease.json" +_DELIVERY_RECOVERY_DRIVER = "studio" +_DELIVERY_RECOVERY_RUNNING = "running" +_DELIVERY_RECOVERY_DONE = "done" +# 复原的租约:心跳过期说明持有它的 Studio 进程已经不在了,可以重新打包。 +_DELIVERY_RECOVERY_HEARTBEAT_SECONDS = 20.0 +_DELIVERY_RECOVERY_STALE_SECONDS = 90.0 +# 一个交付最多复原几次:结论已经落地就不再重开。 +_DELIVERY_RECOVERY_MAX_ATTEMPTS = 2 +# 打包是本地文件操作,但项目可能有几百 MB:给一个绝对上限,免得卡死读任务。 +_DELIVERY_RECOVERY_TIMEOUT_SECONDS = 900 +# 交付阶段已经落定的状态:只有落定的交付才需要收尾回合解释它。 +_DELIVERY_SETTLED_STATES = { + "succeeded", + "succeeded_with_warnings", + "partial", + "failed", +} _MIGRATION_ACTIVITY_LOG_PATHS = tuple( f"{MIGRATION_ROOT}/work/agentic/logs/codex-attempt-{attempt}.jsonl" for attempt in range(1, 4) @@ -173,7 +333,10 @@ ) _ENV_REFERENCE_RE = re.compile(r"\$\{|\$\(|`") -logger = logging.getLogger(__name__) +# Anchor the analysis diagnostics under the veadk logger: the Studio entrypoint +# pins the root logger to ERROR, so a plain module logger would hide a silent +# fallback from the app-server path to the scripted one. +logger = get_logger(__name__) def _public_environment_defaults( @@ -348,6 +511,51 @@ def _has_activity_payload(value: object) -> bool: return value is not None and value != "" +# Studio's own tools on the delivery turn publish the deliverable, so their calls are +# page content the way the intelligent build's result tool is. +_ACTIVITY_DYNAMIC_TOOL_TITLES: dict[str, dict[str, str]] = { + "publishArtifact": { + "running": "正在拉取迁移产物并核对字节", + "completed": "已拉取迁移产物并核对字节", + "failed": "迁移产物核对未通过", + }, + "reportDelivery": { + "running": "正在提交交付结论", + "completed": "已提交交付结论", + "failed": "提交交付结论未完成", + }, + "askUser": { + "running": "正在等待用户回答", + "completed": "已收到用户回答", + "failed": "用户回答未收到", + }, +} + + +def _activity_dynamic_tool_text(result: object) -> str: + """The sentence Studio's own tool returned, which is what the page shows.""" + if not isinstance(result, dict): + return "" + content_items = result.get("contentItems") + texts = ( + [ + entry["text"] + for entry in content_items + if isinstance(entry, dict) and isinstance(entry.get("text"), str) + ] + if isinstance(content_items, list) + else [] + ) + joined = "\n".join(part for part in texts if part) + if joined: + return joined + if result.get("success") is True: + return "已接收。" + if result.get("success") is False: + return "调用被拒绝。" + return "" + + def _activity_status(event_type: str, item: dict[str, object]) -> str: status = str(item.get("status") or "").lower() if event_type.endswith(".failed") or status in {"failed", "error", "declined"}: @@ -362,10 +570,218 @@ def _analysis_result_message(value: str) -> bool: return False try: candidate = json.loads(value) - validate_analysis_result(candidate) - except (MigrationContractError, ValueError): + except ValueError: return False - return True + return is_model_document(candidate) + + +# 页面按智能构建同一套 Codex 事件渲染工具行(原生图标、标签、耗时),所以活动项要 +# 带上原生 itemType;缺了它,同一段 Codex 输出会变成另一套行样式。 +_ACTIVITY_NATIVE_ITEM_TYPES = { + "reasoning": "reasoning", + "agent_message": "agentMessage", + "command_execution": "commandExecution", + "file_change": "fileChange", + "mcp_tool_call": "mcpToolCall", + "dynamic_tool_call": "dynamicToolCall", + "collab_tool_call": "collabToolCall", + "web_search": "webSearch", +} + + +def _activity_native_fields( + item_type: str, + item: dict[str, object], +) -> dict[str, object]: + """The fields the shared row renderer reads off a Codex item. + + ``itemType`` selects Codex' own row (icon, computed label, untruncated output) and + ``durationMs`` is what the collapsed process header reports, so a migration turn + that ran for minutes does not read like one that ran instantly. + """ + fields: dict[str, object] = {} + native = _ACTIVITY_NATIVE_ITEM_TYPES.get(item_type) + if native: + fields["itemType"] = native + duration = item.get("duration_ms") + if isinstance(duration, int) and not isinstance(duration, bool) and duration >= 0: + fields["durationMs"] = duration + phase = item.get("phase") + if isinstance(phase, str) and phase: + fields["phase"] = phase + return fields + + +def _activity_row_name( + item: dict[str, object], + fallback: str, + *, + secret_values: tuple[str, ...], +) -> str: + """The label the shared row renderer shows for a tool call. + + The app-server already names its own rows (运行命令 / 修改文件 / 网络搜索 / + ``MCP · server/tool``) and the intelligent build labels them from exactly that + name, so a migration turn reads the same. A log written before the app-server + path recorded the name, or the scripted ``codex exec`` driver that never has one, + keeps the migration's own wording. + """ + name = item.get("name") + if isinstance(name, str) and name.strip(): + return _redact_activity_text(name, secret_values=secret_values) + return fallback + + +# 回合自报的终态,和智能构建 turn-summary 的 status 是同一套取值。 +_ACTIVITY_TURN_STATUSES = { + "completed": "completed", + "failed": "failed", + "cancelled": "interrupted", + "interrupted": "interrupted", +} + +_ACTIVITY_TURN_NUMBERS = ("startedAt", "completedAt", "durationMs") + +# 迁移主回合正常由垫片跑在沙箱 app-server 上(见 codex_exec_shim.py),日志形状 +# 与 app-server 一致。垫片连不上时它把这一轮交回真正的 `codex exec --json`,那份 +# 事件流只报蛇形 token 用量、也没有回合对象;这两张表把那种终态行翻译成 app-server +# 那套形状。 +_ACTIVITY_EXEC_TURN_EVENT_TYPES = ("turn.completed", "turn.failed", "turn.interrupted") + +_ACTIVITY_EXEC_USAGE_KEYS = { + "totalTokens": ("totalTokens", "total_tokens"), + "inputTokens": ("inputTokens", "input_tokens"), + "outputTokens": ("outputTokens", "output_tokens"), + "cachedInputTokens": ("cachedInputTokens", "cached_input_tokens"), + "cacheWriteInputTokens": ("cacheWriteInputTokens", "cache_write_input_tokens"), + "reasoningOutputTokens": ("reasoningOutputTokens", "reasoning_output_tokens"), +} + +_ACTIVITY_TURN_USAGE_KEYS = ( + "totalTokens", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "reasoningOutputTokens", +) + + +def _activity_turn_number(value: object) -> int | float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if value >= 0 else None + + +def _activity_turn_usage(value: object) -> dict[str, int]: + """The token counts the shared summary renders, in the browser's own naming.""" + if not isinstance(value, dict): + return {} + usage: dict[str, int] = {} + for key in _ACTIVITY_TURN_USAGE_KEYS: + count = value.get(key) + if isinstance(count, int) and not isinstance(count, bool) and count >= 0: + usage[key] = count + return usage + + +def _activity_exec_turn_usage(value: object) -> dict[str, int]: + """Token usage of a `codex exec` turn, in the browser's own naming. + + Codex reports input and output tokens and lets the reader add them up; the + app-server reports the same number ready-made, so it is completed here. This is + the only cost the in-Sandbox stream carries: it timestamps nothing. + """ + if not isinstance(value, dict): + return {} + counts: dict[str, int] = {} + for key, aliases in _ACTIVITY_EXEC_USAGE_KEYS.items(): + for alias in aliases: + candidate = _activity_turn_number(value.get(alias)) + if candidate is not None: + counts[key] = int(candidate) + break + if "totalTokens" not in counts: + total = counts.get("inputTokens", 0) + counts.get("outputTokens", 0) + if total: + counts["totalTokens"] = total + return counts + + +def _activity_turn_status(event_type: str, turn: dict[str, object]) -> str: + raw = turn.get("status") + if isinstance(raw, dict): + raw = raw.get("type") + status = _ACTIVITY_TURN_STATUSES.get(str(raw or "").strip().lower()) + if status: + return status + if event_type == "turn.failed": + return "failed" + if event_type == "turn.interrupted": + return "interrupted" + return "completed" + + +def _activity_tool_row(item: dict[str, object]) -> bool: + """Whether the page draws this item as a tool call. + + Kept in step with the shared renderer's own mapping: a command row is a tool call, + and so is a status row the page only shows because it did not succeed. + """ + kind = str(item.get("kind") or "") + return kind == "command" or (kind == "status" and item.get("status") != "completed") + + +def _activity_turn_summary( + turn: dict[str, object], + *, + items: list[dict[str, object]], + phase: str, + attempt: int, + status: str, + usage: dict[str, int], + secret_values: tuple[str, ...], +) -> dict[str, object]: + """One summary of everything the turn logged, in the app-server's own numbers. + + The intelligent build reports a turn's wall-clock time, tool calls, tool time and + token usage from the turn's lifecycle and usage events. A migration turn that ran + on the app-server reports the same numbers and they are carried over unchanged; a + turn whose log has no turn object (the fallback `codex exec --json` stream) still + settles here out of the items logged before it ended. + """ + tools = [item for item in items if _activity_tool_row(item)] + measured = [ + item + for item in tools + if isinstance(item.get("durationMs"), int) + and not isinstance(item.get("durationMs"), bool) + ] + detail: dict[str, object] = { + "turnId": str(turn.get("id") or ""), + "status": status, + "toolCalls": len(tools), + "toolDurationComplete": len(measured) == len(tools), + } + for key in _ACTIVITY_TURN_NUMBERS: + number = _activity_turn_number(turn.get(key)) + if number is not None: + detail[key] = number + if measured or not tools: + detail["toolDurationMs"] = sum(int(item["durationMs"]) for item in measured) + model = turn.get("model") + if isinstance(model, str) and model.strip(): + detail["model"] = _redact_activity_text(model, secret_values=secret_values) + if usage: + detail["usage"] = usage + failed = status in {"failed", "interrupted"} + return { + "id": f"{phase}:{attempt}:turn-summary", + "kind": "summary", + "status": "failed" if failed else "completed", + "title": "本轮执行未完成" if failed else "本轮执行完成", + "turn": detail, + } def _parse_activity_log( @@ -376,6 +792,7 @@ def _parse_activity_log( ) -> list[dict[str, object]]: items: list[dict[str, object]] = [] item_indexes: dict[str, int] = {} + thread_id = "" def upsert(item: dict[str, object]) -> None: item_id = str(item["id"]) @@ -410,6 +827,30 @@ def upsert(item: dict[str, object]) -> None: status = _activity_status(event_type, item) secret_values = _activity_secret_values(event) + # 回合结算行只带回合自己的统计(耗时/模型/token 用量),没有 item:它汇总 + # 的是这一份活动日志里此前记下的所有行。 + raw_turn = event.get("turn") + if isinstance(raw_turn, dict): + upsert( + _activity_turn_summary( + raw_turn, + items=items, + phase=phase, + attempt=attempt, + status=_activity_turn_status(event_type, raw_turn), + usage=_activity_turn_usage(event.get("usage")), + secret_values=secret_values, + ) + ) + continue + + # 迁移主回合的第一行只说它开了哪个 thread,回合结算时用它当回合 ID。 + if event_type == "thread.started": + raw_thread_id = event.get("thread_id") + if isinstance(raw_thread_id, str): + thread_id = raw_thread_id.strip() + continue + if item_type in {"reasoning", "agent_message"}: raw_text = item.get("text") if not isinstance(raw_text, str): @@ -430,6 +871,7 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": "Codex 思考" if item_type == "reasoning" else "Codex 更新", "detail": detail, + **_activity_native_fields(item_type, item), } ) continue @@ -505,10 +947,18 @@ def upsert(item: dict[str, object]) -> None: "completed": "命令执行完成", "failed": "命令执行失败", }[status] - tool: dict[str, object] = {"name": title} + tool: dict[str, object] = { + "name": _activity_row_name(item, title, secret_values=secret_values) + } + command_input: dict[str, object] = {} if command_text: + command_input["command"] = command_text + actions = item.get("command_actions") + if _has_activity_payload(actions): + command_input["commandActions"] = actions + if command_input: tool["input"] = _activity_payload( - {"command": command_text}, + command_input, secret_values=secret_values, ) output = item.get("aggregated_output") @@ -527,6 +977,46 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": title, "tool": tool, + **_activity_native_fields(item_type, item), + } + ) + continue + + if item_type == "dynamic_tool_call": + tool_name = _redact_activity_text( + str(item.get("name") or ""), + secret_values=secret_values, + ) + unknown = { + "running": f"正在调用工具 {tool_name or 'Studio'}", + "completed": f"已调用工具 {tool_name or 'Studio'}", + "failed": f"工具 {tool_name or 'Studio'} 调用未完成", + } + # 调用本身完成、但 Studio 拒绝了参数:页面要按「没成功」显示, + # 这样被拒的那一次收尾在活动流里是看得见的。 + result = item.get("result") + rejected = isinstance(result, dict) and result.get("success") is False + row_status = "failed" if rejected else status + title = _ACTIVITY_DYNAMIC_TOOL_TITLES.get(tool_name, unknown)[row_status] + tool: dict[str, object] = {"name": title} + arguments = item.get("arguments") + if _has_activity_payload(arguments): + tool["input"] = _activity_payload( + arguments, + secret_values=secret_values, + ) + detail = _activity_dynamic_tool_text(result) + if detail: + detail = _redact_activity_text(detail, secret_values=secret_values) + tool["error" if rejected else "output"] = detail + upsert( + { + "id": activity_id, + "kind": "command", + "status": row_status, + "title": title, + "tool": tool, + **_activity_native_fields(item_type, item), } ) continue @@ -540,7 +1030,9 @@ def upsert(item: dict[str, object]) -> None: "completed": f"已更新{subject}", "failed": f"更新{subject}失败", }[status] - tool: dict[str, object] = {"name": title} + tool: dict[str, object] = { + "name": _activity_row_name(item, title, secret_values=secret_values) + } if isinstance(changes, list): tool["input"] = _activity_payload( {"changes": changes}, @@ -553,6 +1045,7 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": title, "tool": tool, + **_activity_native_fields(item_type, item), } ) continue @@ -572,7 +1065,9 @@ def upsert(item: dict[str, object]) -> None: "completed": f"已调用工具 {label}", "failed": f"工具 {label} 调用未完成", }[status] - tool = {"name": title} + tool = { + "name": _activity_row_name(item, title, secret_values=secret_values) + } arguments = item.get("arguments") if _has_activity_payload(arguments): tool["input"] = _activity_payload( @@ -598,6 +1093,7 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": title, "tool": tool, + **_activity_native_fields(item_type, item), } ) continue @@ -658,6 +1154,7 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": title, "tool": tool, + **_activity_native_fields(item_type, item), } ) continue @@ -673,7 +1170,9 @@ def upsert(item: dict[str, object]) -> None: for key in ("query", "action") if _has_activity_payload(item.get(key)) } - tool = {"name": title} + tool = { + "name": _activity_row_name(item, title, secret_values=secret_values) + } if input_value: tool["input"] = _activity_payload( input_value, @@ -686,6 +1185,7 @@ def upsert(item: dict[str, object]) -> None: "status": status, "title": title, "tool": tool, + **_activity_native_fields(item_type, item), } ) continue @@ -708,6 +1208,23 @@ def upsert(item: dict[str, object]) -> None: ) continue + # 垫片够不到 app-server 时会把这一轮交回真正的 `codex exec --json`,那份事件流 + # 没有回合对象,只写一条裸的终态行。这个回合的成本照智能构建的样式补齐:工具次数 + # 从上面记下的行数出来,token 用量从这条终态行出来。(垫片写的回合带 turn 对象, + # 在本循环开头就已经结算,不会重复。) + if event_type in _ACTIVITY_EXEC_TURN_EVENT_TYPES: + upsert( + _activity_turn_summary( + {"id": thread_id}, + items=items, + phase=phase, + attempt=attempt, + status=_activity_turn_status(event_type, {}), + usage=_activity_exec_turn_usage(event.get("usage")), + secret_values=secret_values, + ) + ) + if event_type == "error": raw_message = event.get("message") detail = ( @@ -1127,201 +1644,208 @@ def semantic_version(text): def _analysis_schema() -> dict[str, object]: - evidence = { - "type": "object", - "additionalProperties": False, - "required": ["path", "line", "reason"], - "properties": { - "path": { - "type": "string", - "minLength": 1, - "maxLength": 4_096, - "pattern": ( - r"^(?!/)(?!.*(?:^|/)\.{1,2}(?:/|$))" - r"(?!.*//)(?!.*\\)[^\x00-\x1f\x7f]+$" - ), - }, - "line": {"type": "integer", "minimum": 1}, - "reason": {"type": "string", "minLength": 1, "maxLength": 4_000}, + """The document Codex writes when only the scripted driver is available. + + Protocol bookkeeping (schema_version, attempt, input_sha256) is not part of it: + Studio injects those while storing the result, so the model is only asked for its + own judgement. Narrowing what the model must produce is what makes a shape drift + a quality problem instead of a lost analysis. + """ + return analysis_document_schema() + + +def _interactive_analysis_context() -> str: + """The ask-by-tool section, only for turns that registered ``askUser``.""" + return """ +## 交互提问(本回合可用) + +- 本轮已注册 askUser 工具。当项目内容无法回答、且答案会改变迁移方式、入口或范围时, + 必须先用 askUser 直接询问用户,不要先把结论交付出去。 +- 一次提问 1-3 个问题,每个问题给出简短 header 和完整 question;有自然选择时给出 + 2-3 个 options(每项含 label 和 description,第一项为推荐项),没有自然选择时省略 options。 +- 用户回答会在同一次分析中返回。拿到回答后继续完成分析,并用 reportRoute 交付最终结论, + 不要重复提问已经问过的问题。 +- 只有 askUser 返回 unanswered(用户没有在时限内回答)时,才用 status=needs_input + 交付这些问题,让用户之后在页面上补充。 +- 能从项目文件确认的事实必须自己查证,禁止为了省事而提问。 + +""" + + +_ANALYSIS_OUTCOME_PATH = f"{MIGRATION_ROOT}/diagnostics/analysis/model-turn.json" +_CONSERVATIVE_SUMMARY = ( + "本轮没有取得可用的模型分析结论。以下结论由 Studio 依据项目文件清单与确定性" + "检测直接生成:推荐按 {framework} 迁移,范围覆盖项目内全部文件。" + "可以直接确认并开始迁移;如需更精确的迁移方式,请重新发起一次分析。" +) +_CONSERVATIVE_WARNING = ( + "保守结论:模型分析未给出可用结果,本结论由 Studio 依据项目文件清单与确定性" + "检测直接生成,未经模型复核,请在使用前核对迁移范围。" +) + + +def _analysis_failure_reason(outcome: dict[str, object]) -> str: + """One sentence explaining why the model layer produced nothing usable.""" + refusals = outcome.get("refusals") + if isinstance(refusals, list) and refusals: + return "模型提交的结论未通过校验:" + ";".join( + str(item) for item in refusals[-3:] + ) + if outcome.get("events"): + return "模型回合结束但没有提交任何结论。" + return "模型回合没有得到可用输出。" + + +def _conservative_analysis( + detection: dict[str, object] | None, + *, + attempt: int, + input_sha256: str, + reason: str, +) -> tuple[dict[str, object], list[str]]: + """Build a usable conclusion without any model output. + + The point of the fallback is that "analysis" must produce something a user can act + on. Any is always an executable route, and the verified candidates stay selectable, + so the confirmation page keeps offering what detection proved. + """ + candidates = detection_candidates(detection) + frameworks = [item["id"] for item in candidates] + recommended = frameworks[0] if frameworks else "any" + warnings = [_CONSERVATIVE_WARNING] + if reason: + warnings.append(f"模型分析未交付可用结论的原因:{reason}") + for item in (detection or {}).get("candidates", []): + if isinstance(item, dict) and item.get("id"): + evidence = item.get("evidence") + if isinstance(evidence, list): + for entry in evidence: + if isinstance(entry, dict) and entry.get("path"): + warnings.append( + f"检测证据:{entry.get('path')}:{entry.get('line') or 1}" + f" — {entry.get('reason') or ''}" + ) + return build_analysis_result( + RECOMMENDATION_KIND, + { + "summary": _CONSERVATIVE_SUMMARY.format(framework=recommended), + "frameworks": [ + { + "id": item["id"], + "confidence": item.get("confidence"), + "evidence": item.get("evidence"), + } + for item in candidates + ] + or [{"id": "any", "confidence": "low", "evidence": []}], + "recommended": {"framework": recommended, "entry": None, "reason": ""}, + "boundary": {"include": ["项目内全部文件"], "exclude": []}, + "warnings": warnings, }, - } + attempt=attempt, + input_sha256=input_sha256, + detection=detection, + ) + + +def _empty_detection_report() -> dict[str, object]: + """Detection that could not run. + + Analysis still proceeds; the report only says that the file inventory is unknown, + so nothing downstream may treat an empty inventory as "the project has no files". + """ return { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": False, - "required": [ - "schema_version", - "status", - "attempt", - "input_sha256", - "summary", - "frameworks", - "recommended", - "entries", - "boundary", - "assumptions", - "questions", - "warnings", - ], - "properties": { - "schema_version": {"const": 1}, - "status": { - "enum": [ - "needs_input", - "recommendation_ready", - "unsupported", - ] - }, - "attempt": {"type": "integer", "minimum": 1, "maximum": 100}, - "input_sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$", - }, - "summary": { - "type": "string", - "minLength": 1, - "maxLength": 20_000, - }, - "frameworks": { - "type": "array", - "maxItems": 20, - "items": { - "type": "object", - "additionalProperties": False, - "required": ["id", "confidence", "evidence"], - "properties": { - "id": { - "enum": list(MIGRATION_FRAMEWORKS), - }, - "confidence": {"enum": ["high", "medium", "low"]}, - "evidence": { - "type": "array", - "maxItems": 100, - "items": evidence, - }, - }, - }, - }, - "recommended": { - "anyOf": [ - { - "type": "object", - "additionalProperties": False, - "required": ["framework", "entry", "reason"], - "properties": { - "framework": {"enum": _STRUCTURED_FRAMEWORKS}, - "entry": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "pattern": STRUCTURED_ENTRY_PATTERN, - }, - "reason": {"type": "string", "maxLength": 4_000}, - }, - }, - { - "type": "object", - "additionalProperties": False, - "required": ["framework", "entry", "reason"], - "properties": { - "framework": {"enum": ["dify", "any"]}, - "entry": {"type": "null"}, - "reason": {"type": "string", "maxLength": 4_000}, - }, - }, - {"type": "null"}, - ], - }, - "entries": { - "type": "array", - "maxItems": 100, - "items": { - "type": "object", - "additionalProperties": False, - "required": ["value", "framework", "evidence"], - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "pattern": STRUCTURED_ENTRY_PATTERN, - }, - "framework": {"enum": _STRUCTURED_FRAMEWORKS}, - "evidence": { - "type": "string", - "minLength": 1, - "maxLength": 4_000, - }, - }, - }, - }, - "boundary": { - "type": "object", - "additionalProperties": False, - "required": ["include", "exclude"], - "properties": { - "include": { - "type": "array", - "maxItems": 200, - "items": {"type": "string", "maxLength": 4_000}, - }, - "exclude": { - "type": "array", - "maxItems": 200, - "items": {"type": "string", "maxLength": 4_000}, - }, - }, - }, - "assumptions": { - "type": "array", - "maxItems": 100, - "items": {"type": "string", "maxLength": 4_000}, - }, - "questions": { - "type": "array", - "maxItems": 50, - "items": { - "type": "object", - "additionalProperties": False, - "required": ["id", "prompt", "required"], - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - }, - "prompt": { - "type": "string", - "minLength": 1, - "maxLength": 4_000, - }, - "required": {"type": "boolean"}, - }, - }, - }, - "warnings": { - "type": "array", - "maxItems": 100, - "items": {"type": "string", "maxLength": 4_000}, - }, + "schema_version": 1, + "files": {"count": 0, "listed": []}, + "documents": [], + "candidates": [], + "unreadable": [], + "degraded": True, + "degraded_reason": "detection_missing", + } + + +def _detection_report(content: bytes) -> dict[str, object]: + """Run the model-free detection and validate its own output before storing it.""" + return validate_detection_report(detect_source(content)) + + +def _detection_prompt_context(detection: dict[str, object]) -> str: + """Render the verified facts Codex may rely on instead of rediscovering them.""" + files = detection.get("files") + listed = files.get("listed") if isinstance(files, dict) else [] + view = { + "files": { + "count": files.get("count") if isinstance(files, dict) else 0, + "listed": listed if isinstance(listed, list) else [], }, - "allOf": [ + "documents": [ { - "if": { - "properties": {"status": {"const": "unsupported"}}, - "required": ["status"], - }, - "then": { - "properties": { - "recommended": {"type": "null"}, - "entries": {"maxItems": 0}, - "questions": {"maxItems": 0}, - } - }, - "else": {"properties": {"recommended": {"not": {"type": "null"}}}}, + "path": str(item.get("path") or ""), + "status": str(item.get("status") or ""), + "dsl": str(item.get("dsl") or ""), } + for item in detection.get("documents", []) + if isinstance(item, dict) ], + "candidates": detection.get("candidates", []), + "unreadable": detection.get("unreadable", []), + "degraded": detection.get("degraded"), + "degraded_reason": detection.get("degraded_reason"), } + return f"""## 已核实的项目事实(Studio 免模型检测) + +以下内容由 Studio 在分析开始前用确定性程序核实,可直接作为事实使用,不必再用命令 +重复验证;你的结论必须与之一致,不一致时必须在 summary 里说明原因。 + +```json +{json.dumps(view, ensure_ascii=False, indent=2)} +``` + +- files.count 是 ZIP 内真实文件数,files.listed 是文件名清单(最多 200 条;超出部分 + 需要时自行读取)。 +- candidates 是检测器已确认的框架候选,附带文件与行号。candidates 非空时,你的 + frameworks 必须包含这些候选;在没有任何新证据的情况下给出 unsupported 会被拒绝。 +- unreadable 列出检测器无法读取的文件及原因;degraded 为 true 时,把 degraded_reason + 视作分析限制写入 warnings,不要据此判定项目材料不足。 + +""" + + +def _tool_protocol_context() -> str: + """The delivery protocol for turns that registered the analysis tools.""" + return """## 输出协议 + +- 分析结束后调用下面三个工具之一交付结论,调用一次即可,不要重复提交: + - `reportRecommendation`:推荐一种可执行的迁移方式(summary 必填,其余尽量给)。 + - `reportNeedsInput`:必须先由用户补充信息才能决定迁移方式,把问题写进 questions。 + - `reportUnsupported`:项目无法迁移。只用于材料不足或证据完整的高风险行为链, + 必须给出 summary 和至少两条指向项目内真实文件的证据(path、line、reason), + 引用不存在的文件会被拒绝。 +- Studio 会补齐 schema_version、attempt、input_sha256 等簿记字段,你不要输出它们。 +- 字段缺失、类型不对、层级不对都不会导致失败:Studio 会取默认值或忽略多余内容, + 只有在结论本身无法成立时才会拒绝,并在返回值里指出具体字段。 +- 只有 summary 是必填的。summary 用简体中文写给用户看,说明结论和理由。 +- Dify/Any 的推荐入口必须为空;Structured 入口必须是相对项目根目录的文件入口, + 例如 `agent.py:agent`、`langgraph.json:graph_id`。 +- 不要输出 Markdown 表格,也不要在总结里重复分析过程。""" + + +def _document_protocol_context() -> str: + """The delivery protocol when only the scripted ``codex exec`` driver is free.""" + return """## 输出协议 + +- 最终响应只输出一个 JSON 对象,不要输出 Markdown 围栏、解释或额外文字。 +- 必填字段只有 status 和 summary: + - status 取 recommendation_ready、needs_input 或 unsupported; + - summary 用简体中文写给用户看,说明结论和理由。 +- 另外尽量给出这些可选字段:frameworks(候选,每项含 id、confidence、evidence)、 + recommended(含 framework、entry、reason)、entries、boundary、assumptions、 + warnings、questions(status=needs_input 时给出必答问题)。 +- 不要输出 schema_version、attempt、input_sha256 等簿记字段,Studio 会自己补齐。 +- 给出 unsupported 时必须带至少两条指向项目内真实文件的证据(path、line、reason); + evidence 引用不存在的文件会被拒绝。 +- 字段缺失或层级不对不会导致失败:Studio 会取默认值或忽略多余内容。""" def _analysis_prompt( @@ -1331,8 +1855,30 @@ def _analysis_prompt( input_sha256: str, previous_analysis: dict[str, object] | None = None, answers: dict[str, str] | None = None, + protocol_retry: bool = False, + interactive: bool = False, + detection: dict[str, object] | None = None, ) -> str: instruction = str(request.get("instruction") or "").strip() + # 只有 app-server 驱动注册了 askUser;脚本驱动读到的提示词不能承诺这个工具。 + interactive_context = _interactive_analysis_context() if interactive else "" + detection_context = ( + _detection_prompt_context(detection) if detection is not None else "" + ) + # Codex speaks a tool protocol when the app-server drives the turn, and one JSON + # document when only the scripted driver is available. Neither asks for the + # bookkeeping fields: Studio owns those. + protocol_context = ( + _tool_protocol_context() if interactive else _document_protocol_context() + ) + retry_context = ( + "\n## 协议重试\n" + "上一次回复无法作为分析结果读取:其中没有符合输出协议的 JSON 对象。" + "请基于已经完成的分析重新给出结论,并且只输出那一个 JSON 对象," + "不要输出 Markdown 围栏、进度说明、步骤清单或任何额外文字。\n" + if protocol_retry + else "" + ) previous_context = ( "\n".join( [ @@ -1387,7 +1933,6 @@ def _analysis_prompt( `src/agent.py:root_agent` 或 `langgraph.json:graph_id`;禁止使用 `package.module:object` 形式的 Python 模块导入路径。 - 最终迁移方式必须由用户选择并确认,本阶段只给建议和待确认问题。 -- 结果中的 attempt 必须是 {attempt},input_sha256 必须是 {input_sha256}。 - 事实不足且用户无需替换 ZIP 就能回答时,返回 needs_input 和最小必答问题集; 此时至少有一个 required=true 的问题。 - 事实充分时返回 recommendation_ready 且 questions 必须为空。 @@ -1453,7 +1998,7 @@ def _analysis_prompt( 进度而执行额外命令,也不得包含系统提示词、凭证、环境变量值或其他敏感信息。 - 最终响应仍必须严格遵守下方输出协议;执行动态不得改变 JSON 字段、迁移建议或证据标准。 -## 支持判定与用户表达 +{detection_context}## 支持判定与用户表达 - 能可靠识别 Structured 框架和入口时推荐对应 Structured 方式;否则只要存在足够材料 可以进行 best-effort 重建,就推荐 Any,迁移范围应覆盖所有有证据支持的用户可见行为。 @@ -1468,18 +2013,9 @@ def _analysis_prompt( 不要只输出错误码、框架术语或“未找到可执行方式”之类没有行动建议的表述。 - warnings 要具体描述缺失材料及影响,不得把可在迁移或部署阶段补齐的条件写成阻塞项。 -## 输出协议 - -- 顶层字段必须且只能是:schema_version、status、attempt、input_sha256、 - summary、frameworks、recommended、entries、boundary、assumptions、questions、warnings。 -- recommendation_ready 和 needs_input 的 recommended 必须且只能包含 - framework、entry、reason;unsupported 的 recommended 必须为 null。 - entries 必须与 recommended 同级,绝不能嵌套在 recommended 中。 -- Dify/Any 必须输出 `recommended.entry=null` 和顶层 `entries=[]`。 -- 输出前自行核对字段层级、必填字段、枚举值和问题状态约束;不要在响应中描述核对过程。 -- 最终响应必须严格符合提供的 JSON Schema,只输出一个 JSON 对象,不要输出 - Markdown 围栏、解释或额外文字。 +{interactive_context}{protocol_context} +{retry_context} ## 用户补充要求 {instruction or "用户未补充额外要求。"} @@ -1630,65 +2166,332 @@ def is_macos_metadata(path): return "set -euo pipefail\npython3 - <<'PY'\n" + script.strip() + "\nPY" -def _codex_event_extractor() -> str: - return ( - "import json,sys\n" - "message = None\n" - "with open(sys.argv[1], encoding='utf-8') as events:\n" - " for line in events:\n" - " try:\n" - " event = json.loads(line)\n" - " except (TypeError, ValueError):\n" - " continue\n" - " item = event.get('item')\n" - " if (\n" - " event.get('type') == 'item.completed'\n" - " and isinstance(item, dict)\n" - " and item.get('type') == 'agent_message'\n" - " and isinstance(item.get('text'), str)\n" - " and item['text'].strip()\n" - " ):\n" - " message = item['text']\n" - "if message is None:\n" - " raise SystemExit('Codex agent_message event is missing')\n" - "with open(sys.argv[2], 'w', encoding='utf-8') as output:\n" - " output.write(message)\n" +def _analysis_result_extractor_script() -> str: + """Return the in-Sandbox script that recovers one analysis result object. + + Codex interleaves progress updates with its final answer, may deliver that answer as + commentary, and may wrap it in Markdown. Selecting the last agent message blindly + therefore fails whenever a progress update arrives last, which is exactly what the + analysis protocol asks Codex to emit. This script instead scans every agent message + from newest to oldest and keeps the first JSON object that satisfies the analysis + contract, so non-contract progress text is skipped instead of being fatal. + """ + return f""" +import json +import sys + +_CONTRACT_KEYS = {list(_ANALYSIS_CONTRACT_KEYS)!r} +_CONTRACT_STATUSES = {list(_ANALYSIS_CONTRACT_STATUSES)!r} +_NEWLINE = chr(10) + + +def _objects(text): + stripped = text.strip() + try: + value = json.loads(stripped) + except ValueError: + pass + else: + if isinstance(value, dict): + yield value + for block in stripped.split("```")[1::2]: + body = block.split(_NEWLINE, 1)[1] if _NEWLINE in block else "" + try: + value = json.loads(body.strip()) + except ValueError: + continue + if isinstance(value, dict): + yield value + decoder = json.JSONDecoder() + for index, character in enumerate(stripped): + if character != "{{": + continue + try: + value, _ = decoder.raw_decode(stripped[index:]) + except ValueError: + continue + if isinstance(value, dict): + yield value + + +def _contract(value): + if not isinstance(value, dict): + return None + if value.get("status") not in _CONTRACT_STATUSES: + return None + if any(key not in value for key in _CONTRACT_KEYS): + return None + summary = value.get("summary") + if not isinstance(summary, str) or not summary.strip(): + return None + return value + + +def main(argv): + if len(argv) < 3: + raise SystemExit("usage: extractor [diagnostics]") + answers = [] + commentary = [] + with open(argv[1], encoding="utf-8") as events: + for line in events: + try: + event = json.loads(line) + except (TypeError, ValueError): + continue + if not isinstance(event, dict) or event.get("type") != "item.completed": + continue + item = event.get("item") + if not isinstance(item, dict) or item.get("type") != "agent_message": + continue + text = item.get("text") + if not isinstance(text, str) or not text.strip(): + continue + if item.get("phase") == "commentary": + commentary.append(text) + else: + answers.append(text) + reason = ( + "no_agent_message" + if not answers and not commentary + else "no_contract_object" ) + found = None + for text in list(reversed(answers)) + list(reversed(commentary)): + for value in _objects(text): + contract = _contract(value) + if contract is not None: + found = contract + break + if found is not None: + break + if found is not None: + reason = "extracted" + if len(argv) > 3: + with open(argv[3], "w", encoding="utf-8") as diagnostics: + json.dump( + {{ + "reason": reason, + "answer_messages": len(answers), + "commentary_messages": len(commentary), + }}, + diagnostics, + ensure_ascii=False, + ) + if found is None: + raise SystemExit("Codex analysis result is unavailable: " + reason) + with open(argv[2], "w", encoding="utf-8") as output: + json.dump(found, output, ensure_ascii=False) -def _start_analysis_command(task_id: str, attempt: int) -> str: - running_status = { +if __name__ == "__main__": + main(sys.argv) +""" + + +def _analysis_running_status(attempt: int) -> dict[str, object]: + """The analysis status while Codex works, shared by both analysis drivers.""" + return { "schema_version": 1, "attempt": attempt, "state": "analyzing", "message": "正在分析项目框架、入口与迁移边界", } - ready_status = { + + +def _clear_analysis_status_command() -> str: + """Return the command that drops driver state before a takeover start.""" + return "\n".join( + [ + "set -euo pipefail", + f"rm -f {shlex.quote(_ANALYSIS_STATUS_PATH)}", + f"rm -f {shlex.quote(_ANALYSIS_DRIVER_PATH)}", + ] + ) + + +def _analysis_driver_marker( + *, + driver: str, + attempt: int, + input_sha256: str = "", + started_at: float | None = None, + heartbeat_at: float | None = None, + owner_process: str = "", + state: str = _ANALYSIS_DRIVER_RUNNING, +) -> dict[str, object]: + """Describe which driver owns the analysis attempt currently in flight.""" + started = time.time() if started_at is None else started_at + return { "schema_version": 1, + "driver": driver, + "state": state, "attempt": attempt, - "state": "ready", - "message": "项目分析完成,请确认迁移方式", + "input_sha256": input_sha256, + "started_at": started, + "heartbeat_at": started if heartbeat_at is None else heartbeat_at, + "owner_process": owner_process, } - needs_input_status = { + + +def _delivery_turn_marker( + *, + state: str, + attempts: int = 1, + verdict: bool | None = None, + started_at: float | None = None, + heartbeat_at: float | None = None, +) -> dict[str, object]: + """Describe the Studio worker that owns the closing delivery turn. + + ``verdict`` says whether the turn that ended published a report, so a delivery that + was already tried is not retried on every later read of the same task. + """ + started = time.time() if started_at is None else started_at + return { "schema_version": 1, - "attempt": attempt, - "state": "needs_input", - "message": "需要补充少量信息后继续分析", + "driver": _DELIVERY_TURN_DRIVER, + "state": state, + "attempts": attempts, + "verdict": verdict, + "started_at": started, + "heartbeat_at": started if heartbeat_at is None else heartbeat_at, + "owner_process": _STUDIO_PROCESS_ID, } - failed_status = { + + +def _delivery_recovery_marker( + *, + state: str, + attempts: int = 1, + verdict: bool | None = None, + files: int | None = None, + started_at: float | None = None, + heartbeat_at: float | None = None, +) -> dict[str, object]: + """Describe the Studio worker that rebuilds a delivery the CLI never packaged. + + ``verdict`` records whether that attempt produced a delivery, so a task whose + packaging cannot be rebuilt is written off once instead of on every later read. + """ + started = time.time() if started_at is None else started_at + return { "schema_version": 1, - "attempt": attempt, - "state": "failed", - "message": "项目分析未完成,请查看日志后重试", - "error": { - "code": "MIGRATION_ANALYSIS_FAILED", - "message": "Codex 未能完成只读项目分析。", - "retryable": False, - }, + "driver": _DELIVERY_RECOVERY_DRIVER, + "state": state, + "attempts": attempts, + "verdict": verdict, + "files": files, + "started_at": started, + "heartbeat_at": started if heartbeat_at is None else heartbeat_at, + "owner_process": _STUDIO_PROCESS_ID, } - start_failed_status = { - "schema_version": 1, - "attempt": attempt, + + +def _recovery_verdict(stdout: str) -> dict[str, object] | None: + """The JSON line the rebuild program answers with, if it answered with one.""" + for line in reversed(stdout.strip().splitlines()): + candidate = line.strip() + if not candidate.startswith("{"): + continue + try: + value = json.loads(candidate) + except ValueError: + continue + if isinstance(value, dict): + return value + return None + + +def _delivery_prompt( + *, + task_id: str, + framework: str, + expected_state: str, + exit_code: object, +) -> str: + """The instructions for the turn that closes one finished delivery.""" + logs = f"{MIGRATION_ROOT}/work/agentic/logs" + exit_code_text = str(exit_code) if exit_code is not None else "未知" + return "\n".join( + [ + "# 迁移交付收尾", + "", + "沙箱里的迁移命令已经结束,现在由你核对这次迁移实际交付了什么,", + "并把结论发布给用户。交付状态由 AgentKit CLI 决定,你只负责解释它。", + "", + f"- 运行 ID:{task_id}", + f"- 迁移框架:{framework}(agentic)", + f"- 迁移命令退出码:{exit_code_text}", + f"- 这次交付的状态已经确定为:{expected_state}", + "", + "## 证据文件(沙箱内绝对路径)", + f"- 交付状态:`{_DELIVERY_STATUS_PATH}`", + f"- 产物清单:`{_DELIVERY_RESULT_PATH}`", + f"- 交付产物:`{_DELIVERY_ARTIFACT_PATH}`", + f"- 迁移任务日志:`{logs}/task.log`", + f"- 校验日志:`{logs}/validation-attempt-1.log`", + f"- Codex 事件流:`{logs}/codex-attempt-1.jsonl`", + f"- 迁移命令日志:`{MIGRATION_ROOT}/diagnostics/migration/migration.log`", + "", + "## 执行顺序", + "1. 读上面的证据文件,弄清这次交付的结果:产物包含什么、有哪些提示、", + " 如果是失败,失败发生在哪一步(Codex 尝试、校验、打包)。", + f"2. 调用 `{ARTIFACT_TOOL_NAME}`,参数 `path` 固定为 `{ARTIFACT_PATH}`,", + " 由 Studio 读取并核对产物字节。交付失败时跳过这一步。", + f"3. 调用 `{DELIVERY_TOOL_NAME}` 提交结论,参数严格按给定 Schema:", + f" - `state` 必须等于 {expected_state},其它取值会被拒绝;", + " - `message` 是给用户看的一句中文结论:成功时说清产物内容,", + " 失败时说清失败在哪一步、日志里的关键证据、用户下一步可以做什么;", + " - `warnings` 是用户需要知道的迁移提示,没有就留空数组。", + "", + "## 约束", + "- 不要修改产物、不要重跑迁移、不要执行会改变沙箱状态的命令。", + "- 失败原因如果只有用户能提供(例如缺失的模型密钥、部署目标、是否接受降级),", + " 可以调用 `askUser` 提问,然后按回答给出结论。", + "- 不要输出 Markdown 表格,不要贴大段日志原文。", + ] + ) + + +def _start_analysis_command(task_id: str, attempt: int) -> str: + running_status = _analysis_running_status(attempt) + ready_status = { + "schema_version": 1, + "attempt": attempt, + "state": "ready", + "message": "项目分析完成,请确认迁移方式", + } + needs_input_status = { + "schema_version": 1, + "attempt": attempt, + "state": "needs_input", + "message": "需要补充少量信息后继续分析", + } + failed_status = { + "schema_version": 1, + "attempt": attempt, + "state": "failed", + "message": "项目分析未完成,请查看日志后重试", + "error": { + "code": "MIGRATION_ANALYSIS_FAILED", + "message": "Codex 未能完成只读项目分析。", + "retryable": False, + }, + } + protocol_failed_status = { + "schema_version": 1, + "attempt": attempt, + "state": "failed", + "message": "Codex 未返回可解析的分析结果,请重试", + "error": { + "code": "MIGRATION_ANALYSIS_RESULT_MISSING", + "message": "Codex 未产出符合分析协议的 JSON 结果。", + "retryable": True, + }, + } + start_failed_status = { + "schema_version": 1, + "attempt": attempt, "state": "failed", "message": "项目分析启动失败,请新建迁移后重试", "error": { @@ -1709,7 +2512,7 @@ def _start_analysis_command(task_id: str, attempt: int) -> str: }, } result_tmp = f"{_ANALYSIS_RESULT_PATH}.{attempt}.tmp" - log_path = f"{MIGRATION_ROOT}/diagnostics/analysis/attempt-{attempt}.log" + log_path = _analysis_activity_path(attempt) pid_path = f"{MIGRATION_ROOT}/control/analysis.pid" lock_path = f"{MIGRATION_ROOT}/control/analysis-start-{attempt}.lock" validate_json = shlex.quote( @@ -1723,22 +2526,49 @@ def _start_analysis_command(task_id: str, attempt: int) -> str: "import json,sys; " f"raise SystemExit(0 if json.load(open(sys.argv[1])).get('attempt') == {attempt} else 1)" ) - extract_agent_message = shlex.quote(_codex_event_extractor()) + extract_analysis_result = shlex.quote(_analysis_result_extractor_script()) + retry_log_path = ( + f"{MIGRATION_ROOT}/diagnostics/analysis/attempt-{attempt}-retry.log" + ) + extraction_diagnostics = f"{_ANALYSIS_EXTRACTION_DIAGNOSTICS_PATH}.{attempt}" inner = "\n".join( [ "set +e", + "run_analysis() {", ( - "codex exec --json --sandbox read-only --skip-git-repo-check " + " codex exec --json --sandbox read-only --skip-git-repo-check " f"--cd {shlex.quote(_PROJECT_PATH)} " f"--output-schema {shlex.quote(_ANALYSIS_SCHEMA_PATH)} " - f"- < {shlex.quote(_ANALYSIS_PROMPT_PATH)} " - f"> {shlex.quote(log_path)} 2>&1" + '- < "$1" > "$2" 2>&1' + ), + "}", + ( + f"run_analysis {shlex.quote(_ANALYSIS_PROMPT_PATH)} " + f"{shlex.quote(log_path)}" ), "code=$?", + "extracted=0", + ( + f"if python3 -c {extract_analysis_result} " + f"{shlex.quote(log_path)} {shlex.quote(result_tmp)} " + f"{shlex.quote(extraction_diagnostics)}; then extracted=1; fi" + ), + # 协议重试:上一轮回复无法作为分析结果读取时,在同一项目内再要一次纯 + # JSON 结论,避免一次格式偏差就让整个迁移任务失败。 + 'if [ "$extracted" -ne 1 ]; then', + ( + f" run_analysis {shlex.quote(_ANALYSIS_RETRY_PROMPT_PATH)} " + f"{shlex.quote(retry_log_path)}" + ), + " code=$?", ( - f"if python3 -c {extract_agent_message} " - f"{shlex.quote(log_path)} {shlex.quote(result_tmp)} && " - f"python3 -c {validate_json} " + f" if python3 -c {extract_analysis_result} " + f"{shlex.quote(retry_log_path)} {shlex.quote(result_tmp)} " + f"{shlex.quote(extraction_diagnostics)}; then extracted=1; fi" + ), + "fi", + ( + f'if [ "$extracted" -eq 1 ] && python3 -c {validate_json} ' f"{shlex.quote(result_tmp)}; then" ), ( @@ -1760,7 +2590,11 @@ def _start_analysis_command(task_id: str, attempt: int) -> str: "else", ' if [ "$code" -eq 0 ]; then code=1; fi', f" rm -f {shlex.quote(result_tmp)}", - f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, failed_status)}", + ' if [ "$extracted" -eq 1 ]; then', + f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, failed_status)}", + " else", + f" {_atomic_json_command(_ANALYSIS_STATUS_PATH, protocol_failed_status)}", + " fi", "fi", "finished_at=$(python3 -c 'import time; print(int(time.time()))')", ( @@ -1862,6 +2696,16 @@ def _migration_instruction( "Treat missing source credentials or environment variables as explicit ", "deployment requirements or validation warnings; do not rewrite runtime ", "behavior merely to make validation pass.", + "Treat the deterministic migration contract as blocking: while ", + "validation_findings.json still lists a fatal or repairable finding, the ", + "migration is not finished and no completion may be reported. Fix those ", + "findings in the same turn and rerun scripts/validate_runtime.sh until it ", + "passes; a degraded finding may remain only when the report states it ", + "honestly.", + "Never rewrite the .agentkit/agentkit.yaml that ak init recorded: its ", + "sha256 is the config baseline that contract checks, and the application ", + "name comes from the confirmed migration settings, not from the source ", + "project.", "Keep the generated project compatible with AgentkitAgentServerApp. ", "Never replace or monkeypatch Agent/root_agent run or run_async methods; ", "configure the Agent through supported constructor arguments and callbacks.", @@ -1947,6 +2791,156 @@ def _ak_command( return " ".join(shlex.quote(item) for item in common) +_MIGRATION_DRIVER_TEMPLATE = '''"""Publish the migration driver lease and the artifact manifest. + +Written into the Sandbox by the launch script and run twice: in the background to +keep the heartbeat fresh while the migration CLI works, and once after it exits to +publish the finished record with the artifact digest. + +The background copy also watches the CLI it speaks for. A migration that loses only +its CLI -- the launch shell survives, the agent's own work is already on disk -- would +otherwise keep a fresh heartbeat forever, and a fresh heartbeat is exactly what tells +Studio the run is still alive. Watching the pid turns that into a `lost` record, which +is the one state Studio can still rebuild a delivery from. +""" + +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +__HEARTBEAT_SECONDS__ + +path = Path(sys.argv[1]) +artifact = Path(sys.argv[2]) +run_id = sys.argv[3] +mode = sys.argv[4] +cli_pid_path = Path(sys.argv[5]) + + +def publish(value): + temporary = path.with_name(path.name + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") + os.replace(temporary, path) + + +def lease(state, heartbeat_at, finished_at=None, exit_code=None, artifact_entry=None): + return { + "schema_version": 1, + "run_id": run_id, + "state": state, + "heartbeat_at": heartbeat_at, + "finished_at": finished_at, + "exit_code": exit_code, + "artifact": artifact_entry, + } + + +def manifest(): + """Return the artifact descriptor, or None when the CLI produced no archive.""" + if not artifact.is_file(): + return None + digest = hashlib.sha256() + size = 0 + with artifact.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + digest.update(chunk) + return {"path": artifact.name, "sha256": digest.hexdigest(), "size": size} + + +def cli_is_gone(): + """Whether the CLI this heartbeat speaks for has left the Sandbox.""" + try: + cli_pid = int(cli_pid_path.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return False + try: + os.kill(cli_pid, 0) + except ProcessLookupError: + return True + except OSError: + return False + return False + + +if mode == "heartbeat": + publish(lease("running", int(time.time()))) + while True: + time.sleep(HEARTBEAT_SECONDS) + if cli_is_gone(): + publish(lease("lost", int(time.time()))) + break + publish(lease("running", int(time.time()))) +else: + now = int(time.time()) + publish( + lease( + "finished", + now, + finished_at=now, + exit_code=int(sys.argv[6]), + artifact_entry=manifest(), + ) + ) +''' + + +def _migration_driver_script() -> str: + """The Sandbox-side script that publishes the delivery driver lease.""" + return _MIGRATION_DRIVER_TEMPLATE.replace( + "__HEARTBEAT_SECONDS__", + f"HEARTBEAT_SECONDS = {_MIGRATION_DRIVER_HEARTBEAT_SECONDS}", + ) + + +def _migration_codex_shim_lines() -> list[str]: + """Install the Sandbox `codex` shim that the migration CLI picks up on PATH.""" + return [ + f"studio_codex_shim_dir={shlex.quote(_MIGRATION_CODEX_SHIM_DIR)}", + 'mkdir -p "$studio_codex_shim_dir"', + f"cat > {shlex.quote(_MIGRATION_CODEX_SHIM_PATH)} <<'STUDIO_CODEX_SHIM'", + _codex_shim_source().rstrip("\n"), + "STUDIO_CODEX_SHIM", + # 垫片只要求一个能连 app-server 的解释器,取沙箱里第一个带 websockets 的。 + "studio_codex_shim_python=$(command -v python3)", + 'for studio_python_candidate in /usr/bin/python3 "$studio_codex_shim_python"; do', + ' if "$studio_python_candidate" -c "import websockets" >/dev/null 2>&1; then', + ' studio_codex_shim_python="$studio_python_candidate"', + " break", + " fi", + "done", + "printf '%s\\n' \"$studio_codex_shim_python\" > " + f"{shlex.quote(_MIGRATION_CODEX_SHIM_PYTHON_PATH)}", + f"cat > {shlex.quote(_MIGRATION_CODEX_SHIM_WRAPPER_PATH)} <<'STUDIO_CODEX_WRAPPER'", + "#!/bin/sh", + "set -eu", + 'studio_codex_shim_dir="${STUDIO_CODEX_SHIM_DIR:-$(dirname "$0")}"', + 'exec "$(cat "$studio_codex_shim_dir/python")" ' + '"$studio_codex_shim_dir/studio-codex-shim.py" "$@"', + "STUDIO_CODEX_WRAPPER", + f"chmod 0755 {shlex.quote(_MIGRATION_CODEX_SHIM_WRAPPER_PATH)} " + f"{shlex.quote(_MIGRATION_CODEX_SHIM_PATH)}", + 'export STUDIO_CODEX_SHIM_DIR="$studio_codex_shim_dir"', + "export STUDIO_MIGRATION_SHIM_STATE=" + f"{shlex.quote(_MIGRATION_CODEX_SHIM_STATE_PATH)}", + # 真正的 codex 必须在改 PATH 之前解析出来:垫片回退时要用它。同一个 shell + # 里重复安装时,PATH 开头已经是垫片,此时保留上一次解析出的真 codex。 + "studio_codex_shim_real=$(command -v codex)", + f'if [ "$studio_codex_shim_real" = {shlex.quote(_MIGRATION_CODEX_SHIM_WRAPPER_PATH)} ];', + ' then studio_codex_shim_real=""; fi', + 'if [ -n "$studio_codex_shim_real" ]; then', + ' export STUDIO_MIGRATION_REAL_CODEX="$studio_codex_shim_real"', + "fi", + 'export PATH="$studio_codex_shim_dir:$PATH"', + ] + + def _start_migration_command( task_id: str, confirmation: dict[str, object], @@ -1989,16 +2983,47 @@ def _start_migration_command( ), ] ) + driver = " ".join( + [ + "python3", + shlex.quote(_MIGRATION_DRIVER_SCRIPT_PATH), + shlex.quote(_MIGRATION_DRIVER_PATH), + shlex.quote(_DELIVERY_ARTIFACT_PATH), + shlex.quote(task_id), + ] + ) inner = "\n".join( [ "set +e", + *_migration_codex_shim_lines(), + ( + f"cat > {shlex.quote(_MIGRATION_DRIVER_SCRIPT_PATH)} " + "<<'STUDIO_MIGRATION_DRIVER'" + ), + _migration_driver_script(), + "STUDIO_MIGRATION_DRIVER", + f"{driver} heartbeat {shlex.quote(_MIGRATION_CLI_PID_PATH)} &", + "driver_pid=$!", "(", "set -e", *validation_model_env, *structured_copy, cli, - f") > {shlex.quote(log_path)} 2>&1", + f") > {shlex.quote(log_path)} 2>&1 &", + "cli_pid=$!", + ( + f"printf '%s\\n' \"$cli_pid\" > " + f"{shlex.quote(_MIGRATION_CLI_PID_PATH)}.tmp" + ), + ( + f"mv {shlex.quote(_MIGRATION_CLI_PID_PATH)}.tmp " + f"{shlex.quote(_MIGRATION_CLI_PID_PATH)}" + ), + 'wait "$cli_pid"', "code=$?", + 'kill "$driver_pid" 2>/dev/null', + 'wait "$driver_pid" 2>/dev/null', + f'{driver} finish {shlex.quote(_MIGRATION_CLI_PID_PATH)} "$code"', "finished_at=$(python3 -c 'import time; print(int(time.time()))')", ( f'printf \'%s\\n\' "{{\\"schema_version\\":1,' @@ -2132,6 +3157,51 @@ def _stop_command() -> str: ) +def _normalized_analysis_document( + value: object, + *, + expected_attempt: int, + expected_input_sha256: str, +) -> object: + """Turn whatever was stored into the state-file contract. + + The app-server driver stores the assembled document. The scripted fallback stores + the JSON document Codex wrote, which carries the judgement only, so it is accepted + here with no detection report: that path's shape is constrained while Codex decodes + it, and the destructive verdict still has to bring its own evidence. + """ + if isinstance(value, dict) and "schema_version" in value and "boundary" in value: + recommended = value.get("recommended") + if ( + "entries" not in value + and isinstance(recommended, dict) + and "entries" in recommended + ): + recommended = dict(recommended) + value = { + **value, + "recommended": recommended, + "entries": recommended.pop("entries"), + } + return { + **value, + "attempt": expected_attempt, + "input_sha256": expected_input_sha256, + } + if not is_model_document(value): + raise MigrationContractError( + "analysis document is neither a state file nor a judgement" + ) + assert isinstance(value, dict) + document, _ = build_analysis_result( + KIND_BY_STATUS[str(value["status"])], + value, + attempt=expected_attempt, + input_sha256=expected_input_sha256, + ) + return document + + class MigrationService: """Derive task state from remote Sessions and files without a local repository.""" @@ -2143,6 +3213,14 @@ def __init__( ) -> None: self._gateway = gateway self._clock = clock + # 进程内的后台分析驱动,键为 (session_id, attempt),避免重复起同一轮分析。 + self._analysis_drivers: dict[tuple[str, int], threading.Thread] = {} + # 正在等待用户回答的分析提问,由 HTTP 线程投递答案。 + self._analysis_input = AnalysisInputRegistry() + # 进程内的交付收尾回合,键为 session_id,避免同一交付重复收尾。 + self._delivery_turns: dict[str, threading.Thread] = {} + # 进程内的交付复原,键为 session_id,避免同一交付重复打包。 + self._delivery_recoveries: dict[str, threading.Thread] = {} @staticmethod def _translate(error: MigrationGatewayError) -> MigrationError: @@ -2259,6 +3337,25 @@ def _read( except MigrationGatewayError as error: raise self._translate(error) from error + def _read_detection( + self, + session: MigrationSandboxSession, + ) -> dict[str, object]: + """Read the model-free detection report. + + A missing or unreadable report never fails analysis: it degrades to "inventory + unknown", which keeps the verdict gate open instead of judging the project. + """ + try: + value = self._read_json(session, _DETECTION_PATH, optional=True) + if value is not None: + return validate_detection_report(value) + except (MigrationError, MigrationContractError): + logger.warning( + "Studio migration detection report is unusable; continuing without it" + ) + return _empty_detection_report() + def _read_json( self, session: MigrationSandboxSession, @@ -2301,25 +3398,13 @@ def _read_analysis( ) try: value = json.loads(content) - if isinstance(value, dict): - recommended = value.get("recommended") - if ( - "entries" not in value - and isinstance(recommended, dict) - and "entries" in recommended - ): - recommended = dict(recommended) - value = { - **value, - "recommended": recommended, - "entries": recommended.pop("entries"), - } - value = { - **value, - "attempt": expected_attempt, - "input_sha256": expected_input_sha256, - } - analysis = validate_analysis_result(value) + analysis = validate_analysis_result( + _normalized_analysis_document( + value, + expected_attempt=expected_attempt, + expected_input_sha256=expected_input_sha256, + ) + ) except (UnicodeDecodeError, ValueError, MigrationContractError) as error: raise MigrationError( "MIGRATION_ANALYSIS_INVALID", @@ -2616,112 +3701,1531 @@ def _process_exit_is_settling(self, process_exit: dict[str, object]) -> bool: finished_at = _timestamp(process_exit.get("finished_at")) if finished_at is None: return False - age = self._clock() - finished_at - return -_REMOTE_CLOCK_SKEW_SECONDS <= age < _REMOTE_STATE_SETTLE_SECONDS + age = self._clock() - finished_at + return -_REMOTE_CLOCK_SKEW_SECONDS <= age < _REMOTE_STATE_SETTLE_SECONDS + + def _read_migration_driver( + self, + session: MigrationSandboxSession, + ) -> dict[str, object] | None: + """Read the delivery driver lease, tolerating a missing or damaged record. + + The lease is control-plane bookkeeping rather than a delivery contract, so a + record that cannot be read or validated is reported and ignored instead of + making every later read of the task fail. + """ + try: + driver = self._read_json( + session, + _MIGRATION_DRIVER_PATH, + optional=True, + ) + except MigrationError: + logger.warning( + "Studio migration driver lease is unreadable task_id=%s", + session.task_id, + ) + return None + if driver is None: + return None + try: + return validate_migration_driver( + driver, + expected_run_id=session.task_id, + ) + except MigrationContractError as error: + logger.warning( + "Studio migration driver lease is invalid task_id=%s error=%s", + session.task_id, + error, + ) + return None + + def _migration_driver_lost( + self, + driver: dict[str, object] | None, + ) -> bool: + """Whether the delivery driver behind a task is gone for good. + + The heartbeat either says so itself, having watched the CLI it speaks for leave + the Sandbox, or stops advancing because the process group it lived in is gone. + Either way nothing will write the delivery state the task waits for. + """ + if not isinstance(driver, dict) or driver.get("state") not in { + "running", + "lost", + }: + return False + if driver.get("state") == "lost": + return True + heartbeat = driver.get("heartbeat_at") + if isinstance(heartbeat, bool) or not isinstance(heartbeat, int): + return False + return self._clock() - float(heartbeat) >= _MIGRATION_DRIVER_STALE_SECONDS + + @staticmethod + def _validate_request( + existing: dict[str, object], + expected: dict[str, object], + ) -> None: + MigrationService._validated_request( + existing, + str(expected["task_id"]), + ) + if ( + existing.get("source_file_name") != expected["source_file_name"] + or existing.get("instruction") != expected["instruction"] + or existing.get("model_id") != expected.get("model_id") + or existing.get("evaluation") != expected.get("evaluation") + or existing.get("session_ttl_seconds") != expected["session_ttl_seconds"] + ): + raise MigrationError( + "MIGRATION_REQUEST_CONFLICT", + "该迁移会话 ID 已用于其他迁移请求。", + status_code=409, + retryable=False, + ) + + def upload_source( + self, + task_id: str, + owner_id: str, + content: bytes, + ) -> dict[str, object]: + summary = validate_source_archive(content) + session = self._session(task_id, owner_id) + current = self.get_task(task_id, owner_id) + if current["state"] != "awaiting_upload": + raise MigrationError( + "MIGRATION_SOURCE_LOCKED", + "分析开始后不能修改项目附件;请等待完成或终止当前迁移。", + status_code=409, + ) + digest = hashlib.sha256(content).hexdigest() + accepted_source = self._read_json( + session, + _SOURCE_STATUS_PATH, + optional=True, + ) + if accepted_source is not None: + accepted_source = self._validated_source(accepted_source) + accepted_digest = accepted_source.get("sha256") + if accepted_digest != digest: + raise MigrationError( + "MIGRATION_SOURCE_LOCKED", + "项目附件已锁定;只能使用原 ZIP 继续启动分析。", + status_code=409, + ) + else: + candidate = f"{MIGRATION_ROOT}/input/.source-{digest}.zip" + self._put( + session, + candidate, + content, + media_type="application/zip", + ) + self._execute( + session, + _prepare_source_command( + candidate_path=candidate, + source_sha256=digest, + source_size=len(content), + summary=summary, + ), + operation="prepare_source", + timeout_seconds=_FILE_OPERATION_TIMEOUT_SECONDS, + ) + # The verified facts the analysis may rely on are computed here, in + # Studio's own process, so no model ever authors the file inventory the + # unsupported verdict is measured against. + self._put( + session, + _DETECTION_PATH, + _json_bytes(_detection_report(content)), + media_type="application/json", + ) + request = self._read_json(session, _REQUEST_PATH, optional=True) + if request is None: + raise MigrationError( + "MIGRATION_REQUEST_MISSING", + "迁移请求文件不存在。", + status_code=502, + ) + request = self._validated_request(request, task_id) + self._put( + session, + _ANALYSIS_SCHEMA_PATH, + _json_bytes(_analysis_schema()), + media_type="application/json", + ) + detection = self._read_detection(session) + self._put( + session, + _ANALYSIS_PROMPT_PATH, + _analysis_prompt( + request, + attempt=1, + input_sha256=digest, + detection=detection, + ).encode("utf-8"), + media_type="text/markdown", + ) + self._put( + session, + _ANALYSIS_RETRY_PROMPT_PATH, + _analysis_prompt( + request, + attempt=1, + input_sha256=digest, + protocol_retry=True, + detection=detection, + ).encode("utf-8"), + media_type="text/markdown", + ) + if self._start_app_server_analysis( + session, + prompt=_analysis_prompt( + request, + attempt=1, + input_sha256=digest, + interactive=True, + detection=detection, + ), + attempt=1, + input_sha256=digest, + model_id=str(request.get("model_id") or ""), + ): + return self.get_task(task_id, owner_id) + self._start_scripted_analysis(session, task_id=task_id, attempt=1) + return self.get_task(task_id, owner_id) + + def _start_scripted_analysis( + self, + session: MigrationSandboxSession, + *, + task_id: str, + attempt: int, + clear_status: bool = False, + ) -> None: + """Run the analysis inside the Sandbox with ``codex exec``. + + The generated script owns its own background process, so Studio only launches + it. A takeover start (``clear_status``) first drops the state left by the + previous driver, because the script refuses to start when a status for the + same attempt already exists. + """ + if clear_status: + self._execute( + session, + _clear_analysis_status_command(), + operation="clear_analysis", + timeout_seconds=30, + ) + self._put( + session, + _ANALYSIS_DRIVER_PATH, + _json_bytes( + _analysis_driver_marker( + driver=_ANALYSIS_DRIVER_SCRIPT, + attempt=attempt, + owner_process=_STUDIO_PROCESS_ID, + ) + ), + media_type="application/json", + ) + self._execute( + session, + _start_analysis_command(task_id, attempt), + operation="start_analysis", + timeout_seconds=30, + ) + + def _start_app_server_analysis( + self, + session: MigrationSandboxSession, + *, + prompt: str, + attempt: int, + input_sha256: str, + model_id: str = "", + timeout_seconds: float = _ANALYSIS_TURN_TIMEOUT_SECONDS, + ) -> bool: + """Analyse through the Sandbox app-server on a Studio background worker. + + The turn must not run inside the HTTP request: an upload that waits for Codex + would be cut off by the gateway on a long analysis. The worker keeps the same + file contract as the scripted path, so ``get_task`` reads both drivers alike. + Returns ``False`` when the caller must start the scripted path instead. + """ + if not app_server_analysis_enabled(): + return False + key = (session.session_id, attempt) + running = self._analysis_drivers.get(key) + if running is not None and running.is_alive(): + return True + started_at = time.time() + self._put( + session, + _ANALYSIS_DRIVER_PATH, + _json_bytes( + _analysis_driver_marker( + driver=_ANALYSIS_DRIVER_APP_SERVER, + attempt=attempt, + input_sha256=input_sha256, + started_at=started_at, + owner_process=_STUDIO_PROCESS_ID, + ) + ), + media_type="application/json", + ) + self._put( + session, + _ANALYSIS_STATUS_PATH, + _json_bytes(_analysis_running_status(attempt)), + media_type="application/json", + ) + worker = threading.Thread( + target=self._app_server_analysis_worker, + args=(session, attempt, input_sha256, prompt, model_id, timeout_seconds), + name=f"migration-analysis-{attempt}", + daemon=True, + ) + self._analysis_drivers[key] = worker + try: + worker.start() + except Exception: # noqa: BLE001 - a failed start must fall back to the script + self._analysis_drivers.pop(key, None) + logger.exception( + "Studio migration analysis worker could not start task_id=%s", + session.task_id, + ) + return False + return True + + def _app_server_analysis_worker( + self, + session: MigrationSandboxSession, + attempt: int, + input_sha256: str, + prompt: str, + model_id: str, + timeout_seconds: float, + ) -> None: + """Run one app-server turn and persist it, or hand over to the script.""" + key = (session.session_id, attempt) + diagnostics: dict[str, object] = {} + try: + try: + analysis = asyncio.run( + self._run_app_server_turn( + session, + attempt=attempt, + input_sha256=input_sha256, + prompt=prompt, + model_id=model_id, + timeout_seconds=timeout_seconds, + diagnostics=diagnostics, + ) + ) + except MigrationAnalysisUnavailable as error: + logger.warning( + "Studio migration app-server analysis unavailable task_id=%s " + "attempt=%s error_type=%s", + session.task_id, + attempt, + type(error).__name__, + ) + analysis = None + self._persist_analysis_outcome( + session, attempt=attempt, outcome=diagnostics + ) + if analysis is None and diagnostics.get("events"): + # The turn reached Codex and Codex produced output, but nothing + # acceptable arrived. That is a result-production problem, not a + # verdict about the project, so Studio concludes for itself instead of + # spending another model run: the analysis still has to produce + # something the user can act on. + logger.warning( + "Studio migration analysis turn delivered no acceptable result; " + "persisting the conservative conclusion task_id=%s attempt=%s " + "refusals=%s", + session.task_id, + attempt, + len(diagnostics.get("refusals") or []), + ) + try: + conservative, notes = _conservative_analysis( + self._read_detection(session), + attempt=attempt, + input_sha256=input_sha256, + reason=_analysis_failure_reason(diagnostics), + ) + except Exception: # noqa: BLE001 - never lose the task to a fallback bug + logger.exception( + "Studio migration conservative analysis failed task_id=%s " + "attempt=%s", + session.task_id, + attempt, + ) + else: + logger.info( + "Studio migration conservative conclusion stored task_id=%s " + "attempt=%s notes=%s", + session.task_id, + attempt, + notes, + ) + self._persist_app_server_analysis( + session, + attempt=attempt, + analysis=conservative, + ) + return + if analysis is None: + # The turn can also end without ever delivering the contract, which is + # why the driver switches here as well; say so, or an operator only + # sees a scripted log with no explanation of where it came from. + logger.warning( + "Studio migration app-server analysis returned no result; " + "continuing with the scripted driver task_id=%s attempt=%s", + session.task_id, + attempt, + ) + self._start_scripted_analysis( + session, + task_id=session.task_id, + attempt=attempt, + clear_status=True, + ) + return + self._persist_app_server_analysis( + session, + attempt=attempt, + analysis=analysis, + ) + except Exception: # noqa: BLE001 - the worker must never kill the process + logger.exception( + "Studio migration app-server analysis worker failed task_id=%s " + "attempt=%s", + session.task_id, + attempt, + ) + finally: + if self._analysis_drivers.get(key) is threading.current_thread(): + self._analysis_drivers.pop(key, None) + + async def _run_app_server_turn( + self, + session: MigrationSandboxSession, + *, + attempt: int, + input_sha256: str, + prompt: str, + model_id: str, + timeout_seconds: float, + diagnostics: dict[str, object] | None = None, + ) -> dict[str, object] | None: + """Run the app-server turn while refreshing the background driver lease.""" + + async def beat() -> None: + warned = False + while True: + await asyncio.sleep(_ANALYSIS_DRIVER_HEARTBEAT_SECONDS) + try: + await asyncio.to_thread( + self._put, + session, + _ANALYSIS_DRIVER_PATH, + _json_bytes( + _analysis_driver_marker( + driver=_ANALYSIS_DRIVER_APP_SERVER, + attempt=attempt, + input_sha256=input_sha256, + owner_process=_STUDIO_PROCESS_ID, + ) + ), + media_type="application/json", + ) + except Exception as error: # noqa: BLE001 - lease refresh is advisory + if not warned: + warned = True + logger.warning( + "Studio migration analysis lease refresh failed " + "task_id=%s error_type=%s", + session.task_id, + type(error).__name__, + ) + + # The scripted driver's activity log is written inside the Sandbox by + # ``codex exec --json``; an app-server turn only exists on the wire, so its + # events are recorded into the very same file. One reader then serves both + # drivers, and the page shows what Codex is doing on either path. + activity = AnalysisActivityLog( + lambda content: self._put( + session, + _analysis_activity_path(attempt), + content, + media_type="text/plain", + ) + ) + # 用户在回合内作答的时间不算 Codex 的工作时间,从墙钟预算里扣除。 + waited_seconds = [0.0] + + async def questioner( + questions: tuple[dict[str, object], ...], + ) -> dict[str, tuple[str, ...]] | None: + """Publish one question set and wait for the page to answer it.""" + return await self._ask_user( + session, + questions=questions, + attempt=attempt, + window_seconds=_ANALYSIS_INPUT_WINDOW_SECONDS, + waited_seconds=waited_seconds, + ) + + heartbeat = asyncio.create_task(beat()) + flusher = asyncio.create_task(activity.run()) + try: + return await run_route_analysis( + endpoint=session.endpoint, + prompt=prompt, + cwd=_PROJECT_PATH, + attempt=attempt, + input_sha256=input_sha256, + model=model_id, + timeout_seconds=timeout_seconds, + event_sink=activity.record, + questioner=questioner, + detection=self._read_detection(session), + diagnostics=diagnostics, + idle_timeout_seconds=( + timeout_seconds + + _ANALYSIS_INPUT_WINDOW_SECONDS + + _ANALYSIS_INPUT_IDLE_MARGIN_SECONDS + ), + host_wait_seconds=lambda: waited_seconds[0], + ) + finally: + flusher.cancel() + with contextlib.suppress(asyncio.CancelledError): + await flusher + await activity.aclose() + heartbeat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat + + def _persist_analysis_outcome( + self, + session: MigrationSandboxSession, + *, + attempt: int, + outcome: dict[str, object], + ) -> None: + """Record what the model layer produced. Never fails the worker.""" + if not outcome: + return + try: + self._put( + session, + _ANALYSIS_OUTCOME_PATH, + _json_bytes({"schema_version": 1, "attempt": attempt, **outcome}), + media_type="application/json", + ) + except Exception: # noqa: BLE001 - diagnostics must not break analysis + logger.warning( + "Studio migration analysis outcome could not be stored task_id=%s " + "attempt=%s", + session.task_id, + attempt, + ) + + def _persist_app_server_analysis( + self, + session: MigrationSandboxSession, + *, + attempt: int, + analysis: dict[str, object], + ) -> None: + """Store the contract delivered by the dynamic tool and close the lease.""" + status = str(analysis.get("status") or "") + if status == "unsupported": + payload: dict[str, object] = { + "schema_version": 1, + "attempt": attempt, + "state": "failed", + "message": _ANALYSIS_UNSUPPORTED_MESSAGE, + "error": { + "code": "MIGRATION_ANALYSIS_UNSUPPORTED", + "message": "项目分析未找到可执行的迁移方式。", + "retryable": False, + }, + } + else: + payload = { + "schema_version": 1, + "attempt": attempt, + "state": "ready" if status == "recommendation_ready" else status, + "message": _ANALYSIS_STATUS_MESSAGES.get( + "ready" if status == "recommendation_ready" else status, + "项目分析已更新", + ), + } + self._put( + session, + _ANALYSIS_RESULT_PATH, + _json_bytes(analysis), + media_type="application/json", + ) + self._put( + session, + _ANALYSIS_STATUS_PATH, + _json_bytes(payload), + media_type="application/json", + ) + self._put( + session, + _ANALYSIS_DRIVER_PATH, + _json_bytes( + _analysis_driver_marker( + driver=_ANALYSIS_DRIVER_APP_SERVER, + attempt=attempt, + owner_process=_STUDIO_PROCESS_ID, + state=_ANALYSIS_DRIVER_DONE, + ) + ), + media_type="application/json", + ) + logger.info( + "Studio migration app-server analysis completed task_id=%s " + "attempt=%s status=%s", + session.task_id, + attempt, + status, + ) + + def recover_stalled_analysis(self, task_id: str, owner_id: str) -> bool: + """Hand a stalled app-server analysis back to the scripted driver. + + A Studio restart drops the worker that owned the turn while the task still + reads as analysing. The lease written by the worker says who owns it and how + fresh it is, so a request may take over once that lease goes stale. + """ + try: + session = self._session(task_id, owner_id) + marker = self._read_json( + session, + _ANALYSIS_DRIVER_PATH, + optional=True, + ) + except Exception as error: # noqa: BLE001 - recovery must never fail a read + logger.warning( + "Studio migration analysis recovery skipped task_id=%s error_type=%s", + task_id, + type(error).__name__, + ) + return False + if not isinstance(marker, dict): + return False + if str(marker.get("driver") or "") != _ANALYSIS_DRIVER_APP_SERVER: + return False + if str(marker.get("state") or _ANALYSIS_DRIVER_RUNNING) != ( + _ANALYSIS_DRIVER_RUNNING + ): + return False + attempt = marker.get("attempt") + if not isinstance(attempt, int) or attempt < 1: + return False + running = self._analysis_drivers.get((session.session_id, attempt)) + if running is not None and running.is_alive(): + return False + heartbeat = marker.get("heartbeat_at") + age = ( + time.time() - float(heartbeat) + if isinstance(heartbeat, (int, float)) + else _ANALYSIS_DRIVER_STALE_SECONDS + ) + if age < _ANALYSIS_DRIVER_STALE_SECONDS: + return False + logger.warning( + "Studio migration app-server analysis lease expired; restarting the " + "scripted driver task_id=%s attempt=%s", + task_id, + attempt, + ) + try: + self._start_scripted_analysis( + session, + task_id=task_id, + attempt=attempt, + clear_status=True, + ) + except Exception as error: # noqa: BLE001 - recovery must never fail a read + logger.warning( + "Studio migration analysis recovery failed task_id=%s error_type=%s", + task_id, + type(error).__name__, + ) + return False + return True + + async def _ask_user( + self, + session: MigrationSandboxSession, + *, + questions: tuple[dict[str, object], ...], + attempt: int, + window_seconds: float, + waited_seconds: list[float], + ) -> dict[str, tuple[str, ...]] | None: + """Publish one question set and wait for the page to answer it. + + The wait is host latency rather than Codex progress, so callers pass the + accumulator their turn uses to keep that time out of its own budget. + """ + pending = self._analysis_input.open( + session.session_id, + attempt=attempt, + questions=questions, + ) + loop = asyncio.get_running_loop() + started = loop.time() + try: + answers = await asyncio.to_thread( + pending.future.result, + window_seconds, + ) + # 线程里等的是 concurrent.futures.Future:3.10 跨回 asyncio 时它的 + # TimeoutError 会被换成 asyncio 自己的类(3.11+ 才同为内置类), + # 因此两种都收,别退回单个 TimeoutError。 + except (TimeoutError, asyncio.TimeoutError): + logger.info( + "Studio migration question timed out task_id=%s attempt=%s " + "window_seconds=%s", + session.task_id, + attempt, + window_seconds, + ) + answers = None + finally: + waited_seconds[0] += loop.time() - started + self._analysis_input.discard( + session.session_id, + request_id=pending.request_id, + ) + return answers + + def drive_delivery_turn( + self, + task_id: str, + owner_id: str, + *, + task: dict[str, object] | None = None, + ) -> bool: + """Close one settled delivery on a Studio app-server turn. + + Cheap enough for a watcher tick or a read: with the task payload in hand it only + looks at the delivery phase's own bookkeeping, and the turn itself runs on a + background worker because a Codex turn must never sit inside a request. + """ + if not delivery_app_server_enabled(): + return False + try: + session = self._session(task_id, owner_id) + target = self._delivery_turn_target(session, task) + if target is None or not self._delivery_turn_needed(session): + return False + return self._start_app_server_delivery(session, target=target) + except Exception: # noqa: BLE001 - closing a delivery never fails a read + logger.exception( + "Studio migration delivery turn could not start task_id=%s", + task_id, + ) + return False + + def _delivery_turn_target( + self, + session: MigrationSandboxSession, + task: dict[str, object] | None, + ) -> str | None: + """The settled delivery state a closing turn has to explain, if any. + + A closing turn explains how a delivery ended, so it starts on a settled + delivery only: an unfinished run has nothing to report yet, and a structured + migration has no agent work whose outcome needs reading. + """ + if isinstance(task, dict): + state = str(task.get("state") or "") + confirmation = task.get("confirmation") + if ( + state in _DELIVERY_SETTLED_STATES + and isinstance(confirmation, dict) + and confirmation.get("execution_model") == "agentic" + ): + return state + return None + confirmation = self._read_json(session, _CONFIRMATION_PATH, optional=True) + if ( + not isinstance(confirmation, dict) + or confirmation.get("execution_model") != "agentic" + ): + return None + delivery = self._read_json(session, _DELIVERY_STATUS_PATH, optional=True) + if isinstance(delivery, dict): + state = str(delivery.get("state") or "") + if state in _DELIVERY_SETTLED_STATES: + return state + driver = self._read_migration_driver(session) + if self._migration_driver_lost(driver): + return "failed" + process_exit = self._read_json(session, _PROCESS_EXIT_PATH, optional=True) + if process_exit is None: + return None + try: + settled = not self._process_exit_is_settling( + self._validated_process_exit(process_exit) + ) + except MigrationError: + return "failed" + return "failed" if settled else None + + def _delivery_turn_needed(self, session: MigrationSandboxSession) -> bool: + """Whether this delivery still waits for its closing turn. + + The report makes the turn idempotent, and the lease keeps two Studio processes + from closing the same delivery at once: only a lease whose heartbeat stopped + is treated as gone. + """ + running = self._delivery_turns.get(session.session_id) + if running is not None and running.is_alive(): + return False + report = self._read_delivery_report(session) + if isinstance(report, dict): + return False + lease = self._read_delivery_turn_lease(session) + if not isinstance(lease, dict): + return True + if lease.get("state") == _DELIVERY_TURN_RUNNING: + heartbeat = lease.get("heartbeat_at") + age = ( + time.time() - float(heartbeat) + if isinstance(heartbeat, (int, float)) + and not isinstance(heartbeat, bool) + else _DELIVERY_TURN_STALE_SECONDS + ) + if age < _DELIVERY_TURN_STALE_SECONDS: + return False + attempts = lease.get("attempts") + if ( + isinstance(attempts, int) + and not isinstance(attempts, bool) + and attempts >= _DELIVERY_TURN_MAX_ATTEMPTS + and lease.get("verdict") is not True + ): + return False + return True + + def _read_delivery_turn_lease( + self, + session: MigrationSandboxSession, + ) -> dict[str, object] | None: + """Read the closing turn's lease, tolerating a missing or damaged record.""" + try: + lease = self._read_json(session, _DELIVERY_TURN_PATH, optional=True) + except MigrationError: + logger.warning( + "Studio delivery turn lease is unreadable task_id=%s", + session.task_id, + ) + return None + if ( + not isinstance(lease, dict) + or lease.get("schema_version") != 1 + or lease.get("driver") != _DELIVERY_TURN_DRIVER + ): + return None + return lease + + def _read_delivery_report( + self, + session: MigrationSandboxSession, + ) -> dict[str, object] | None: + """Read the closing turn's verdict, tolerating a damaged record. + + The report is an explanation layer on top of the delivery contract, so a record + that cannot be read or validated is ignored rather than failing every later read + of the task. + """ + try: + report = self._read_json(session, _DELIVERY_REPORT_PATH, optional=True) + except MigrationError: + logger.warning( + "Studio delivery report is unreadable task_id=%s", + session.task_id, + ) + return None + if not isinstance(report, dict): + return None + state = str(report.get("state") or "") + if state not in _DELIVERY_SETTLED_STATES: + return None + try: + return validate_delivery_report( + report, + expected_run_id=session.task_id, + expected_state=state, + ) + except MigrationContractError as error: + logger.warning( + "Studio delivery report is invalid task_id=%s error=%s", + session.task_id, + error, + ) + return None + + def _drive_delivery_recovery(self, session: MigrationSandboxSession) -> bool: + """Rebuild the packaging half of a delivery whose CLI never finished. + + The AgentKit CLI settles an agentic delivery in one place: once the Codex turn + reports a terminal state, it turns the files on disk into an artifact and a + manifest. None of that needs a model, so a run that lost its CLI after the + agent finished leaves a complete project that nothing will ever package. + + Returns True while a rebuild is in flight, which is what keeps the read path + from writing the task off in the same breath. + """ + worker = self._delivery_recoveries.get(session.session_id) + if worker is not None and worker.is_alive(): + return True + lease = self._read_delivery_recovery_lease(session) + if isinstance(lease, dict): + if lease.get("state") == _DELIVERY_RECOVERY_RUNNING: + heartbeat = lease.get("heartbeat_at") + age = ( + self._clock() - float(heartbeat) + if isinstance(heartbeat, (int, float)) + and not isinstance(heartbeat, bool) + else _DELIVERY_RECOVERY_STALE_SECONDS + ) + if age < _DELIVERY_RECOVERY_STALE_SECONDS: + return True + if lease.get("state") == _DELIVERY_RECOVERY_DONE: + # 结论已经落地:不再重开,否则每次读任务都要重跑一遍打包。 + return False + attempts = lease.get("attempts") + if ( + isinstance(attempts, int) + and not isinstance(attempts, bool) + and attempts >= _DELIVERY_RECOVERY_MAX_ATTEMPTS + ): + return False + request = self._delivery_recovery_request(session) + if request is None: + return False + return self._start_delivery_recovery(session, lease, request) + + def _read_delivery_recovery_lease( + self, + session: MigrationSandboxSession, + ) -> dict[str, object] | None: + """Read the rebuild's lease, tolerating a missing or damaged record.""" + try: + lease = self._read_json( + session, + _DELIVERY_RECOVERY_LEASE_PATH, + optional=True, + ) + except MigrationError: + logger.warning( + "Studio delivery recovery lease is unreadable task_id=%s", + session.task_id, + ) + return None + if not isinstance(lease, dict) or lease.get("schema_version") != 1: + return None + return lease + + def _delivery_recovery_request( + self, + session: MigrationSandboxSession, + ) -> dict[str, object] | None: + """The bindings a rebuild has to be handed, or None when there is nothing to + rebuild. + + A delivery is only rebuildable once the agent wrote a terminal state of its + own, which is also what separates "the CLI died on a finished project" from + "the CLI died mid-run". The second one stays a failure. + """ + try: + status = self._read_json(session, _AGENT_STATUS_PATH, optional=True) + except MigrationError: + logger.warning( + "Studio migration agent state is unreadable task_id=%s", + session.task_id, + ) + return None + if ( + not isinstance(status, dict) + or str(status.get("state") or "") not in _DELIVERED_AGENT_STATES + ): + return None + try: + confirmation_content = self._read( + session, + _CONFIRMATION_PATH, + max_bytes=_MAX_PROVENANCE_BYTES, + optional=True, + ) + source = self._read_json(session, _SOURCE_STATUS_PATH, optional=True) + capabilities = self._read_json(session, _CAPABILITIES_PATH, optional=True) + except MigrationError: + return None + if confirmation_content is None or not isinstance(source, dict): + return None + try: + confirmation = json.loads(confirmation_content) + except (UnicodeDecodeError, ValueError): + return None + if not isinstance(confirmation, dict): + return None + cli = capabilities.get("cli") if isinstance(capabilities, dict) else None + cli_version = str(cli.get("version") or "") if isinstance(cli, dict) else "" + source_sha256 = str(source.get("sha256") or "") + if not cli_version or len(source_sha256) != 64: + return None + return { + "output_dir": _DELIVERY_OUTPUT_DIR, + "delivery_dir": _DELIVERY_DIR, + "status_path": _AGENT_STATUS_PATH, + "run_id": session.task_id, + "framework": str(confirmation.get("framework") or ""), + "source_sha256": source_sha256, + "provenance_sha256": hashlib.sha256(confirmation_content).hexdigest(), + "cli_version": cli_version, + } + + def _start_delivery_recovery( + self, + session: MigrationSandboxSession, + previous: dict[str, object] | None, + request: dict[str, object], + ) -> bool: + """Hand one rebuildable delivery to a Studio background worker.""" + attempts = 1 + if isinstance(previous, dict) and isinstance(previous.get("attempts"), int): + attempts = int(previous["attempts"]) + 1 + self._put( + session, + _DELIVERY_RECOVERY_LEASE_PATH, + _json_bytes( + _delivery_recovery_marker( + state=_DELIVERY_RECOVERY_RUNNING, + attempts=attempts, + ) + ), + media_type="application/json", + ) + worker = threading.Thread( + target=self._delivery_recovery_worker, + args=(session, attempts, request), + name=f"migration-recovery-{session.session_id[-8:]}", + daemon=True, + ) + self._delivery_recoveries[session.session_id] = worker + try: + worker.start() + except Exception: # noqa: BLE001 - a failed start keeps the task's own state + self._delivery_recoveries.pop(session.session_id, None) + logger.exception( + "Studio migration delivery recovery worker could not start task_id=%s", + session.task_id, + ) + return False + return True + + def _delivery_recovery_worker( + self, + session: MigrationSandboxSession, + attempts: int, + request: dict[str, object], + ) -> None: + """Rebuild one delivery, and leave the lease saying how that went.""" + verdict = False + files: int | None = None + stop = threading.Event() + heartbeat = threading.Thread( + target=self._keep_delivery_recovery_lease, + args=(session, attempts, stop), + name=f"migration-recovery-beat-{session.session_id[-8:]}", + daemon=True, + ) + heartbeat.start() + try: + outcome = self._run_delivery_recovery(session, request) + verdict = isinstance(outcome, dict) and outcome.get("ok") is True + count = outcome.get("files") if isinstance(outcome, dict) else None + if isinstance(count, int) and not isinstance(count, bool): + files = count + except Exception: # noqa: BLE001 - the worker must never kill the process + logger.exception( + "Studio migration delivery recovery failed task_id=%s", + session.task_id, + ) + finally: + stop.set() + with contextlib.suppress(Exception): + heartbeat.join(timeout=_DELIVERY_RECOVERY_HEARTBEAT_SECONDS) + if ( + self._delivery_recoveries.get(session.session_id) + is threading.current_thread() + ): + self._delivery_recoveries.pop(session.session_id, None) + with contextlib.suppress(Exception): + self._put( + session, + _DELIVERY_RECOVERY_LEASE_PATH, + _json_bytes( + _delivery_recovery_marker( + state=_DELIVERY_RECOVERY_DONE, + attempts=attempts, + verdict=verdict, + files=files, + ) + ), + media_type="application/json", + ) + + def _keep_delivery_recovery_lease( + self, + session: MigrationSandboxSession, + attempts: int, + stop: threading.Event, + ) -> None: + """Hold the rebuild's lease open while it runs, so only one of them runs.""" + started = time.time() + while not stop.wait(_DELIVERY_RECOVERY_HEARTBEAT_SECONDS): + try: + self._put( + session, + _DELIVERY_RECOVERY_LEASE_PATH, + _json_bytes( + _delivery_recovery_marker( + state=_DELIVERY_RECOVERY_RUNNING, + attempts=attempts, + started_at=started, + ) + ), + media_type="application/json", + ) + except Exception: # noqa: BLE001 - a missed beat must not stop the rebuild + logger.warning( + "Studio migration delivery recovery lease is unreadable task_id=%s", + session.task_id, + ) + + def _run_delivery_recovery( + self, + session: MigrationSandboxSession, + request: dict[str, object], + ) -> dict[str, object] | None: + """Install the rebuild in the Sandbox and run it there. + + The program that runs is this repository's own module, shipped as source, so + the rules it packages by are the rules the tests exercise rather than a second + copy of them living in the Sandbox. + """ + self._put( + session, + _DELIVERY_RECOVERY_SCRIPT_PATH, + delivery_recovery_source().encode("utf-8"), + media_type="text/x-python", + ) + self._put( + session, + _DELIVERY_RECOVERY_REQUEST_PATH, + _json_bytes(request), + media_type="application/json", + ) + inner = "\n".join( + [ + "python3 " + f"{shlex.quote(_DELIVERY_RECOVERY_SCRIPT_PATH)} " + f"{shlex.quote(_DELIVERY_RECOVERY_REQUEST_PATH)} " + f"> {shlex.quote(_DELIVERY_RECOVERY_RESULT_PATH)} " + f"2> {shlex.quote(_DELIVERY_RECOVERY_LOG_PATH)}", + "code=$?", + f"cat {shlex.quote(_DELIVERY_RECOVERY_RESULT_PATH)}", + 'if [ "$code" -ne 0 ]; then', + f" tail -c 4000 {shlex.quote(_DELIVERY_RECOVERY_LOG_PATH)} >&2", + "fi", + "exit 0", + ] + ) + result = self._execute( + session, + f"bash -c {shlex.quote(inner)}", + operation="delivery_recovery", + timeout_seconds=_DELIVERY_RECOVERY_TIMEOUT_SECONDS, + ) + stdout = result.get("stdout") if isinstance(result, dict) else None + verdict = _recovery_verdict(stdout) if isinstance(stdout, str) else None + if isinstance(verdict, dict) and verdict.get("ok") is True: + logger.info( + "Studio migration delivery rebuilt task_id=%s files=%s bytes=%s " + "manifest_sha256=%s", + session.task_id, + verdict.get("files"), + verdict.get("bytes"), + verdict.get("manifest_sha256"), + ) + else: + stderr = result.get("stderr") if isinstance(result, dict) else None + logger.warning( + "Studio migration delivery could not be rebuilt task_id=%s " + "verdict=%s stderr=%s", + session.task_id, + verdict, + str(stderr or "")[:1000], + ) + return verdict + + def _start_app_server_delivery( + self, + session: MigrationSandboxSession, + *, + target: str, + ) -> bool: + """Hand one settled delivery to a Studio background worker.""" + previous = self._read_delivery_turn_lease(session) + attempts = 1 + if isinstance(previous, dict) and isinstance(previous.get("attempts"), int): + attempts = int(previous["attempts"]) + 1 + self._put( + session, + _DELIVERY_TURN_PATH, + _json_bytes( + _delivery_turn_marker(state=_DELIVERY_TURN_RUNNING, attempts=attempts) + ), + media_type="application/json", + ) + worker = threading.Thread( + target=self._app_server_delivery_worker, + args=(session, target, attempts), + name=f"migration-delivery-{session.session_id[-8:]}", + daemon=True, + ) + self._delivery_turns[session.session_id] = worker + try: + worker.start() + except Exception: # noqa: BLE001 - a failed start keeps the CLI's own state + self._delivery_turns.pop(session.session_id, None) + logger.exception( + "Studio migration delivery turn worker could not start task_id=%s", + session.task_id, + ) + return False + return True - @staticmethod - def _validate_request( - existing: dict[str, object], - expected: dict[str, object], + def _app_server_delivery_worker( + self, + session: MigrationSandboxSession, + target: str, + attempts: int, ) -> None: - MigrationService._validated_request( - existing, - str(expected["task_id"]), - ) - if ( - existing.get("source_file_name") != expected["source_file_name"] - or existing.get("instruction") != expected["instruction"] - or existing.get("model_id") != expected.get("model_id") - or existing.get("evaluation") != expected.get("evaluation") - or existing.get("session_ttl_seconds") != expected["session_ttl_seconds"] - ): - raise MigrationError( - "MIGRATION_REQUEST_CONFLICT", - "该迁移会话 ID 已用于其他迁移请求。", - status_code=409, - retryable=False, + """Close one delivery on an app-server turn, or keep the CLI's own record.""" + verdict = False + try: + try: + report = asyncio.run( + self._run_app_server_delivery_turn(session, target=target) + ) + except DeliveryTurnUnavailable as error: + logger.warning( + "Studio migration delivery turn unavailable task_id=%s " + "expected_state=%s error_type=%s", + session.task_id, + target, + type(error).__name__, + ) + report = None + if report is None: + # 没有结论就保留 CLI 自己的交付状态;租约记下这一次没有结论, + # 免得之后每次读任务都重开一个回合。 + logger.warning( + "Studio migration delivery turn returned no verdict; keeping the " + "CLI delivery state task_id=%s expected_state=%s attempts=%s", + session.task_id, + target, + attempts, + ) + else: + verdict = self._persist_delivery_report( + session, + report, + expected_state=target, + ) + except Exception: # noqa: BLE001 - the worker must never kill the process + logger.exception( + "Studio migration delivery turn failed task_id=%s expected_state=%s", + session.task_id, + target, ) + finally: + if ( + self._delivery_turns.get(session.session_id) + is threading.current_thread() + ): + self._delivery_turns.pop(session.session_id, None) + with contextlib.suppress(Exception): + self._put( + session, + _DELIVERY_TURN_PATH, + _json_bytes( + _delivery_turn_marker( + state=_DELIVERY_TURN_DONE, + attempts=attempts, + verdict=verdict, + ) + ), + media_type="application/json", + ) - def upload_source( + async def _run_app_server_delivery_turn( self, - task_id: str, - owner_id: str, - content: bytes, - ) -> dict[str, object]: - summary = validate_source_archive(content) - session = self._session(task_id, owner_id) - current = self.get_task(task_id, owner_id) - if current["state"] != "awaiting_upload": - raise MigrationError( - "MIGRATION_SOURCE_LOCKED", - "分析开始后不能修改项目附件;请等待完成或终止当前迁移。", - status_code=409, + session: MigrationSandboxSession, + *, + target: str, + ) -> dict[str, object] | None: + """Run the closing turn while refreshing its lease and recording its events.""" + + async def beat() -> None: + warned = False + while True: + await asyncio.sleep(_DELIVERY_TURN_HEARTBEAT_SECONDS) + try: + await asyncio.to_thread( + self._put, + session, + _DELIVERY_TURN_PATH, + _json_bytes( + _delivery_turn_marker(state=_DELIVERY_TURN_RUNNING) + ), + media_type="application/json", + ) + except Exception as error: # noqa: BLE001 - lease refresh is advisory + if not warned: + warned = True + logger.warning( + "Studio migration delivery turn lease refresh failed " + "task_id=%s error_type=%s", + session.task_id, + type(error).__name__, + ) + + async def questioner( + questions: tuple[dict[str, object], ...], + ) -> dict[str, tuple[str, ...]] | None: + """Publish one question set and wait for the page to answer it.""" + return await self._ask_user( + session, + questions=questions, + attempt=1, + window_seconds=_DELIVERY_TURN_INPUT_WINDOW_SECONDS, + waited_seconds=waited_seconds, ) - digest = hashlib.sha256(content).hexdigest() - accepted_source = self._read_json( - session, - _SOURCE_STATUS_PATH, - optional=True, - ) - if accepted_source is not None: - accepted_source = self._validated_source(accepted_source) - accepted_digest = accepted_source.get("sha256") - if accepted_digest != digest: - raise MigrationError( - "MIGRATION_SOURCE_LOCKED", - "项目附件已锁定;只能使用原 ZIP 继续启动分析。", - status_code=409, - ) - else: - candidate = f"{MIGRATION_ROOT}/input/.source-{digest}.zip" - self._put( + + request = None + try: + request = self._read_json(session, _REQUEST_PATH, optional=True) + except MigrationError: + request = None + model_id = str((request or {}).get("model_id") or "") + framework = "" + exit_code: object = None + try: + confirmation = self._read_json(session, _CONFIRMATION_PATH, optional=True) + if isinstance(confirmation, dict): + framework = str(confirmation.get("framework") or "") + process_exit = self._read_json(session, _PROCESS_EXIT_PATH, optional=True) + if isinstance(process_exit, dict): + exit_code = process_exit.get("exit_code") + except MigrationError: + logger.warning( + "Studio migration delivery turn evidence is incomplete task_id=%s", + session.task_id, + ) + await asyncio.to_thread(self._prepare_delivery_turn_cwd, session) + activity = AnalysisActivityLog( + lambda content: self._put( session, - candidate, + _DELIVERY_TURN_ACTIVITY_PATH, content, - media_type="application/zip", + media_type="text/plain", + ), + # 交付回合的 publishArtifact 调用就是产物的交接,页面要看得见。 + include_dynamic_tools=True, + ) + waited_seconds = [0.0] + heartbeat = asyncio.create_task(beat()) + flusher = asyncio.create_task(activity.run()) + report: dict[str, object] | None = None + try: + report = await run_delivery_turn( + endpoint=session.endpoint, + prompt=_delivery_prompt( + task_id=session.task_id, + framework=framework, + expected_state=target, + exit_code=exit_code, + ), + cwd=_DELIVERY_TURN_CWD, + run_id=session.task_id, + expected_state=target, + publisher=lambda path: self._publish_delivery_artifact( + session, + path, + expected_state=target, + ), + model=model_id, + timeout_seconds=_DELIVERY_TURN_TIMEOUT_SECONDS, + event_sink=activity.record, + extra_tools=( + DynamicTool( + name=ASK_TOOL_NAME, + description=DELIVERY_ASK_TOOL_DESCRIPTION, + schema=ASK_TOOL_SCHEMA, + handler=ask_tool_handler(questioner), + ), + ), + idle_timeout_seconds=( + _DELIVERY_TURN_TIMEOUT_SECONDS + + _DELIVERY_TURN_INPUT_WINDOW_SECONDS + + _DELIVERY_TURN_INPUT_IDLE_MARGIN_SECONDS + ), + host_wait_seconds=lambda: waited_seconds[0], ) + return report + finally: + # 结论已落地:把「提交交付结论」这行收口,别让页面停在进行中。 + if report is not None: + await asyncio.to_thread(activity.complete_dynamic_tools) + flusher.cancel() + with contextlib.suppress(asyncio.CancelledError): + await flusher + await activity.aclose() + heartbeat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat + + def _prepare_delivery_turn_cwd(self, session: MigrationSandboxSession) -> None: + """Make sure the turn has a working directory that is not the deliverable.""" + try: self._execute( session, - _prepare_source_command( - candidate_path=candidate, - source_sha256=digest, - source_size=len(content), - summary=summary, - ), - operation="prepare_source", - timeout_seconds=_FILE_OPERATION_TIMEOUT_SECONDS, + f"mkdir -p {shlex.quote(_DELIVERY_TURN_CWD)}", + operation="prepare_delivery_turn", + timeout_seconds=30, ) - request = self._read_json(session, _REQUEST_PATH, optional=True) - if request is None: - raise MigrationError( - "MIGRATION_REQUEST_MISSING", - "迁移请求文件不存在。", - status_code=502, + except Exception as error: # noqa: BLE001 - a cwd is a convenience, not a gate + logger.warning( + "Studio migration delivery turn cwd unavailable task_id=%s " + "error_type=%s", + session.task_id, + type(error).__name__, ) - request = self._validated_request(request, task_id) - self._put( + + def _publish_delivery_artifact( + self, + session: MigrationSandboxSession, + path: str, + *, + expected_state: str, + ) -> PublishedArtifact: + """Read the delivered artifact back and require it to match the CLI manifest. + + This is why the delivery closes on a Studio turn at all: Studio hashes the bytes + it pulled itself, so a manifest describing some other archive than the one on + disk cannot become this migration's published artifact. + """ + if path != ARTIFACT_PATH: + raise DeliveryContractError(f"产物路径必须是 {ARTIFACT_PATH}") + content = self._read( session, - _ANALYSIS_SCHEMA_PATH, - _json_bytes(_analysis_schema()), - media_type="application/json", + _DELIVERY_ARTIFACT_PATH, + max_bytes=_MAX_ARTIFACT_BYTES, ) + assert content is not None + digest = hashlib.sha256(content).hexdigest() + size = len(content) + manifest = self._read_json(session, _DELIVERY_RESULT_PATH, optional=True) + if not isinstance(manifest, dict): + raise DeliveryContractError("迁移产物清单不存在,无法核对产物") + try: + validated = validate_delivery_result( + manifest, + expected_run_id=session.task_id, + expected_status=expected_state, + ) + except MigrationContractError as error: + raise DeliveryContractError(f"迁移产物清单无效({error})") from error + artifact = validated["artifact"] + assert isinstance(artifact, dict) + if artifact.get("sha256") != digest or artifact.get("size") != size: + raise DeliveryContractError("产物字节与迁移产物清单不一致") + return PublishedArtifact(path=ARTIFACT_PATH, sha256=digest, size=size) + + def _persist_delivery_report( + self, + session: MigrationSandboxSession, + report: dict[str, object], + *, + expected_state: str, + ) -> bool: + """Keep the closing turn's verdict beside the delivery it explains.""" + try: + validated = validate_delivery_report( + report, + expected_run_id=session.task_id, + expected_state=expected_state, + ) + except MigrationContractError as error: + logger.warning( + "Studio migration delivery report rejected task_id=%s error=%s", + session.task_id, + error, + ) + return False self._put( session, - _ANALYSIS_PROMPT_PATH, - _analysis_prompt( - request, - attempt=1, - input_sha256=digest, - ).encode("utf-8"), - media_type="text/markdown", + _DELIVERY_REPORT_PATH, + _json_bytes(validated), + media_type="application/json", ) - self._execute( - session, - _start_analysis_command(task_id, 1), - operation="start_analysis", - timeout_seconds=30, + warnings = validated.get("warnings") + logger.info( + "Studio migration delivery turn completed task_id=%s state=%s warnings=%s", + session.task_id, + expected_state, + len(warnings) if isinstance(warnings, list) else 0, ) - return self.get_task(task_id, owner_id) + return True + + def _with_delivery_report( + self, + session: MigrationSandboxSession, + task: dict[str, object], + ) -> dict[str, object]: + """Let the closing turn's verdict stand in for the generic CLI sentence. + + The state still comes from the delivery contract; the turn only supplies the + sentence the user reads, and only when it explains that same state. + """ + state = str(task.get("state") or "") + if state not in _DELIVERY_SETTLED_STATES: + return task + report = self._read_delivery_report(session) + if not isinstance(report, dict) or report.get("state") != state: + return task + return {**task, "message": str(report["message"])} def list_tasks(self, owner_id: str) -> dict[str, list[dict[str, object]]]: try: @@ -2778,7 +5282,8 @@ def list_tasks(self, owner_id: str) -> dict[str, list[dict[str, object]]]: return {"items": tasks} def get_task(self, task_id: str, owner_id: str) -> dict[str, object]: - return self._task_from_session(self._session(task_id, owner_id)) + session = self._session(task_id, owner_id) + return self._with_delivery_report(session, self._task_from_session(session)) @staticmethod def _artifact_status( @@ -2850,6 +5355,17 @@ def _task_payload( "canStop": state in _STOPPABLE_STATES, "artifact": artifact_status, } + # 分析回合和交付收尾回合共用同一张提问卡片:注册表里还有活着的提问, + # 页面就必须看到它。任务状态本身不受影响——分析提问时任务仍是分析中, + # 交付提问时任务已经落定,卡片同样要出现。 + pending_input = self._analysis_input.pending(session.session_id) + if pending_input is not None: + payload["pendingInput"] = ask_payload(pending_input) + payload["message"] = ( + "分析正在等待你的回答" + if state == "analyzing" + else "交付说明正在等待你的回答" + ) if request.get("model_id"): payload["modelId"] = str(request["model_id"]) if isinstance(request.get("evaluation"), dict): @@ -2924,6 +5440,9 @@ def _task_from_session( confirmation, session.task_id, ) + driver = ( + self._read_migration_driver(session) if confirmation is not None else None + ) delivery = self._read_json(session, _DELIVERY_STATUS_PATH, optional=True) delivery_state = "" if delivery is not None: @@ -3002,6 +5521,36 @@ def _task_from_session( "retryable": False, }, ) + if self._migration_driver_lost(driver): + assert driver is not None + # CLI 不在了,但 agent 可能已经把项目做完:先把打包补上,补不上才是失败。 + if self._drive_delivery_recovery(session): + return self._task_payload( + session, + request, + state="migrating", + message="正在整理迁移结果", + confirmation=confirmation, + ) + logger.warning( + "Studio migration delivery driver stopped without a result " + "task_id=%s heartbeat_at=%s stale_seconds=%s", + session.task_id, + driver.get("heartbeat_at"), + _MIGRATION_DRIVER_STALE_SECONDS, + ) + return self._task_payload( + session, + request, + state="failed", + message="迁移执行进程已中断,请重新发起迁移。", + confirmation=confirmation, + error={ + "code": "MIGRATION_DELIVERY_INTERRUPTED", + "message": "迁移执行进程已中断,未生成完整的迁移交付。", + "retryable": False, + }, + ) if delivery is not None: return self._task_payload( session, @@ -3311,6 +5860,7 @@ def submit_answers( _json_bytes(_analysis_schema()), media_type="application/json", ) + detection = self._read_detection(session) self._put( session, _ANALYSIS_PROMPT_PATH, @@ -3320,15 +5870,100 @@ def submit_answers( input_sha256=str(source["sha256"]), previous_analysis=analysis, answers=body.answers, + detection=detection, ).encode("utf-8"), media_type="text/markdown", ) - self._execute( + self._put( session, - _start_analysis_command(task_id, next_attempt), - operation="start_analysis", - timeout_seconds=30, + _ANALYSIS_RETRY_PROMPT_PATH, + _analysis_prompt( + request, + attempt=next_attempt, + input_sha256=str(source["sha256"]), + previous_analysis=analysis, + answers=body.answers, + protocol_retry=True, + detection=detection, + ).encode("utf-8"), + media_type="text/markdown", ) + if self._start_app_server_analysis( + session, + prompt=_analysis_prompt( + request, + attempt=next_attempt, + input_sha256=str(source["sha256"]), + previous_analysis=analysis, + answers=body.answers, + interactive=True, + detection=detection, + ), + attempt=next_attempt, + input_sha256=str(source["sha256"]), + model_id=str(request.get("model_id") or ""), + ): + return self.get_task(task_id, owner_id) + self._start_scripted_analysis(session, task_id=task_id, attempt=next_attempt) + return self.get_task(task_id, owner_id) + + def submit_analysis_input( + self, + task_id: str, + owner_id: str, + body: SubmitAnalysisInputBody, + ) -> dict[str, object]: + """Hand the answers for an in-turn question back to the waiting analysis. + + This is deliberately not the ``needs_input`` re-run: the questions came from the + running app-server turn, so the answers unblock that same turn, which keeps the + project exploration it already paid for. + """ + session = self._session(task_id, owner_id) + pending = self._analysis_input.pending(session.session_id) + if pending is None or pending.request_id != body.request_id: + raise MigrationError( + "MIGRATION_ANALYSIS_INPUT_GONE", + "这次提问已经结束,请刷新页面后按当前分析状态继续。", + status_code=409, + ) + question_ids = [str(question["id"]) for question in pending.questions] + if set(body.answers) - set(question_ids): + raise MigrationError( + "MIGRATION_ANALYSIS_INPUT_INVALID", + "回答与当前分析问题不匹配,请刷新后重试。", + status_code=409, + ) + if any( + not body.answers.get(question_id, "").strip() + for question_id in question_ids + ): + raise MigrationError( + "MIGRATION_ANALYSIS_INPUT_REQUIRED", + "请先回答当前分析的全部问题。", + status_code=422, + ) + try: + answers = normalize_answers( + {question_id: body.answers[question_id] for question_id in question_ids} + ) + except AnalysisAskError as error: + # 请求体已经校验过,这里只是兜底:回答不接受就走同一类错误码。 + raise MigrationError( + "MIGRATION_ANALYSIS_INPUT_INVALID", + f"回答无法提交({error})。", + status_code=409, + ) from error + if not self._analysis_input.resolve( + session.session_id, + request_id=body.request_id, + answers=answers, + ): + raise MigrationError( + "MIGRATION_ANALYSIS_INPUT_GONE", + "这次提问已经结束,请刷新页面后按当前分析状态继续。", + status_code=409, + ) return self.get_task(task_id, owner_id) def confirm( @@ -3546,6 +6181,16 @@ def activity(self, task_id: str, owner_id: str) -> dict[str, object]: items.extend( _parse_activity_log(content, attempt, phase="migration") ) + # 交付收尾回合只在 app-server 上存在,它的事件同样写回 codex exec 行格式, + # 这样迁移页在 CLI 收尾之后还能继续看到 Codex 在做什么。 + turn_log = self._read( + session, + _DELIVERY_TURN_ACTIVITY_PATH, + max_bytes=_MAX_ACTIVITY_LOG_BYTES, + optional=True, + ) + if turn_log is not None: + items.extend(_parse_activity_log(turn_log, 1, phase="delivery")) return { "available": True, "complete": task["state"] in _ACTIVITY_COMPLETE_STATES, @@ -3574,7 +6219,7 @@ def activity(self, task_id: str, owner_id: str) -> dict[str, object]: items: list[dict[str, object]] = [] analysis_log = self._read( session, - f"{MIGRATION_ROOT}/diagnostics/analysis/attempt-{analysis_attempt}.log", + _analysis_activity_path(analysis_attempt), max_bytes=_MAX_ACTIVITY_LOG_BYTES, optional=True, ) @@ -3901,8 +6546,36 @@ def _verified_artifact_content( "迁移产物完整性校验失败。", status_code=502, ) + self._verify_driver_attestation(session, descriptor) return content + def _verify_driver_attestation( + self, + session: MigrationSandboxSession, + descriptor: dict[str, object], + ) -> None: + """Require the published driver digest to agree with the delivery manifest. + + The manifest is produced by the CLI, the digest by the launch script that + supervised it. When both are present they must describe the same archive, + otherwise the bytes changed after the run that produced them. + """ + driver = self._read_migration_driver(session) + if not isinstance(driver, dict) or driver.get("state") != "finished": + return + published = driver.get("artifact") + if not isinstance(published, dict): + return + if ( + published.get("sha256") != descriptor["sha256"] + or published.get("size") != descriptor["size"] + ): + raise MigrationError( + "MIGRATION_ARTIFACT_INTEGRITY_FAILED", + "迁移产物与交付发布清单不一致。", + status_code=502, + ) + def materialize_deployment( self, task_id: str, diff --git a/frontend/src/adk/migrations.ts b/frontend/src/adk/migrations.ts index a964b19ef..985fa081b 100644 --- a/frontend/src/adk/migrations.ts +++ b/frontend/src/adk/migrations.ts @@ -1,6 +1,7 @@ import { withAuth } from "./auth"; import { withLocalUser } from "./identity"; import { adkT, withLocaleHeaders } from "./i18n"; +import type { SandboxTokenUsage } from "./sandbox"; import { DEFAULT_REQUEST_TIMEOUT_MS, requestSignal, @@ -203,6 +204,18 @@ export interface MigrationAnalysis { warnings: string[]; } +export interface MigrationPendingQuestion { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; +} + +export interface MigrationPendingInput { + id: string; + questions: MigrationPendingQuestion[]; +} + export interface MigrationTask { id: string; state: MigrationTaskState; @@ -225,6 +238,7 @@ export interface MigrationTask { deployReady: boolean; }; analysis?: MigrationAnalysis; + pendingInput?: MigrationPendingInput; analysisRef?: { attempt: number; sha256: string; @@ -255,7 +269,8 @@ export type MigrationActivityKind = | "message" | "plan" | "command" - | "status"; + | "status" + | "summary"; export interface MigrationActivityTool { name: string; @@ -270,6 +285,21 @@ export interface MigrationActivityPlanItem { status: "pending" | "in_progress" | "completed" | "failed"; } +/** One turn's own cost, in the shape the shared turn summary renders. */ +export interface MigrationActivityTurn { + turnId: string; + status: string; + model?: string; + durationMs?: number; + startedAt?: number; + completedAt?: number; + usage?: Partial; + usageIncomplete?: boolean; + toolCalls: number; + toolDurationMs?: number; + toolDurationComplete: boolean; +} + export interface MigrationActivityItem { id: string; kind: MigrationActivityKind; @@ -278,6 +308,14 @@ export interface MigrationActivityItem { detail?: string; tool?: MigrationActivityTool; plan?: MigrationActivityPlanItem[]; + /** Codex' own item type: the shared row renderer keys its native look off it. */ + itemType?: string; + /** How long Codex spent on the item; the process header sums these. */ + durationMs?: number; + /** Codex' message phase (commentary / final_answer) when it reports one. */ + phase?: string; + /** The turn's timing and token usage, on the item that summarizes a turn. */ + turn?: MigrationActivityTurn; } export interface MigrationActivity { @@ -415,8 +453,70 @@ const ACTIVITY_KINDS = new Set([ "plan", "command", "status", + "summary", ]); +const TURN_USAGE_KEYS = [ + "totalTokens", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "reasoningOutputTokens", +] as const; + +const TURN_NUMBER_KEYS = ["durationMs", "startedAt", "completedAt"] as const; + +/** A turn summary the page can hand to the shared component, or a contract error. */ +function normalizeActivityTurn(value: unknown): MigrationActivityTurn { + const turn = record(value, adkT("migrations.labels.activityItem")); + if ( + typeof turn.turnId !== "string" || + typeof turn.status !== "string" || + !Number.isSafeInteger(turn.toolCalls) || + (turn.toolCalls as number) < 0 || + typeof turn.toolDurationComplete !== "boolean" + ) { + throw new Error(adkT("migrations.invalidActivityItem")); + } + const normalized: MigrationActivityTurn = { + turnId: turn.turnId, + status: turn.status, + toolCalls: turn.toolCalls as number, + toolDurationComplete: turn.toolDurationComplete, + }; + if (typeof turn.model === "string" && turn.model) normalized.model = turn.model; + if (turn.usageIncomplete === true) normalized.usageIncomplete = true; + for (const key of TURN_NUMBER_KEYS) { + const number = turn[key]; + if (number === undefined) continue; + if (!Number.isFinite(number) || (number as number) < 0) { + throw new Error(adkT("migrations.invalidActivityItem")); + } + normalized[key] = number as number; + } + if (turn.toolDurationMs !== undefined) { + if (!Number.isFinite(turn.toolDurationMs) || (turn.toolDurationMs as number) < 0) { + throw new Error(adkT("migrations.invalidActivityItem")); + } + normalized.toolDurationMs = turn.toolDurationMs as number; + } + if (turn.usage !== undefined) { + const usage = record(turn.usage, adkT("migrations.labels.activityItem")); + const counts: Partial = {}; + for (const key of TURN_USAGE_KEYS) { + const count = usage[key]; + if (count === undefined) continue; + if (!Number.isSafeInteger(count) || (count as number) < 0) { + throw new Error(adkT("migrations.invalidActivityItem")); + } + counts[key] = count as number; + } + normalized.usage = counts; + } + return normalized; +} + const ACTIVITY_STATES = new Set([ "running", "completed", @@ -780,6 +880,47 @@ function normalizeAnalysis(value: unknown): MigrationAnalysis { }; } +function normalizePendingInput(value: unknown): MigrationPendingInput { + const input = record(value, adkT("migrations.labels.pendingInput")); + if (typeof input.id !== "string" || !input.id.trim()) { + throw new Error(adkT("migrations.invalidPendingInput")); + } + if (!Array.isArray(input.questions) || input.questions.length === 0) { + throw new Error(adkT("migrations.invalidPendingInput")); + } + const questions = input.questions.map((item) => { + const question = record(item, adkT("migrations.labels.pendingQuestion")); + if ( + typeof question.id !== "string" || + !question.id.trim() || + typeof question.header !== "string" || + typeof question.question !== "string" + ) { + throw new Error(adkT("migrations.invalidPendingInput")); + } + const rawOptions = question.options; + const options = Array.isArray(rawOptions) + ? rawOptions.map((option) => { + const entry = record(option, adkT("migrations.labels.pendingOption")); + if ( + typeof entry.label !== "string" || + typeof entry.description !== "string" + ) { + throw new Error(adkT("migrations.invalidPendingInput")); + } + return { label: entry.label, description: entry.description }; + }) + : []; + return { + id: question.id, + header: question.header, + question: question.question, + options, + }; + }); + return { id: input.id, questions }; +} + function normalizeTask(value: unknown): MigrationTask { const task = record(value, adkT("migrations.labels.task")); const artifact = record( @@ -831,6 +972,8 @@ function normalizeTask(value: unknown): MigrationTask { } if (task.analysis !== undefined) normalized.analysis = normalizeAnalysis(task.analysis); + if (task.pendingInput !== undefined) + normalized.pendingInput = normalizePendingInput(task.pendingInput); if (task.analysisRef !== undefined) { const reference = record( task.analysisRef, @@ -1000,6 +1143,15 @@ function normalizeActivity(value: unknown): MigrationActivity { ...(typeof item.detail === "string" ? { detail: item.detail } : {}), ...(tool ? { tool } : {}), ...(plan ? { plan } : {}), + // Codex' own row fields and the turn summary are what the shared renderer + // reads; a normalization that dropped them would hand the page a stream it + // renders as something else. + ...(typeof item.itemType === "string" ? { itemType: item.itemType } : {}), + ...(typeof item.durationMs === "number" ? { durationMs: item.durationMs } : {}), + ...(typeof item.phase === "string" ? { phase: item.phase } : {}), + ...(item.turn !== undefined + ? { turn: normalizeActivityTurn(item.turn) } + : {}), }; }), }; @@ -1686,6 +1838,188 @@ export async function getMigrationActivity( ); } +/** One frame of the task event stream; every payload is a whole snapshot. */ +export type MigrationTaskEvent = + | { kind: "task"; seq: number; task: MigrationTask } + | { kind: "activity"; seq: number; activity: MigrationActivity } + /** ``code`` and ``message`` are empty on the frame that reports recovery. */ + | { + kind: "error"; + seq: number; + code: string; + message: string; + retryable: boolean; + } + | { kind: "done"; seq: number; state: string }; + +const STREAM_RETRY_MS = 1_000; +const STREAM_RETRY_MAX_MS = 15_000; + +function invalidStream(): Error { + return new Error(adkT("migrations.invalidEventsResponse")); +} + +/** + * Parse one Server-Sent Events frame into a task event. + * + * Heartbeats, comments, and event names this build does not know yet return ``null`` + * rather than throwing, so a newer Studio can add events without breaking the page. + */ +export function parseMigrationStreamFrame(frame: string): MigrationTaskEvent | null { + const lines = frame.split(/\r?\n/); + const name = lines + .find((line) => line.startsWith("event:")) + ?.slice(6) + .trim(); + if (!name) return null; + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) return null; + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + throw invalidStream(); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw invalidStream(); + } + const value = payload as Record; + const seq = value.seq; + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 1) { + throw invalidStream(); + } + if (name === "task") return { kind: "task", seq, task: normalizeTask(value) }; + if (name === "activity") { + return { kind: "activity", seq, activity: normalizeActivity(value) }; + } + if (name === "error") { + return { + kind: "error", + seq, + // An error frame without a code or a message says the task is readable again. + code: typeof value.code === "string" ? value.code : "", + message: typeof value.message === "string" ? value.message : "", + retryable: value.retryable === true, + }; + } + if (name === "done") { + return { + kind: "done", + seq, + state: typeof value.state === "string" ? value.state : "", + }; + } + return null; +} + +function streamPause(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal.addEventListener("abort", finish, { once: true }); + if (signal.aborted) finish(); + }); +} + +async function readMigrationStream(args: { + taskId: string; + cursor: number; + signal: AbortSignal; + onEvent: (event: MigrationTaskEvent) => void; + advance: (seq: number) => void; +}): Promise { + const response = await request( + `/tasks/${encodeURIComponent(args.taskId)}/events?after=${args.cursor}`, + { + signal: args.signal, + cache: "no-store", + headers: { Accept: "text/event-stream" }, + }, + 0, + ); + if (!response.ok) { + throw await errorFrom(response, adkT("migrations.loadEventsFailed")); + } + if (!response.body) throw invalidStream(); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let done = false; + try { + while (!done) { + const chunk = await reader.read(); + buffer += decoder.decode(chunk.value, { stream: !chunk.done }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() ?? ""; + for (const frame of frames) { + const event = parseMigrationStreamFrame(frame); + if (!event) continue; + args.advance(event.seq); + args.onEvent(event); + if (event.kind === "done") done = true; + } + if (chunk.done) break; + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + return done; +} + +/** + * Follow one migration task until it settles, reconnecting from the last sequence it + * saw. The server owns the payloads and the settlement rule, so the page no longer + * polls: it applies snapshots and stops when the stream says ``done``. + */ +export async function observeMigrationTask(args: { + taskId: string; + signal: AbortSignal; + after?: number; + onEvent: (event: MigrationTaskEvent) => void; + onConnection?: (message: string) => void; +}): Promise { + let cursor = Math.max(0, Math.trunc(args.after ?? 0)); + let retry = 0; + while (!args.signal.aborted) { + try { + const done = await readMigrationStream({ + taskId: args.taskId, + cursor, + signal: args.signal, + onEvent: args.onEvent, + advance: (seq) => { + cursor = seq; + }, + }); + retry = 0; + if (done || args.signal.aborted) return; + args.onConnection?.(""); + await streamPause(STREAM_RETRY_MS, args.signal); + } catch (error) { + if (args.signal.aborted) return; + if ( + error instanceof MigrationApiError && + [401, 403, 404].includes(error.status) + ) { + throw error; + } + args.onConnection?.(adkT("migrations.reconnecting")); + await streamPause( + Math.min(STREAM_RETRY_MS * 2 ** retry++, STREAM_RETRY_MAX_MS), + args.signal, + ); + } + } +} + export async function confirmMigrationTask(args: { taskId: string; framework: MigrationFramework; @@ -1753,6 +2087,32 @@ export async function submitMigrationAnalysisAnswers(args: { ); } +export async function submitMigrationAnalysisInput(args: { + taskId: string; + requestId: string; + answers: Record; + signal?: AbortSignal; +}): Promise { + return normalizeTask( + await json( + await request( + `/tasks/${encodeURIComponent(args.taskId)}/input`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: args.requestId, + answers: args.answers, + }), + signal: args.signal, + }, + SESSION_START_TIMEOUT_MS, + ), + adkT("migrations.submitInputFailed"), + ), + ); +} + export async function stopMigrationTask( taskId: string, signal?: AbortSignal, diff --git a/frontend/src/create/developmentPresentation.ts b/frontend/src/create/developmentPresentation.ts index a95c578c3..a85d76a26 100644 --- a/frontend/src/create/developmentPresentation.ts +++ b/frontend/src/create/developmentPresentation.ts @@ -56,8 +56,11 @@ export function developmentToolLabel(block: Extract): s [/\b(?:curl|wget)\b/, "Send HTTP request"], [/\bcompileall\b/, "Check Python syntax"], ]; - const summary = rules.find(([pattern]) => pattern.test(command))?.[1] || "Run shell command"; - return adkT("developmentRuns.command", { target: summary }); + const summary = rules.find(([pattern]) => pattern.test(command))?.[1]; + // A caller that already names the row keeps its own wording: the migration + // publishes commands as native status ("命令执行完成") instead of guessing what + // the shell command does, and that decision must survive the native row. + return summary ? adkT("developmentRuns.command", { target: summary }) : block.name; } if (block.itemType === "fileChange") { const paths = Array.isArray(args.changes) ? args.changes.map((change) => short(record(change).path)).filter(Boolean) : []; diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json index 25d3e7df5..c47fc09e6 100644 --- a/frontend/src/i18n/resources/en-US/adk.json +++ b/frontend/src/i18n/resources/en-US/adk.json @@ -170,6 +170,7 @@ "invalidAnalysisEvidence": "The analysis evidence has an invalid format.", "invalidEntryCandidate": "An entry candidate has an invalid format.", "invalidQuestion": "A follow-up question has an invalid format.", + "invalidPendingInput": "Analysis questions are not readable", "invalidTask": "The migration session has an invalid format.", "invalidAnalysisReference": "The analysis result reference has an invalid format.", "invalidSourcePersistence": "The migration source persistence status has an invalid format.", @@ -193,8 +194,12 @@ "createTaskFailed": "Failed to create the migration session", "uploadProjectFailed": "Failed to upload the migration project", "loadActivityFailed": "Failed to load migration activity", + "loadEventsFailed": "Failed to follow the migration progress stream", + "invalidEventsResponse": "The migration progress stream returned an invalid frame.", + "reconnecting": "Connection lost. Reconnecting to the migration progress stream…", "startFailed": "Failed to start the migration", "submitAnswersFailed": "Failed to submit additional analysis information", + "submitInputFailed": "Could not submit the analysis answer", "stopFailed": "Failed to stop the migration", "deleteTaskFailed": "Failed to delete the migration session", "loadArtifactFailed": "Failed to load the migration artifact", @@ -268,7 +273,10 @@ "evaluationSummary": "Evaluation summary", "evaluationCaseResult": "Evaluation case result", "evaluationOutput": "Evaluation output", - "evaluationLimitations": "Evaluation limitations" + "evaluationLimitations": "Evaluation limitations", + "pendingInput": "Analysis question", + "pendingQuestion": "Analysis question item", + "pendingOption": "Analysis question option" } }, "sandbox": { diff --git a/frontend/src/i18n/resources/en-US/migrations.json b/frontend/src/i18n/resources/en-US/migrations.json index 2c86b0279..3655d33a1 100644 --- a/frontend/src/i18n/resources/en-US/migrations.json +++ b/frontend/src/i18n/resources/en-US/migrations.json @@ -98,7 +98,12 @@ "title": "Codex activity", "startingAnalysis": "Codex is starting the analysis…", "startingMigration": "Codex is starting the migration…", - "loadError": "Codex activity is temporarily unavailable. The current task is unaffected." + "loadError": "Codex activity is temporarily unavailable. The current task is unaffected.", + "liveAnalyzing": "Analyzing the project", + "liveMigrating": "Running the migration", + "liveValidating": "Verifying the migration", + "livePackaging": "Packaging the migration", + "liveDelivery": "Reviewing the delivery" }, "artifact": { "title": "Migration output", @@ -213,10 +218,18 @@ "submitting": "Continuing analysis…", "submit": "Submit and continue analysis" }, + "pendingInput": { + "ariaLabel": "Answer the questions the migration needs you to decide", + "title": "The migration needs your answer", + "description": "Your answer continues the current step instead of restarting it", + "other": "Other", + "otherPlaceholder": "Or type your own answer", + "submitting": "Submitting your answer…", + "submit": "Submit answer and continue" + }, "confirmation": { "ariaLabel": "Confirm migration method", "title": "Confirm migration method", - "description": "Migration starts only after confirmation", "framework": "Migration method", "frameworkPlaceholder": "Select a migration method", "agentName": "Agent name", diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json index f7b8a6487..ea385df8e 100644 --- a/frontend/src/i18n/resources/zh-CN/adk.json +++ b/frontend/src/i18n/resources/zh-CN/adk.json @@ -170,6 +170,7 @@ "invalidAnalysisEvidence": "分析证据格式错误。", "invalidEntryCandidate": "入口候选格式错误。", "invalidQuestion": "待确认问题格式错误。", + "invalidPendingInput": "分析问题无法读取", "invalidTask": "迁移会话格式错误。", "invalidAnalysisReference": "分析结果引用格式错误。", "invalidSourcePersistence": "迁移源码保存状态格式错误。", @@ -193,8 +194,12 @@ "createTaskFailed": "创建迁移会话失败", "uploadProjectFailed": "上传迁移项目失败", "loadActivityFailed": "读取迁移执行动态失败", + "loadEventsFailed": "订阅迁移进度流失败", + "invalidEventsResponse": "迁移进度流返回了非法数据帧。", + "reconnecting": "连接已断开,正在重新连接迁移进度流…", "startFailed": "启动迁移失败", "submitAnswersFailed": "提交分析补充信息失败", + "submitInputFailed": "提交分析回答失败", "stopFailed": "终止迁移失败", "deleteTaskFailed": "删除迁移会话失败", "loadArtifactFailed": "读取迁移产物失败", @@ -268,7 +273,10 @@ "evaluationSummary": "评测汇总", "evaluationCaseResult": "评测用例结果", "evaluationOutput": "评测输出", - "evaluationLimitations": "评测限制" + "evaluationLimitations": "评测限制", + "pendingInput": "分析提问", + "pendingQuestion": "分析问题项", + "pendingOption": "分析问题选项" } }, "sandbox": { diff --git a/frontend/src/i18n/resources/zh-CN/migrations.json b/frontend/src/i18n/resources/zh-CN/migrations.json index 15e94a47d..231721004 100644 --- a/frontend/src/i18n/resources/zh-CN/migrations.json +++ b/frontend/src/i18n/resources/zh-CN/migrations.json @@ -98,7 +98,12 @@ "title": "Codex 执行动态", "startingAnalysis": "Codex 正在开始分析…", "startingMigration": "Codex 正在开始迁移…", - "loadError": "暂时无法读取 Codex 执行动态,不影响当前任务。" + "loadError": "暂时无法读取 Codex 执行动态,不影响当前任务。", + "liveAnalyzing": "正在分析项目", + "liveMigrating": "正在执行迁移", + "liveValidating": "正在校验迁移结果", + "livePackaging": "正在整理迁移产物", + "liveDelivery": "正在核对交付产物" }, "artifact": { "title": "迁移产物", @@ -213,10 +218,18 @@ "submitting": "正在继续分析…", "submit": "提交并继续分析" }, + "pendingInput": { + "ariaLabel": "回答迁移需要你决定的问题", + "title": "迁移需要你的回答", + "description": "回答后会在当前这一步里继续,不需要重新开始", + "other": "其他", + "otherPlaceholder": "也可以直接输入你的答案", + "submitting": "正在提交回答…", + "submit": "提交回答并继续" + }, "confirmation": { "ariaLabel": "确认迁移方式", "title": "确认迁移方式", - "description": "确认后才会执行实际迁移", "framework": "迁移方式", "frameworkPlaceholder": "选择迁移方式", "agentName": "Agent 名称", diff --git a/frontend/src/migrations/MigrationWorkspace.css b/frontend/src/migrations/MigrationWorkspace.css index 5321c97a4..69a6bf015 100644 --- a/frontend/src/migrations/MigrationWorkspace.css +++ b/frontend/src/migrations/MigrationWorkspace.css @@ -518,8 +518,7 @@ } .migration-activity__stream { - display: grid; - gap: 8px; + display: block; } .migration-activity__error { @@ -857,6 +856,113 @@ font-weight: 400; } +/* Questions asked inside the running analysis turn: options plus a free-text answer. */ +.migration-question { + min-width: 0; + display: grid; + gap: 8px; + overflow-wrap: anywhere; +} + +.migration-question__header { + color: hsl(var(--foreground)); + font-size: 12.5px; + font-weight: 600; +} + +.migration-question__prompt { + margin: 0; + color: hsl(var(--foreground)); + font-size: 13px; + font-weight: 400; + line-height: 1.55; +} + +.migration-question__options { + display: grid; + gap: 6px; +} + +.migration-question__option { + display: flex; + gap: 9px; + align-items: flex-start; + padding: 9px 11px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease; +} + +.migration-question__option:hover { + border-color: hsl(var(--ring) / 0.45); +} + +.migration-question__option.is-selected { + border-color: hsl(var(--ring) / 0.7); + background: hsl(var(--accent) / 0.5); +} + +.migration-question__option input { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin: 2px 0 0; + accent-color: hsl(var(--primary)); +} + +.migration-question__option span { + min-width: 0; + display: grid; + gap: 2px; +} + +.migration-question__option strong { + color: hsl(var(--foreground)); + font-size: 12.5px; + font-weight: 600; +} + +.migration-question__option em { + color: hsl(var(--muted-foreground)); + font-size: 12px; + font-style: normal; + line-height: 1.5; +} + +.migration-question__other { + display: grid; + gap: 6px; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.migration-question__other textarea { + width: 100%; + min-height: 40px; + box-sizing: border-box; + padding: 9px 11px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + outline: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 13.5px; + line-height: 1.55; + resize: vertical; +} + +.migration-question__other textarea:focus { + border-color: hsl(var(--ring) / 0.7); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.12); +} + +.migration-question__other textarea:disabled { + cursor: not-allowed; + opacity: 0.6; +} + .migration-confirmation__actions { display: flex; justify-content: flex-end; diff --git a/frontend/src/migrations/MigrationWorkspace.tsx b/frontend/src/migrations/MigrationWorkspace.tsx index b9414a1c2..c2905bacc 100644 --- a/frontend/src/migrations/MigrationWorkspace.tsx +++ b/frontend/src/migrations/MigrationWorkspace.tsx @@ -17,7 +17,6 @@ import { createMigrationTask, downloadMigrationArtifact, downloadMigrationEvaluationReport, - getMigrationActivity, getMigrationArtifact, getMigrationArtifactFile, getMigrationCapabilities, @@ -25,12 +24,14 @@ import { getMigrationEvaluationReport, getMigrationTask, listMigrationTasks, + observeMigrationTask, MigrationApiError, putMigrationEvaluationDataset, resumeMigrationEvaluation, retryMigrationEvaluation, stopMigrationTask, submitMigrationAnalysisAnswers, + submitMigrationAnalysisInput, uploadMigrationSource, type MigrationAnalysis, type MigrationActivity, @@ -105,8 +106,8 @@ import { i18n } from "../i18n/runtime"; import "./MigrationWorkspace.css"; const MAX_SOURCE_BYTES = 20 * 1024 * 1024; -const POLL_INTERVAL_MS = 1_200; -const ACTIVITY_POLL_INTERVAL_MS = 3_000; +// Task detail and activity arrive on one Server-Sent Events stream; only the +// list of recent tasks is still polled. const LIST_POLL_INTERVAL_MS = 5_000; const MAX_VISIBLE_FILES = 500; const ignoreMigrationAction = () => undefined; @@ -304,6 +305,27 @@ function isTerminalState(state: MigrationTask["state"]): boolean { ].includes(state); } +/** + * What the collapsed process header says while Codex is working, the way the + * intelligent build reports its run phase there. Settled migrations still have a + * closing turn to describe, so their status is the delivery review. + */ +function migrationLiveStatus(task: MigrationTask): string { + if (task.state === "analyzing") return migrationText("activity.liveAnalyzing"); + if (task.state === "migrating") return migrationText("activity.liveMigrating"); + if (task.state === "validating") return migrationText("activity.liveValidating"); + if (task.state === "packaging") return migrationText("activity.livePackaging"); + if ( + task.state === "succeeded" || + task.state === "succeeded_with_warnings" || + task.state === "partial" || + task.state === "failed" + ) { + return migrationText("activity.liveDelivery"); + } + return ""; +} + function shouldShowCodexActivity(task: MigrationTask): boolean { return ( task.state === "analyzing" || @@ -523,15 +545,21 @@ function MigrationActivityFeed({ loading, error, analyzing, + status, }: { activity: MigrationActivity | null; loading: boolean; error: string; analyzing: boolean; + status: string; }) { const { t } = useTranslation("migrations"); const items = activity?.items ?? []; const blocks = migrationActivityBlocks(items); + // A closing turn keeps working after the task has settled, so the feed is still + // live whenever Codex still has a running item. + const streaming = + !activity?.complete || items.some((item) => item.status === "running"); return (
{blocks.length > 0 ? (
- + {/* The intelligent build's stream: tool calls fold into expandable process rows. */} +
) : loading || !activity?.complete ? ( @@ -732,6 +767,7 @@ export function MigrationWorkspace({ const evaluationTabRef = useRef(null); const preparedAnalysisRef = useRef(""); const evaluationDraftTaskRef = useRef(""); + const taskEventCursorRef = useRef({ taskId: "", seq: 0 }); const transferAbortRef = useRef(null); const evaluationReportAbortRef = useRef(null); const [capability, setCapability] = useState( @@ -757,7 +793,14 @@ export function MigrationWorkspace({ const [loadKey, setLoadKey] = useState(0); const [showAllTasks, setShowAllTasks] = useState(false); const [action, setAction] = useState< - "create" | "upload" | "answer" | "confirm" | "stop" | "download" | "" + | "create" + | "upload" + | "answer" + | "input" + | "confirm" + | "stop" + | "download" + | "" >(""); const [error, setError] = useState(""); const [pollError, setPollError] = useState(""); @@ -768,6 +811,10 @@ export function MigrationWorkspace({ const [entry, setEntry] = useState(""); const [appName, setAppName] = useState(""); const [answers, setAnswers] = useState>({}); + // Answers for questions asked inside the running analysis turn. They are kept + // apart from the needs_input answers: the two cards are mutually exclusive and + // carry different payloads. + const [inputAnswers, setInputAnswers] = useState>({}); const [artifact, setArtifact] = useState(null); const [artifactError, setArtifactError] = useState(""); const [artifactErrorRetryable, setArtifactErrorRetryable] = useState(false); @@ -1072,55 +1119,74 @@ export function MigrationWorkspace({ }, [action, hasPollableTasks]); useEffect(() => { + if (action === "confirm" || !task || taskEnvironmentExpired) return; if ( - action === "confirm" || - !task || - taskEnvironmentExpired || - (!isActiveState(task.state) && - task.persistence?.state !== "saving" && - !isEvaluationPollingState(task)) + !shouldShowCodexActivity(task) && + !isActiveState(task.state) && + task.persistence?.state !== "saving" && + !isEvaluationPollingState(task) ) return; const controller = new AbortController(); - let timer: number | undefined; - const poll = async () => { - try { - const next = await getMigrationTask(task.id, controller.signal); + // Task detail and activity are one snapshot stream now, so the page stops polling + // both. The effect restarts whenever the server-side settlement rule can change; + // the cursor keeps those restarts cheap by resuming instead of replaying. + const resume = + taskEventCursorRef.current.taskId === task.id + ? taskEventCursorRef.current.seq + : 0; + setActivityLoading(activity === null && shouldShowCodexActivity(task)); + void observeMigrationTask({ + taskId: task.id, + signal: controller.signal, + after: resume, + onEvent: (event) => { if (controller.signal.aborted) return; - setTasks((current) => upsertTask(current, next)); + taskEventCursorRef.current = { taskId: task.id, seq: event.seq }; + if (event.kind === "error") { + setActivityLoading(false); + // A frame without code or message says the task is readable again. + const cleared = !event.code && !event.message; + setPollError(cleared ? "" : event.message || t("activity.loadError")); + setPollErrorRetryable(cleared ? false : event.retryable); + return; + } setPollError(""); setPollErrorRetryable(false); - if ( - !isMigrationEnvironmentExpired(next, Date.now()) && - (isActiveState(next.state) || - next.persistence?.state === "saving" || - isEvaluationPollingState(next)) - ) { - timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); + if (event.kind === "task") { + setTasks((current) => upsertTask(current, event.task)); + return; } - } catch (cause) { - if (controller.signal.aborted) return; - setPollError(cause instanceof Error ? cause.message : String(cause)); - setPollErrorRetryable( - cause instanceof MigrationApiError && cause.retryable, - ); - if (cause instanceof MigrationApiError && cause.retryable) { - timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); + if (event.kind === "activity") { + setActivity(event.activity); + setActivityError(""); + setActivityLoading(false); + return; } - } - }; - timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS); - return () => { - controller.abort(); - if (timer !== undefined) window.clearTimeout(timer); - }; + setActivityLoading(false); + }, + onConnection: (message) => { + if (controller.signal.aborted) return; + setPollError(message); + setPollErrorRetryable(false); + }, + }).catch((cause: unknown) => { + if (controller.signal.aborted) return; + setActivityLoading(false); + setPollError(cause instanceof Error ? cause.message : String(cause)); + setPollErrorRetryable(cause instanceof MigrationApiError && cause.retryable); + }); + return () => controller.abort(); }, [ + action, task?.id, task?.state, + task?.analysisRef?.sha256, + task?.confirmation?.framework, task?.persistence?.state, task?.evaluation?.state, taskEnvironmentExpired, - action, + t, ]); useEffect(() => { @@ -1141,62 +1207,6 @@ export function MigrationWorkspace({ setActivityLoading(false); }, [task?.id]); - useEffect(() => { - if (!task || taskEnvironmentExpired || !shouldShowCodexActivity(task)) { - return; - } - - const controller = new AbortController(); - let timer: number | undefined; - const poll = async () => { - setActivityLoading(true); - try { - const next = await getMigrationActivity(task.id, controller.signal); - if (controller.signal.aborted) return; - setActivity(next); - setActivityError(""); - if ( - !next.complete && - !taskEnvironmentExpired && - isActiveState(task.state) - ) { - timer = window.setTimeout( - () => void poll(), - ACTIVITY_POLL_INTERVAL_MS, - ); - } - } catch (cause) { - if (controller.signal.aborted) return; - setActivityError(t("activity.loadError")); - if ( - !taskEnvironmentExpired && - isActiveState(task.state) && - cause instanceof MigrationApiError && - cause.retryable - ) { - timer = window.setTimeout( - () => void poll(), - ACTIVITY_POLL_INTERVAL_MS, - ); - } - } finally { - if (!controller.signal.aborted) setActivityLoading(false); - } - }; - void poll(); - return () => { - controller.abort(); - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [ - task?.id, - task?.state, - task?.analysisRef?.sha256, - task?.confirmation?.framework, - taskEnvironmentExpired, - t, - ]); - useEffect(() => { if ( !task?.analysis || @@ -1614,6 +1624,44 @@ export function MigrationWorkspace({ requiredQuestionsAnswered, ); + // The card follows a live question, not the state: a delivery turn asks after the delivery has already settled. + const pendingInput = task?.pendingInput; + const pendingInputAnswered = (pendingInput?.questions ?? []).every( + (question) => (inputAnswers[question.id] || "").trim().length > 0, + ); + const canSubmitInput = Boolean(pendingInput && pendingInputAnswered && !action); + + // Every question set is a new one, so the previous draft must not leak into it. + const pendingInputId = pendingInput?.id; + useEffect(() => { + setInputAnswers({}); + }, [pendingInputId]); + + async function submitInput() { + if (!task || !pendingInput || !canSubmitInput) return; + setAction("input"); + setError(""); + try { + const next = await submitMigrationAnalysisInput({ + taskId: task.id, + requestId: pendingInput.id, + answers: Object.fromEntries( + pendingInput.questions.map((question) => [ + question.id, + (inputAnswers[question.id] || "").trim(), + ]), + ), + }); + setTasks((current) => upsertTask(current, next)); + } catch (cause) { + const authoritative = await reconcileTaskState(task.id); + if (authoritative && !authoritative.pendingInput) return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(""); + } + } + async function submitAnswers() { if (!task?.analysisRef || !canSubmitAnswers) return; setAction("answer"); @@ -1972,7 +2020,7 @@ export function MigrationWorkspace({ const composerFile = sourceFile; const composerBusy = action === "create" || action === "upload"; const isHome = page === "new" && !task && action !== "create"; - const navigationBusy = composerBusy || action === "confirm" || action === "answer" || action === "stop" || Boolean(evaluationAction); + const navigationBusy = composerBusy || action === "confirm" || action === "answer" || action === "input" || action === "stop" || Boolean(evaluationAction); const showComposer = !task || (task.canUpload && !taskEnvironmentExpired); const expiryCopy = task ? migrationExpiryCopy(task, now) : null; const hasEvaluationTab = Boolean(task?.evaluation?.enabled); @@ -2489,6 +2537,7 @@ export function MigrationWorkspace({ loading={activityLoading} error={activityError} analyzing={task.state === "analyzing"} + status={migrationLiveStatus(task)} /> ) : null} @@ -2496,6 +2545,97 @@ export function MigrationWorkspace({ )} + {pendingInput ? ( +
+
+ {t("pendingInput.title")} + {t("pendingInput.description")} +
+ {pendingInput.questions.map((question) => { + const selected = inputAnswers[question.id] || ""; + const chosen = question.options.some( + (option) => option.label === selected, + ); + return ( +
+ + {question.header} + +

+ {question.question} +

+ {question.options.length > 0 ? ( +
+ {question.options.map((option) => ( + + ))} +
+ ) : null} +