Workrun 进度更新:从理念到解决具体问题

前不久我发了一篇文章《如果有一个本地优先的 Workflow 工具,你们团队会愿意用吗?》,和大家聊了聊我对本地优先工作流工具的一些构想。

这段时间我一直在埋头推进这个项目。今天想跟大家分享一个对我来说蛮重要的阶段性进展:Workrun 的核心功能建设已经初步成型,并且开始具备解决真实、具体问题的能力了。

都说"吃自己的狗粮(Eat your own dog food)"是检验工具好不好用最好的方式。就在今天,我直接使用 Workrun 的 Workflow 编排功能,成功完成了我 "另一个项目"的发布版本(Release Version)流程

"另一个项目"github.com/1111mp/sync...

一次真实的"狗粮"实战

以往在发布一个新版本时,往往需要经历不少琐碎的步骤(例如确认改动、跑检查和构建、整理发布内容、核对产物、发布前再确认一次等)。虽然单独看每一步都不难,但手动串联起来既繁琐又容易出错。

今天我没有使用传统的脚本或手动流程,而是直接打开 Workrun,把这一整条发布链路用流程编排的方式搭了出来:

  • 需要执行确定性操作的部分,仍然交给本地代码和项目脚本;
  • 需要理解上下文的部分,交给 Agent,例如整理改动、生成发布说明;
  • 到关键步骤时暂停,由人确认后再继续;
  • 整个执行过程都能看到节点状态、日志和输出。

演示视频

这条流程目前主要由 6 个节点组成:

  1. 开始节点

    运行时输入本次要发布的版本号,例如 0.1.3-rc.1

  2. 发布前准备(App)

    这是一个本地 Python App,负责读取 Synclan 本地仓库的发布上下文:拉取远程分支和标签、找到上一个正式版本、收集这期间的提交和文件变更,同时检查工作区是否干净、版本文件是否一致。

    它只读取仓库信息,不修改代码、不创建提交,也不打 tag。最后会把这些信息写入 release_context,供后续节点使用。
    查看「发布前准备」App 的 main.py(完整代码)

    python 复制代码
    from __future__ import annotations
    
    import json
    import re
    import subprocess
    import sys
    from pathlib import Path
    from typing import Any, TypeAlias
    
    from workrun_sdk import process
    
    STABLE_TAG_PATTERN = re.compile(r"^v\d+\.\d+\.\d+$")
    
    JsonValue: TypeAlias = (
        None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
    )
    JsonObject: TypeAlias = dict[str, JsonValue]
    
    
    def log(message: str) -> None:
        """向工作流日志输出执行进度,避免影响节点的结构化输出。"""
        print(f"[release-context] {message}", file=sys.stderr, flush=True)
    
    
    def run_git(repo: Path, *args: str) -> str:
        result = subprocess.run(
            ["git", "-C", str(repo), *args],
            capture_output=True,
            text=True,
            check=False,
        )
    
        if result.returncode != 0:
            message = result.stderr.strip() or result.stdout.strip()
            raise RuntimeError(f"Git 命令执行失败:git {' '.join(args)}\n{message}")
    
        return result.stdout
    
    
    def resolve_commit(repo: Path, ref: str) -> str:
        return run_git(repo, "rev-parse", "--verify", f"{ref}^{{commit}}").strip()
    
    
    def find_latest_stable_tag(repo: Path, source_ref: str) -> str:
        tags = run_git(
            repo,
            "tag",
            "--merged",
            source_ref,
            "--sort=-version:refname",
        ).splitlines()
    
        for tag in tags:
            if STABLE_TAG_PATTERN.fullmatch(tag):
                return tag
    
        raise RuntimeError(
            f"在 {source_ref} 的历史中未找到正式版本标签,"
            "期望格式为 v主版本.次版本.修订版本,例如 v0.1.2。"
        )
    
    
    def collect_commits(
        repo: Path,
        revision_range: str,
        max_commits: int,
    ) -> tuple[list[dict[str, str]], bool]:
        output = run_git(
            repo,
            "log",
            "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI",
            f"--max-count={max_commits + 1}",
            revision_range,
        )
    
        commits: list[dict[str, str]] = []
    
        for line in output.splitlines():
            sha, short_sha, subject, author, authored_at = line.split("\x1f", maxsplit=4)
            commits.append(
                {
                    "sha": sha,
                    "short_sha": short_sha,
                    "subject": subject,
                    "author": author,
                    "authored_at": authored_at,
                }
            )
    
        return commits[:max_commits], len(commits) > max_commits
    
    
    def collect_changed_files(repo: Path, revision_range: str) -> list[dict[str, str]]:
        files: list[dict[str, str]] = []
    
        for line in run_git(repo, "diff", "--name-status", revision_range).splitlines():
            parts = line.split("\t")
            status = parts[0]
    
            if status.startswith(("R", "C")) and len(parts) == 3:
                files.append(
                    {
                        "status": status,
                        "previous_path": parts[1],
                        "path": parts[2],
                    }
                )
            elif len(parts) == 2:
                files.append(
                    {
                        "status": status,
                        "path": parts[1],
                    }
                )
    
        return files
    
    
    def read_json_version(path: Path) -> str | None:
        if not path.is_file():
            return None
    
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            return None
    
        version = data.get("version")
        return version if isinstance(version, str) else None
    
    
    def read_toml_version(path: Path) -> str | None:
        if not path.is_file():
            return None
    
        content = path.read_text(encoding="utf-8")
        match = re.search(
            r'(?ms)^\[package\]\s*$.*?^version\s*=\s*"([^"]+)"',
            content,
        )
    
        if match:
            return match.group(1)
    
        match = re.search(
            r'(?ms)^\[project\]\s*$.*?^version\s*=\s*"([^"]+)"',
            content,
        )
        return match.group(1) if match else None
    
    
    def collect_versions(repo: Path) -> list[dict[str, str | None]]:
        readers = [
            ("package.json", read_json_version),
            ("pyproject.toml", read_toml_version),
            ("Cargo.toml", read_toml_version),
            ("src-tauri/Cargo.toml", read_toml_version),
            ("src-tauri/tauri.conf.json", read_json_version),
        ]
    
        versions: list[dict[str, str | None]] = []
    
        for relative_path, reader in readers:
            version = reader(repo / relative_path)
            if version is not None:
                versions.append({"path": relative_path, "version": version})
    
        return versions
    
    
    def find_changelog(repo: Path) -> dict[str, str | None]:
        candidates = [
            "CHANGELOG.md",
            "changelog.md",
            "UPDATELOG.md",
            "CHANGELOG",
        ]
    
        for relative_path in candidates:
            path = repo / relative_path
            if not path.is_file():
                continue
    
            content = path.read_text(encoding="utf-8")
            heading = re.search(r"^##\s+(v?\S+)", content, re.MULTILINE)
    
            return {
                "path": relative_path,
                "latest_heading": heading.group(1) if heading else None,
            }
    
        return {"path": None, "latest_heading": None}
    
    
    def main() -> None:
        raw_input = sys.stdin.read()
        log("开始收集发布上下文。")
    
        try:
            state: dict[str, Any] = json.loads(raw_input) if raw_input else {}
        except json.JSONDecodeError as error:
            raise SystemExit(f"工作流输入不是有效 JSON:{error}") from error
    
        repo_path = state.get("repo_path")
        if not isinstance(repo_path, str) or not repo_path.strip():
            raise SystemExit("缺少必填字段 repo_path。")
    
        repo = Path(repo_path).expanduser().resolve()
    
        if not repo.is_dir():
            raise SystemExit(f"仓库路径不存在或不是目录:{repo}")
    
        try:
            log(f"验证 Git 仓库:{repo}")
            if run_git(repo, "rev-parse", "--is-inside-work-tree").strip() != "true":
                raise RuntimeError("指定路径不是 Git 工作区。")
    
            target_version = state.get("target_version")
            remote = state.get("remote", "origin")
            source_ref = state.get("source_ref", f"{remote}/dev")
            target_ref = state.get("target_ref", f"{remote}/main")
            max_commits = state.get("max_commits", 250)
    
            if not isinstance(target_version, str) or not re.fullmatch(
                r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?",
                target_version,
            ):
                raise SystemExit(
                    "target_version 必须是有效版本号,例如 0.1.3 或 0.1.3-rc.1。"
                )
            if not isinstance(remote, str) or not remote:
                raise RuntimeError("remote 必须是有效的远程仓库名称。")
            if not isinstance(source_ref, str) or not source_ref:
                raise RuntimeError("source_ref 必须是有效的 Git 引用。")
            if not isinstance(target_ref, str) or not target_ref:
                raise RuntimeError("target_ref 必须是有效的 Git 引用。")
            if not isinstance(max_commits, int) or not 1 <= max_commits <= 1000:
                raise RuntimeError("max_commits 必须是 1 到 1000 之间的整数。")
    
            # 只更新远程跟踪分支与 tags,不会修改工作区文件。
            log(f"同步远程引用和标签:{remote}")
            run_git(repo, "fetch", "--tags", "--prune", remote)
    
            log(f"解析来源和目标引用:{source_ref}、{target_ref}")
            source_sha = resolve_commit(repo, source_ref)
            target_sha = resolve_commit(repo, target_ref)
            previous_tag = find_latest_stable_tag(repo, source_ref)
            previous_tag_sha = resolve_commit(repo, previous_tag)
            revision_range = f"{previous_tag}..{source_ref}"
            log(f"上一个正式版本:{previous_tag};比较范围:{revision_range}")
    
            blocking_reasons = []
    
            log(f"收集提交记录,最多保留 {max_commits} 条。")
            commits, commits_truncated = collect_commits(
                repo,
                revision_range,
                max_commits,
            )
            changed_files = collect_changed_files(repo, revision_range)
            worktree_changes = [
                line
                for line in run_git(repo, "status", "--porcelain=v1").splitlines()
                if line
            ]
            version_files = collect_versions(repo)
            version_values = {item["version"] for item in version_files}
            log(
                f"已发现 {len(commits)} 条提交、{len(changed_files)} 个变更文件、"
                f"{len(version_files)} 个版本文件。"
            )
    
            if worktree_changes:
                blocking_reasons.append("工作区存在未提交修改。")
    
            if not version_files:
                blocking_reasons.append("未找到可识别的版本文件。")
            elif len(version_values) != 1 or None in version_values:
                blocking_reasons.append("版本文件中的版本号不一致。")
    
            if not commits:
                blocking_reasons.append("上一个正式版本后没有新的提交。")
    
            if blocking_reasons:
                log(
                    f"发布上下文存在 {len(blocking_reasons)} 个阻塞项:{';'.join(blocking_reasons)}"
                )
            else:
                log("发布上下文检查通过,可用于生成发布说明。")
    
            release_context = {
                "target_version": target_version,
                "repository": {
                    "path": str(repo),
                    "remote": remote,
                    "source_ref": source_ref,
                    "source_sha": source_sha,
                    "target_ref": target_ref,
                    "target_sha": target_sha,
                    "worktree_clean": not worktree_changes,
                    "worktree_changes": worktree_changes,
                },
                "previous_release": {
                    "tag": previous_tag,
                    "commit_sha": previous_tag_sha,
                    "revision_range": revision_range,
                },
                "commits": commits,
                "commits_truncated": commits_truncated,
                "changed_files": changed_files,
                "diff_stat": run_git(repo, "diff", "--stat", revision_range).rstrip(),
                "version_files": version_files,
                "version_files_consistent": (
                    len(version_values) == 1 and bool(version_files)
                ),
                "changelog": find_changelog(repo),
                "release_notes_ready": not blocking_reasons,
                "blocking_reasons": blocking_reasons,
            }
    
            log(f"收集完成:{revision_range},发布来源提交:{source_sha[:12]}。")
    
            process.result({"release_context": release_context})
    
        except RuntimeError as error:
            log(f"收集失败:{error}")
            raise SystemExit(f"收集发布上下文失败:{error}") from error
    
    
    if __name__ == "__main__":
        main()
  3. 是否准备好发布(If / Else)

    根据 release_context.release_notes_ready 条件来判断。

    如果工作区有未提交修改、版本号不一致,或者上个正式版本后没有新增提交,流程会直接结束,不进入后面的发布步骤。

  4. 生成发布说明(Agent)

    Agent 读取前一步收集到的提交记录和文件变更,生成 Markdown 格式的发布说明。这里限制得比较严格:只允许写明确的新功能和问题修复;每一条都要附上对应的提交短 SHA,不能凭空补充内容。

    Agent 生成的 Release notes 会输出到 Output key release_notes,提供下个节点读取。

  5. 确认发布说明(Human Review)

    发布说明生成后,流程会暂停。我可以在这里查看发布上下文、修改说明,并决定批准还是拒绝。

    发布这种会影响远程仓库的操作,最后还是应该由人来确认。

  6. 执行发布(App)

    审核通过后,另一个本地 Python App 会执行真正的发布动作:更新版本文件和更新日志、运行必要的检查、创建 release commit、推送分支、fast-forward 合并到目标分支,最后创建并推送版本 tag。

    在执行前它还会再次校验:审批后源分支或目标分支有没有变化、目标 tag 是否已存在、工作区是否干净。任何一项不满足都会停止,而不是继续发布。
    查看「执行发布」App 的 main.py(完整代码)

    python 复制代码
    from __future__ import annotations
    
    import json
    import re
    import subprocess
    import sys
    from datetime import datetime
    from pathlib import Path
    from typing import Any
    
    from workrun_sdk import confirm, process
    
    
    def log(message: str) -> None:
        timestamp = datetime.now().astimezone().strftime("%H:%M:%S")
        print(f"[{timestamp}] [release] {message}", flush=True)
    
    
    def confirm_sensitive_operation(operation: str, detail: str) -> None:
        """Require an explicit approval before a release operation changes state."""
        message = f"即将{operation}:\n\n{detail}\n\n取消将立即中止本次发布。"
        if not confirm(
            message,
            title="确认敏感发布操作",
            confirm_label="继续执行",
        ):
            raise SystemExit(f"用户取消了操作:{operation}。发布已中止。")
    
    
    def git(repo: Path, *args: str) -> str:
        result = subprocess.run(
            ["git", "-C", str(repo), *args],
            capture_output=True,
            text=True,
            check=False,
        )
    
        if result.returncode != 0:
            message = result.stderr.strip() or result.stdout.strip()
            raise RuntimeError(f"git {' '.join(args)} failed:\n{message}")
    
        return result.stdout
    
    
    def cargo(repo: Path, *args: str) -> str:
        result = subprocess.run(
            ["cargo", *args],
            cwd=repo,
            capture_output=True,
            text=True,
            check=False,
        )
    
        if result.returncode != 0:
            message = result.stderr.strip() or result.stdout.strip()
            raise RuntimeError(f"cargo {' '.join(args)} failed:\n{message}")
    
        return result.stdout
    
    
    def sha(repo: Path, ref: str) -> str:
        return git(repo, "rev-parse", "--verify", f"{ref}^{{commit}}").strip()
    
    
    def branch_name(remote_ref: str, remote: str) -> str:
        prefix = f"{remote}/"
    
        if not remote_ref.startswith(prefix):
            raise RuntimeError(
                f"发布分支必须是 {prefix} 开头的远程引用,当前为:{remote_ref}"
            )
    
        branch = remote_ref.removeprefix(prefix)
        if not branch:
            raise RuntimeError("发布分支不能为空。")
    
        return branch
    
    
    def required_string(value: object, name: str) -> str:
        if not isinstance(value, str) or not value:
            raise SystemExit(f"发布上下文缺少必要信息:{name}")
    
        return value
    
    
    def switch_branch(repo: Path, remote: str, branch: str) -> None:
        local_ref = f"refs/heads/{branch}"
        exists = (
            subprocess.run(
                ["git", "-C", str(repo), "show-ref", "--verify", "--quiet", local_ref],
                check=False,
            ).returncode
            == 0
        )
    
        if exists:
            git(repo, "switch", branch)
            git(repo, "merge", "--ff-only", f"{remote}/{branch}")
        else:
            git(repo, "switch", "--track", "-c", branch, f"{remote}/{branch}")
    
    
    def release_notes_from_state(state: dict[str, Any]) -> str:
        notes = state.get("release_notes")
    
        if not isinstance(notes, str) or not notes.strip():
            raise RuntimeError("未找到已审批的发布说明。")
    
        return notes.strip()
    
    
    def replace_version(path: Path, version: str) -> None:
        content = path.read_text(encoding="utf-8")
    
        if path.suffix == ".json":
            updated, count = re.subn(
                r'("version"\s*:\s*)"[^"]+"',
                rf'\g<1>"{version}"',
                content,
                count=1,
            )
        elif path.suffix == ".toml":
            updated, count = re.subn(
                r'(?ms)(^\[package\]\s*$.*?^version\s*=\s*)"[^"]+"',
                rf'\g<1>"{version}"',
                content,
                count=1,
            )
        else:
            raise RuntimeError(f"不支持更新版本文件:{path}")
    
        if count != 1:
            raise RuntimeError(f"无法在文件中定位版本号:{path}")
    
        path.write_text(updated, encoding="utf-8")
    
    
    def main() -> None:
        log("开始读取发布上下文。")
        state = json.loads(sys.stdin.read() or "{}")
        context = state.get("release_context")
    
        if not isinstance(context, dict):
            raise SystemExit("未找到 release_context。")
    
        repository = context.get("repository")
        if not isinstance(repository, dict):
            raise SystemExit("release_context.repository 无效。")
    
        repo = Path(str(repository.get("path", ""))).expanduser().resolve()
        remote = required_string(repository.get("remote"), "remote")
        source_ref = required_string(repository.get("source_ref"), "source_ref")
        target_ref = required_string(repository.get("target_ref"), "target_ref")
        approved_source_sha = required_string(repository.get("source_sha"), "source_sha")
        approved_target_sha = required_string(repository.get("target_sha"), "target_sha")
        version = required_string(context.get("target_version"), "target_version")
        dry_run = context.get("dry_run") is True
    
        if not repo.is_dir():
            raise SystemExit(f"仓库路径不存在:{repo}")
    
        log(
            f"发布准备就绪:{source_ref} -> {target_ref},版本 {version}"
            f"(dry_run={dry_run})。"
        )
    
        if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version):
            raise SystemExit(f"发布版本号无效:{version}")
    
        tag = f"v{version}"
        source_branch = branch_name(source_ref, remote)
        target_branch = branch_name(target_ref, remote)
        release_notes = release_notes_from_state(state)
        log(f"已获取发布说明({len(release_notes)} 个字符)。")
    
        if not release_notes.startswith(f"## {tag}"):
            raise SystemExit(
                f"发布说明标题必须以 `## {tag}` 开始,以确保说明与目标版本一致。"
            )
    
        try:
            # 检查阶段:不允许脏工作区,也不允许审批后分支发生变化。
            log("检查工作区状态。")
            if git(repo, "status", "--porcelain=v1").strip():
                raise RuntimeError("工作区存在未提交修改,已停止发布。")
    
            log(f"从远程 {remote} 获取分支和标签。")
            git(repo, "fetch", "--tags", "--prune", remote)
    
            if sha(repo, source_ref) != approved_source_sha:
                raise RuntimeError(
                    "发布来源分支在审批后已更新,请重新收集上下文并重新审批。"
                )
    
            if sha(repo, target_ref) != approved_target_sha:
                raise RuntimeError(
                    "发布目标分支在审批后已更新,请重新收集上下文并重新审批。"
                )
    
            tag_exists = (
                subprocess.run(
                    [
                        "git",
                        "-C",
                        str(repo),
                        "rev-parse",
                        "-q",
                        "--verify",
                        f"refs/tags/{tag}",
                    ],
                    capture_output=True,
                    text=True,
                    check=False,
                ).returncode
                == 0
            )
    
            if tag_exists:
                raise RuntimeError(f"标签 {tag} 已存在,已停止发布。")
    
            # 在来源分支写入 release commit。
            log(f"切换到来源分支 {source_branch}。")
            switch_branch(repo, remote, source_branch)
    
            version_files = context.get("version_files")
            if not isinstance(version_files, list) or not version_files:
                raise RuntimeError("未找到待更新的版本文件。")
    
            paths_to_commit: list[str] = []
            cargo_manifests: list[str] = []
    
            for item in version_files:
                if not isinstance(item, dict) or not isinstance(item.get("path"), str):
                    continue
    
                relative_path = item["path"]
                version_path = repo / relative_path
    
                if not version_path.is_file():
                    raise RuntimeError(f"版本文件不存在:{relative_path}")
    
                replace_version(version_path, version)
                paths_to_commit.append(relative_path)
                log(f"已更新版本文件:{relative_path}")
    
                if version_path.name == "Cargo.toml":
                    cargo_manifests.append(relative_path)
    
            # Cargo.toml 的 package version 也记录在 Cargo.lock 中。运行 cargo check
            # 以刷新锁文件,并将所在 workspace 的锁文件一起纳入 release commit。
            for manifest_path in cargo_manifests:
                log(f"读取 Cargo workspace:{manifest_path}")
                metadata = json.loads(
                    cargo(
                        repo,
                        "metadata",
                        "--no-deps",
                        "--format-version=1",
                        "--manifest-path",
                        manifest_path,
                    )
                )
                workspace_root = metadata.get("workspace_root")
                if not isinstance(workspace_root, str):
                    raise RuntimeError(f"无法确定 Cargo workspace:{manifest_path}")
    
                log(f"运行 cargo check:{manifest_path}")
                cargo(repo, "check", "--manifest-path", manifest_path)
                lock_path = Path(workspace_root) / "Cargo.lock"
    
                if lock_path.is_file():
                    try:
                        relative_lock_path = str(lock_path.relative_to(repo))
                    except ValueError as error:
                        raise RuntimeError(f"Cargo.lock 不在仓库内:{lock_path}") from error
    
                    if relative_lock_path not in paths_to_commit:
                        paths_to_commit.append(relative_lock_path)
                    log(f"已将锁文件纳入提交:{relative_lock_path}")
    
            changelog = context.get("changelog")
            changelog_path = changelog.get("path") if isinstance(changelog, dict) else None
    
            if not isinstance(changelog_path, str) or not changelog_path:
                raise RuntimeError("未找到更新日志文件。")
    
            update_log = repo / changelog_path
            new_content = f"{release_notes.rstrip()}\n"
            log(f"写入发布说明到:{update_log}({len(new_content)} 个字符)。")
            written = update_log.write_text(
                new_content,
                encoding="utf-8",
            )
            if update_log.read_text(encoding="utf-8") != new_content:
                raise RuntimeError(f"写入发布说明后校验失败:{update_log}")
            log(f"发布说明写入并校验成功({written} 个字符)。")
            paths_to_commit.append(changelog_path)
    
            if dry_run:
                log("Dry run 完成:本地文件已更新,未暂存、提交、推送或创建标签。")
                return
    
            log(f"暂存发布文件:{', '.join(paths_to_commit)}")
            git(repo, "add", "--", *paths_to_commit)
    
            if not git(repo, "diff", "--cached", "--name-only").strip():
                raise RuntimeError("没有检测到可创建的发布修改。")
    
            commit_message = f"chore(release): {tag}"
            log(f"创建发布提交:{commit_message}")
            git(repo, "commit", "-m", commit_message)
            release_commit_sha = sha(repo, "HEAD")
    
            log(f"推送来源分支 {source_branch}。")
            git(repo, "push", remote, f"HEAD:refs/heads/{source_branch}")
    
            # 合并到目标分支。仅允许 fast-forward,绝不生成意外 merge commit 或强推。
            log(f"切换到目标分支 {target_branch} 并 fast-forward 合并。")
            switch_branch(repo, remote, target_branch)
            git(repo, "merge", "--ff-only", source_branch)
            main_release_sha = sha(repo, "HEAD")
    
            log(f"推送目标分支 {target_branch}。")
            git(repo, "push", remote, f"HEAD:refs/heads/{target_branch}")
    
            # Tag 必须创建在已推送至目标分支的 release commit 上。
            confirm_sensitive_operation(
                "创建本地发布标签",
                f"{tag} -> {main_release_sha}",
            )
            log(f"创建并推送标签 {tag}。")
            git(repo, "tag", "-a", tag, "-m", f"Release {tag}", main_release_sha)
            git(repo, "push", remote, f"refs/tags/{tag}")
    
            print(f"发布完成:{tag} -> {main_release_sha}")
    
            process.result(
                {
                    "release_result": {
                        "version": version,
                        "tag": tag,
                        "source_branch": source_branch,
                        "target_branch": target_branch,
                        "release_commit_sha": release_commit_sha,
                        "tag_commit_sha": main_release_sha,
                    }
                }
            )
    
        except RuntimeError as error:
            raise SystemExit(f"执行发布失败:{error}") from error
    
    
    if __name__ == "__main__":
        main()

最终,这条流程顺利跑完,并完成了 Synclan 的一次版本发布。

这件事对我来说还挺重要的。不是因为它说明"以后发布可以一键全自动",而是因为 Workrun 终于开始帮我处理自己的日常工作了。

Workrun 现在能做什么

借这次发布流程,也简单汇报一下目前已经做出来的能力:

  • 在画布上编排 Agent、本地 Python App、远程 Agent 和流程控制节点;
  • 让 Agent 调用本地 Tool App 或 MCP Server 中的工具;
  • 使用 If/Else、Switch 根据运行结果走不同分支;
  • 在流程中向用户提问,或在关键步骤进入人工审核;
  • 以任务或对话的方式运行工作流,并实时查看模型输出、工具调用和脚本日志;
  • 把本地 Python 项目作为可复用的 App 接进工作流,而不是把所有逻辑塞进提示词里。

Workrun 目前仍然是一个本地优先的桌面工具:工作流、配置和本地代码都主要留在自己的设备上。涉及模型服务、远程 Agent 或 MCP 服务时,数据会按实际连接发送出去,这部分也需要由使用者自己判断和配置。

发现短板:真实的痛点才是改进的方向

当然,吃狗粮的过程不仅是为了验证功能,更是为了找茬。

通过这次实际 Workflow 的编排与真实解决问题的过程,我也直观地感受到了目前 Workrun 在一些方面的短板------最明显的是节点之间的数据传递和衔接

现在虽然可以在工作流状态中传递数据(Workflow State),但在实际编排时,哪些数据由哪个节点产出、下一个节点会读取什么、数据格式是否符合预期,还不够直观。有些时候需要自己记住字段名和上下文,流程一复杂就容易增加理解和调试成本。

另外,节点配置、运行时调试和失败后的处理,也还有不少可以继续打磨的地方。真正拿它做事之后才会发现,画布能连起来只是第一步;让数据流动清楚、让问题容易定位、让流程可以放心反复运行,才是更重要的部分。

不过,能明确看到这些短板反而是一件好事,这为我接下来的迭代指明了非常清晰的方向。

接下来

从上一篇文章里讨论的理念与设想,到今天能真正用它跑通另一个项目的发布流程,Workrun 总算跨过了"只能演示"的阶段。

后面还会继续打磨工作流编辑和运行体验,补更多节点和工具集成,也把调试、错误处理、复用与分享这些事情做得更完整一些。

项目还在早期,欢迎试用、提建议或者一起参与:

👉 GitHub 仓库: github.com/1111mp/work...

感谢大家的关注与支持!

相关推荐
番茄不是西红柿kk1 小时前
deepseek-harness跨平台桌面端二开项目(四)安装完点“启动应用“却没反应?背后是 sidecar 的冷启动预算与杀软博弈
人工智能·agent·deepseek
武子康1 小时前
DeepSeek Harness 为什么不用 messages 数组保存一切
人工智能·llm·agent
海市公约2 小时前
独立开发项目ColdChain Guard冷盾:冷链温控合规智能分析系统项目复盘
mongodb·agent·rag·全栈开发项目
pqpo2 小时前
Agent Team 的上下文工程设计:如何组织和共享上下文
agent·ai编程
leeyi2 小时前
Agent 间 Transfer 交接:用户在不同 Agent 间无缝切换(第93篇-E79)
人工智能·aigc·agent
很楠爱上2 小时前
从“AI 看合同”到可举证的合同决策链:CounterClause(对薄) 的架构设计与工程实践
人工智能·经验分享·python·学习·agent
tachibana22 小时前
初识智能体
人工智能·ai·大模型·llm·agent
狂师3 小时前
AI 测试 | 把 UI 自动化测试执行固化成五步流程,这套AI Skill 思路可以直接抄
人工智能·agent·测试
苏灿烤鱼3 小时前
连庄+2,729,空降榜眼只+440
rust·openai·agent