文件预览

draft_publish.py

查看 Founder Signal 技能包中的文件内容。

文件内容

src/founder_signal/draft_publish.py

"""Draft publish handoff for Founder Signal review artifacts."""

from __future__ import annotations

import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

_RUN_REVIEW_FILENAME = "public-run-review.md"
_PUBLISH_INTENT_FILENAME = "draft-publish-intent.json"


@dataclass(frozen=True)
class DraftPublishIntent:
    path: Path
    artifact_path: Path
    visibility: str
    title: str
    requires_confirmation: bool
    public_publish_requires_confirmation: bool


@dataclass(frozen=True)
class DraftReviewPublishResult:
    url: str
    page_id: str
    stdout: str
    stderr: str
    execution_log: list[dict[str, Any]]


def write_public_draft_publish_intent(
    *,
    run_dir: Path,
    profile_id: str,
) -> DraftPublishIntent:
    """Write the Draft review-page intent consumed by the draft-cli skill.

    The intent authorizes a downstream agent to create, append, and publish the Draft
    public page for the generated run review. Any later public web publication outside
    Draft remains a separate confirmation-gated boundary.
    """
    artifact_path = run_dir / _RUN_REVIEW_FILENAME
    if not artifact_path.exists():
        raise FileNotFoundError(f"Run review not found: {artifact_path}")
    title = _derive_page_title(artifact_path=artifact_path, profile_id=profile_id)
    intent_path = run_dir / _PUBLISH_INTENT_FILENAME
    payload = {
        "intent": "publish_markdown_to_draft_public_page",
        "artifact_type": "founder_signal_run_review",
        "visibility": "draft_public_preview",
        "profile_id": profile_id,
        "title": title,
        "artifact_path": str(artifact_path),
        "public_run_review_path": str(artifact_path),
        "draft_cli_skill": "toliuweijing/draft-cli",
        "requires_confirmation": False,
        "auto_publish_on_success": True,
        "draft_public_publish_requires_confirmation": False,
        "external_public_publish_requires_confirmation": True,
        "operator_instruction": (
            "After a successful Founder Signal test or scheduled run, use the "
            "draft CLI supplied by the required draft-cli skill installed in the agent "
            "workspace to create a Draft page, append public-run-review.md, publish the page, "
            "and return the resulting public Draft URL without asking for additional "
            "approval. Ask for explicit confirmation only before any later public web "
            "publication outside the Draft review surface."
        ),
        "status": "public_publish_requested",
    }
    intent_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    return DraftPublishIntent(
        path=intent_path,
        artifact_path=artifact_path,
        visibility="draft_public_preview",
        title=title,
        requires_confirmation=False,
        public_publish_requires_confirmation=False,
    )


def publish_daily_review_to_draft(
    *,
    intent: DraftPublishIntent,
    workspace_root: Path,
    runner: Callable[[list[str], Path], subprocess.CompletedProcess[str]] | None = None,
) -> DraftReviewPublishResult:
    """Create, append, and publicly publish the generated run review in Draft."""
    effective_runner = runner or _run_command
    stdout_parts: list[str] = []
    stderr_parts: list[str] = []
    execution_log: list[dict[str, Any]] = []

    status = _run_json_command(
        ["draft", "status", "--json"],
        cwd=workspace_root,
        runner=effective_runner,
        stdout_parts=stdout_parts,
        stderr_parts=stderr_parts,
        execution_log=execution_log,
        allow_failure=True,
    )
    if str(status.get("state") or "").strip() == "DAEMON_OFFLINE":
        _run_json_command(
            ["draft", "start-server", "--mode", "workspace", "--workspace", str(workspace_root)],
            cwd=workspace_root,
            runner=effective_runner,
            stdout_parts=stdout_parts,
            stderr_parts=stderr_parts,
            execution_log=execution_log,
            allow_failure=False,
        )
        status = _run_json_command(
            ["draft", "status", "--json"],
            cwd=workspace_root,
            runner=effective_runner,
            stdout_parts=stdout_parts,
            stderr_parts=stderr_parts,
            execution_log=execution_log,
            allow_failure=True,
        )
    if str(status.get("state") or "").strip() == "BROWSER_NOT_CONNECTED":
        _run_json_command(
            ["draft", "daemon"],
            cwd=workspace_root,
            runner=effective_runner,
            stdout_parts=stdout_parts,
            stderr_parts=stderr_parts,
            execution_log=execution_log,
            allow_failure=False,
        )

    create_payload = _run_json_command(
        ["draft", "page", "create", intent.title, "--json"],
        cwd=workspace_root,
        runner=effective_runner,
        stdout_parts=stdout_parts,
        stderr_parts=stderr_parts,
        execution_log=execution_log,
        allow_failure=False,
    )
    page_id = _find_first_string(
        create_payload,
        {"page_id", "pageId", "id", "document_id", "documentId"},
    )
    if not page_id:
        raise RuntimeError("Draft CLI did not return a page ID from page create.")

    content = intent.artifact_path.read_text(encoding="utf-8")
    _run_json_command(
        ["draft", "page", "append", page_id, content, "--json"],
        cwd=workspace_root,
        runner=effective_runner,
        stdout_parts=stdout_parts,
        stderr_parts=stderr_parts,
        execution_log=execution_log,
        allow_failure=False,
    )

    publish_payload = _run_json_command(
        ["draft", "page", "publish", page_id, "--json"],
        cwd=workspace_root,
        runner=effective_runner,
        stdout_parts=stdout_parts,
        stderr_parts=stderr_parts,
        execution_log=execution_log,
        allow_failure=False,
    )
    url = _find_first_string(
        publish_payload,
        {
            "public_url",
            "publicUrl",
            "preview_url",
            "previewUrl",
            "url",
            "page_url",
            "pageUrl",
            "href",
            "permalink",
        },
    )
    if not url:
        raise RuntimeError("Draft CLI did not return a public Draft page URL.")
    return DraftReviewPublishResult(
        url=url,
        page_id=page_id,
        stdout="\n".join(part for part in stdout_parts if part).strip(),
        stderr="\n".join(part for part in stderr_parts if part).strip(),
        execution_log=execution_log,
    )


def _derive_page_title(*, artifact_path: Path, profile_id: str) -> str:
    for line in artifact_path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped.startswith("#"):
            heading = stripped.lstrip("#").strip()
            if heading:
                return heading
    return f"Founder Signal Run Review ({profile_id})"


def _run_command(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
    return subprocess.run(command, cwd=cwd, check=False, capture_output=True, text=True)


def _run_json_command(
    command: list[str],
    *,
    cwd: Path,
    runner: Callable[[list[str], Path], subprocess.CompletedProcess[str]],
    stdout_parts: list[str],
    stderr_parts: list[str],
    execution_log: list[dict[str, Any]],
    allow_failure: bool,
) -> dict[str, Any]:
    try:
        result = runner(command, cwd)
    except FileNotFoundError as exc:
        raise RuntimeError("Draft CLI is not installed or not available on PATH.") from exc
    stdout = str(result.stdout or "").strip()
    stderr = str(result.stderr or "").strip()
    stdout_parts.append(stdout)
    stderr_parts.append(stderr)
    execution_log.append(
        {
            "command": _redacted_command(command),
            "returncode": result.returncode,
            "stdout": stdout,
            "stderr": stderr,
        }
    )
    if result.returncode != 0 and not allow_failure:
        detail = stderr or stdout or f"exit code {result.returncode}"
        raise RuntimeError(f"Draft CLI command failed: {' '.join(command)}: {detail}")
    if not stdout:
        return {}
    try:
        payload = json.loads(stdout)
    except json.JSONDecodeError:
        if result.returncode != 0 and allow_failure:
            return {}
        raise RuntimeError(f"Draft CLI command did not return JSON: {' '.join(command)}") from None
    return payload if isinstance(payload, dict) else {}


def _redacted_command(command: list[str]) -> list[str]:
    if len(command) >= 5 and command[:3] == ["draft", "page", "append"]:
        return command[:4] + ["<public-run-review.md content>"] + command[5:]
    return command


def _find_first_string(value: Any, keys: set[str]) -> str:
    if isinstance(value, dict):
        for key, item in value.items():
            if key in keys and str(item or "").strip():
                return str(item).strip()
        for item in value.values():
            found = _find_first_string(item, keys)
            if found:
                return found
    if isinstance(value, list):
        for item in value:
            found = _find_first_string(item, keys)
            if found:
                return found
    return ""