From dee348a35802ce5d73d5f6d70a9200c5b172ebf7 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 19:53:26 +0800 Subject: [PATCH 01/15] fix: publish desktop assets with project releases --- .github/workflows/desktop-release.yml | 104 ++++++++---------- .../05-release-and-updates.md | 14 ++- package.json | 2 +- scripts/check-desktop-release.mjs | 23 +++- scripts/resolve-desktop-release.mjs | 102 +++++++++++++++++ scripts/resolve-desktop-release.test.mjs | 100 +++++++++++++++++ scripts/stage-desktop-release.mjs | 6 +- scripts/stage-desktop-release.test.mjs | 34 ++++-- 8 files changed, 304 insertions(+), 81 deletions(-) create mode 100644 scripts/resolve-desktop-release.mjs create mode 100644 scripts/resolve-desktop-release.test.mjs diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 26349d6..5137f29 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -3,14 +3,14 @@ name: Desktop Release on: push: tags: - - "desktop-v*" + - "v*" workflow_dispatch: inputs: version: - description: "版本号或 desktop-v 标签,例如 0.28.0-beta.1" + description: "版本号或 v 标签,例如 v0.28.0、0.28.0-beta.1" required: true source_ref: - description: "构建来源 ref;留空使用 main" + description: "构建来源 ref;留空使用对应 v 标签" required: false default: "" channel: @@ -18,9 +18,9 @@ on: required: true type: choice options: - - beta - stable - default: beta + - beta + default: stable publish: description: "是否创建或更新 GitHub Release;手动运行默认只上传 CI artifact" required: true @@ -31,10 +31,6 @@ permissions: contents: write actions: read -concurrency: - group: desktop-release-${{ github.ref }} - cancel-in-progress: false - env: NODE_VERSION: "22" PNPM_VERSION: "10.8.1" @@ -51,61 +47,20 @@ jobs: source_ref: ${{ steps.identity.outputs.source_ref }} publish: ${{ steps.identity.outputs.publish }} steps: + - name: Checkout release automation + uses: actions/checkout@v4 + - name: Resolve tag, version and channel id: identity - shell: bash env: EVENT_NAME: ${{ github.event_name }} EVENT_TAG: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} INPUT_VERSION: ${{ github.event.inputs.version || '' }} - INPUT_CHANNEL: ${{ github.event.inputs.channel || 'beta' }} + INPUT_CHANNEL: ${{ github.event.inputs.channel || 'stable' }} INPUT_SOURCE_REF: ${{ github.event.inputs.source_ref || '' }} INPUT_PUBLISH: ${{ github.event.inputs.publish || 'false' }} - run: | - set -euo pipefail - if [[ "$EVENT_NAME" == "push" ]]; then - tag="$EVENT_TAG" - source_ref="${GITHUB_SHA}" - publish=true - else - raw_version="${INPUT_VERSION#desktop-v}" - tag="desktop-v${raw_version}" - source_ref="${INPUT_SOURCE_REF:-main}" - publish="${INPUT_PUBLISH}" - fi - - if [[ ! "$tag" =~ ^desktop-v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then - echo "::error::invalid desktop release tag: $tag" - exit 1 - fi - version="${tag#desktop-v}" - if [[ "$version" == *-* ]]; then - channel=beta - elif [[ "$EVENT_NAME" == push ]]; then - channel=stable - else - channel="${INPUT_CHANNEL:-stable}" - fi - if [[ "$channel" == stable && "$version" == *-* ]]; then - echo "::error::a prerelease version cannot enter stable" - exit 1 - fi - if [[ "$publish" == true && "$channel" == beta && "$version" != *-* ]]; then - echo "::error::a published beta requires a prerelease version" - exit 1 - fi - if [[ "$publish" != true && "$publish" != false ]]; then - echo "::error::publish must be true or false" - exit 1 - fi - - { - echo "tag=$tag" - echo "version=$version" - echo "channel=$channel" - echo "source_ref=$source_ref" - echo "publish=$publish" - } >> "$GITHUB_OUTPUT" + run: node scripts/resolve-desktop-release.mjs build: name: Build ${{ matrix.name }} @@ -202,6 +157,20 @@ jobs: exit 1 } + - name: Verify published source matches release tag + if: needs.resolve.outputs.publish == 'true' + shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")" + source_commit="$(git rev-parse 'HEAD^{commit}')" + test "$source_commit" = "$tag_commit" || { + echo "::error::published desktop source $source_commit does not match $RELEASE_TAG at $tag_commit" + exit 1 + } + - name: Validate macOS signing secrets if: matrix.platform == 'darwin' shell: bash @@ -352,6 +321,9 @@ jobs: needs: [resolve, build] if: needs.resolve.outputs.publish == 'true' && needs.build.result == 'success' runs-on: ubuntu-22.04 + concurrency: + group: desktop-release-publish-${{ needs.resolve.outputs.tag }} + cancel-in-progress: false steps: - name: Checkout source uses: actions/checkout@v4 @@ -387,10 +359,16 @@ jobs: --tag "$RELEASE_TAG" \ --repository "$RELEASE_REPOSITORY" \ --require-all-targets + if [[ -f github-release-assets/checksums.txt ]]; then + test ! -e github-release-assets/desktop-checksums.txt + mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt + fi + test -f github-release-assets/desktop-checksums.txt cp desktop-staged/* github-release-assets/ find github-release-assets -maxdepth 1 -type f -print | sort - - name: Create draft GitHub Release + - name: Ensure GitHub Release exists + id: release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.resolve.outputs.tag }} @@ -400,14 +378,19 @@ jobs: run: | set -euo pipefail if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --target "$SOURCE_REF" --draft=true + is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" + echo "created=false" >> "$GITHUB_OUTPUT" + echo "publish_after_upload=$is_draft" >> "$GITHUB_OUTPUT" + echo "Reusing existing GitHub Release $RELEASE_TAG" else gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$SOURCE_REF" \ - --title "Content Work OS Desktop $RELEASE_TAG" \ + --title "ContentCloud $RELEASE_TAG" \ --generate-notes \ --draft + echo "created=true" >> "$GITHUB_OUTPUT" + echo "publish_after_upload=true" >> "$GITHUB_OUTPUT" fi - name: Upload desktop release assets @@ -419,7 +402,8 @@ jobs: set -euo pipefail gh release upload "$RELEASE_TAG" github-release-assets/* --repo "$GITHUB_REPOSITORY" --clobber - - name: Publish release + - name: Publish draft release + if: steps.release.outputs.publish_after_upload == 'true' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.resolve.outputs.tag }} diff --git a/docs/product/content-work-os-desktop/05-release-and-updates.md b/docs/product/content-work-os-desktop/05-release-and-updates.md index 0a22ea4..d3027f4 100644 --- a/docs/product/content-work-os-desktop/05-release-and-updates.md +++ b/docs/product/content-work-os-desktop/05-release-and-updates.md @@ -54,7 +54,7 @@ desktop/beta/latest.json 桌面端发布 workflow 位于 `.github/workflows/desktop-release.yml`,借鉴 Lime 的发布分层,但只保留 ContentCloud 当前需要的链路: ```text -desktop-vX.Y.Z(-beta.N) +vX.Y.Z(-beta.N) | v resolve (tag / version / channel / source ref) @@ -64,22 +64,24 @@ resolve (tag / version / channel / source ref) +--> Linux x64 --+ | v - aggregate --> draft GitHub Release --> publish + aggregate --> existing/new GitHub Release --> publish ``` 触发约定: -- 推送 `desktop-v0.28.0` 自动构建并发布 `stable` Release。 -- 推送 `desktop-v0.28.0-beta.1` 自动构建并发布 `beta` prerelease。 +- 项目只有一套 `v*` 版本标签。推送 `v0.28.0` 后,桌面安装包与 CLI 资产进入同一个 `v0.28.0` Release。 +- 推送 `v0.28.0-beta.1` 自动构建并发布 `beta` prerelease。 - `workflow_dispatch` 默认只生成 Actions artifact;只有显式打开 `publish` 才会创建或更新 GitHub Release。 -- 手动运行可填写 `source_ref`,但 `apps/desktop/package.json` 的版本必须与输入版本完全一致。 +- 手动运行可填写 `source_ref`;留空时构建对应 `v*` 标签。发布模式强制从不可变的同名标签构建,只有 `publish=false` 的预览 artifact 才允许使用其他 ref。`apps/desktop/package.json` 的版本必须与输入版本完全一致。 +- 对已经存在的 GitHub Release,workflow 只追加或重建带桌面前缀的资产,不会改写 Release 标题、正文或 CLI 资产。 +- 同一标签的 publish job 使用标签级并发锁串行上传,防止自动触发和手动回填交错覆盖资产。 每个平台 job 都执行 `electron-forge make`,随后由 `scripts/stage-desktop-release.mjs`: 1. 只接收 `update-channels.json` 声明的格式,并强制要求 DMG+ZIP、Squirrel installer+NUPKG+RELEASES、DEB+RPM 各自完整。 2. 生成带目标前缀的稳定文件名、目标级 `latest.json` 和 `*-checksums.sha256`。 3. 在 macOS 上执行 `codesign` 与 Gatekeeper assessment,在 Windows 上验证 Authenticode 状态;Linux 仅作为不提供自动更新的预览包。 -4. 在 publish job 复验四个目标、版本和每个文件摘要,汇总为 `desktop--latest.json` 和 `checksums.txt`,再上传 GitHub Release。 +4. 在 publish job 复验四个目标、版本和每个文件摘要,汇总为 `desktop--latest.json` 和 `desktop-checksums.txt`,再上传 GitHub Release。桌面端使用独立校验文件名,避免覆盖 CLI 的 `checksums.txt`。 macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。pnpm 的 build-script 白名单保留在根 `package.json`,release job 还会在 `.pnpm` 的实际解析目录显式执行 `node-gyp rebuild` 并分别检查 `volume.node`、`xattr.node`;不能只构建 hoisted 副本,否则 Forge 仍会在 `appdmg` 加载阶段失败。 diff --git a/package.json b/package.json index 3a960c1..b1d58d3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "package:desktop": "pnpm --dir apps/desktop package", "make:desktop": "pnpm --dir apps/desktop make", "desktop:release:check": "pnpm --dir apps/desktop release:check", - "test:desktop-release": "node --test scripts/stage-desktop-release.test.mjs", + "test:desktop-release": "node --test scripts/*desktop-release.test.mjs", "architecture": "node scripts/check-architecture.mjs", "docs:links": "node scripts/check-doc-links.mjs", "check:plugin": "node scripts/validate-plugin-release.mjs", diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 1d26684..3f5d567 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -17,6 +17,10 @@ const staging = await readFile( resolve(root, "scripts/stage-desktop-release.mjs"), "utf8", ); +const resolver = await readFile( + resolve(root, "scripts/resolve-desktop-release.mjs"), + "utf8", +); const fail = (message) => { console.error(`desktop release check failed: ${message}`); @@ -62,23 +66,40 @@ for (const maker of [ if (forge.includes("electron-builder")) fail("electron-builder must not be introduced beside Electron Forge"); for (const marker of [ - "desktop-v*", + ' - "v*"', "workflow_dispatch:", + "node scripts/resolve-desktop-release.mjs", + "Verify published source matches release tag", + "desktop-release-publish-${{ needs.resolve.outputs.tag }}", "electron-forge make", "actions/upload-artifact@v4", "actions/download-artifact@v4", + "gh release view", "gh release create", + "gh release upload", + "mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt", "CONTENTCLOUD_DESKTOP_SIGN", "node-gyp rebuild", ]) { if (!workflow.includes(marker)) fail(`desktop release workflow is missing ${marker}`); } +if (workflow.includes("desktop-v")) + fail("desktop-only tags must not diverge from the project v* release"); +for (const marker of [ + "published desktop releases must build from", + "inputSourceRef", + "releaseTagPattern", +]) { + if (!resolver.includes(marker)) + fail(`desktop release resolver is missing ${marker}`); +} for (const marker of [ "contentcloud.desktop-update-metadata/1.0", "sha256", "requires verified signing", "aggregate", + "desktop-checksums.txt", ]) { if (!staging.includes(marker)) fail(`desktop release staging is missing ${marker}`); diff --git a/scripts/resolve-desktop-release.mjs b/scripts/resolve-desktop-release.mjs new file mode 100644 index 0000000..5166f3d --- /dev/null +++ b/scripts/resolve-desktop-release.mjs @@ -0,0 +1,102 @@ +import { appendFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const releaseTagPattern = + /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/; + +function parsePublish(value) { + if (value === true || value === "true") return true; + if (value === false || value === "false" || value === "") return false; + throw new Error("publish must be true or false"); +} + +export function resolveDesktopReleaseIdentity({ + eventName, + eventTag = "", + githubSha = "", + inputVersion = "", + inputChannel = "stable", + inputSourceRef = "", + inputPublish = false, +}) { + if (!["push", "workflow_dispatch"].includes(eventName)) { + throw new Error(`unsupported desktop release event: ${eventName}`); + } + + const publish = eventName === "push" ? true : parsePublish(inputPublish); + const rawVersion = + eventName === "push" + ? eventTag.trim() + : inputVersion.trim().replace(/^v/, ""); + const tag = eventName === "push" ? rawVersion : `v${rawVersion}`; + if (!releaseTagPattern.test(tag)) { + throw new Error(`invalid desktop release tag: ${tag}`); + } + + const version = tag.slice(1); + const prerelease = version.includes("-"); + const channel = prerelease + ? "beta" + : eventName === "push" + ? "stable" + : inputChannel || "stable"; + if (!["stable", "beta"].includes(channel)) { + throw new Error(`invalid desktop release channel: ${channel}`); + } + if (channel === "stable" && prerelease) { + throw new Error("a prerelease version cannot enter stable"); + } + if (publish && channel === "beta" && !prerelease) { + throw new Error("a published beta requires a prerelease version"); + } + + let sourceRef; + if (eventName === "push") { + sourceRef = githubSha.trim(); + if (!sourceRef) throw new Error("tag pushes require GITHUB_SHA"); + } else { + const requestedSourceRef = inputSourceRef.trim(); + if (publish && requestedSourceRef && requestedSourceRef !== tag) { + throw new Error( + `published desktop releases must build from ${tag}, not ${requestedSourceRef}`, + ); + } + sourceRef = publish ? tag : requestedSourceRef || tag; + } + + return { tag, version, channel, sourceRef, publish }; +} + +async function main() { + const identity = resolveDesktopReleaseIdentity({ + eventName: process.env.EVENT_NAME, + eventTag: process.env.EVENT_TAG, + githubSha: process.env.GITHUB_SHA, + inputVersion: process.env.INPUT_VERSION, + inputChannel: process.env.INPUT_CHANNEL, + inputSourceRef: process.env.INPUT_SOURCE_REF, + inputPublish: process.env.INPUT_PUBLISH, + }); + const output = [ + `tag=${identity.tag}`, + `version=${identity.version}`, + `channel=${identity.channel}`, + `source_ref=${identity.sourceRef}`, + `publish=${identity.publish}`, + ].join("\n"); + if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `${output}\n`, "utf8"); + } else { + console.log(output); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(`desktop release identity failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/resolve-desktop-release.test.mjs b/scripts/resolve-desktop-release.test.mjs new file mode 100644 index 0000000..272615d --- /dev/null +++ b/scripts/resolve-desktop-release.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { resolveDesktopReleaseIdentity } from "./resolve-desktop-release.mjs"; + +test("resolves a stable tag push", () => { + assert.deepEqual( + resolveDesktopReleaseIdentity({ + eventName: "push", + eventTag: "v0.28.0", + githubSha: "release-commit", + }), + { + tag: "v0.28.0", + version: "0.28.0", + channel: "stable", + sourceRef: "release-commit", + publish: true, + }, + ); +}); + +test("resolves a prerelease tag push into beta", () => { + const identity = resolveDesktopReleaseIdentity({ + eventName: "push", + eventTag: "v0.29.0-beta.1", + githubSha: "prerelease-commit", + }); + assert.equal(identity.channel, "beta"); + assert.equal(identity.publish, true); +}); + +test("defaults a manual build to its immutable version tag", () => { + const identity = resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "0.28.0", + inputPublish: false, + }); + assert.equal(identity.tag, "v0.28.0"); + assert.equal(identity.sourceRef, "v0.28.0"); + assert.equal(identity.channel, "stable"); +}); + +test("allows a custom source ref only for unpublished artifacts", () => { + const identity = resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "v0.28.0", + inputSourceRef: "feature/desktop-preview", + inputPublish: false, + }); + assert.equal(identity.sourceRef, "feature/desktop-preview"); +}); + +test("rejects a custom source ref for a published release", () => { + assert.throws( + () => + resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "v0.28.0", + inputSourceRef: "main", + inputPublish: true, + }), + /must build from v0\.28\.0, not main/, + ); +}); + +test("accepts the exact tag for a manual published release", () => { + const identity = resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "v0.28.0", + inputSourceRef: "v0.28.0", + inputPublish: true, + }); + assert.equal(identity.sourceRef, "v0.28.0"); + assert.equal(identity.publish, true); +}); + +test("rejects the retired desktop-only tag", () => { + assert.throws( + () => + resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "desktop-v0.28.0", + }), + /invalid desktop release tag/, + ); +}); + +test("rejects a stable version published to beta", () => { + assert.throws( + () => + resolveDesktopReleaseIdentity({ + eventName: "workflow_dispatch", + inputVersion: "v0.28.0", + inputChannel: "beta", + inputPublish: true, + }), + /published beta requires a prerelease version/, + ); +}); diff --git a/scripts/stage-desktop-release.mjs b/scripts/stage-desktop-release.mjs index aa0ceb6..b52d049 100644 --- a/scripts/stage-desktop-release.mjs +++ b/scripts/stage-desktop-release.mjs @@ -120,8 +120,8 @@ function validateReleaseIdentity({ version, channel, tag }) { if (!["stable", "beta"].includes(channel)) { throw new Error(`invalid release channel: ${channel}`); } - if (!tag || !tag.endsWith(version)) { - throw new Error(`release tag must end with version ${version}: ${tag}`); + if (tag !== `v${version}`) { + throw new Error(`release tag must be v${version}: ${tag}`); } } @@ -336,7 +336,7 @@ async function aggregateRelease({ } checksumLines.push(`${await sha256(indexPath)} ${basename(indexPath)}`); await writeFile( - join(outDir, "checksums.txt"), + join(outDir, "desktop-checksums.txt"), `${checksumLines.sort().join("\n")}\n`, "utf8", ); diff --git a/scripts/stage-desktop-release.test.mjs b/scripts/stage-desktop-release.test.mjs index 52a3f5d..ddbc29b 100644 --- a/scripts/stage-desktop-release.test.mjs +++ b/scripts/stage-desktop-release.test.mjs @@ -36,7 +36,7 @@ test("stages macOS Forge assets with checksums and verified signing metadata", a ...fixture, version: "0.28.0-beta.1", channel: "beta", - tag: "desktop-v0.28.0-beta.1", + tag: "v0.28.0-beta.1", repository: "limecloud/contentcloud", signed: true, }); @@ -56,7 +56,7 @@ test("rejects unsigned macOS release staging", async () => { ...fixture, version: "0.28.0", channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", signed: false, }), /requires verified signing/, @@ -77,7 +77,7 @@ test("rejects incomplete Forge release output", async () => { forgeDir: incompleteForgeDir, version: "0.28.0", channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", signed: true, }), /incomplete; missing zip/, @@ -90,7 +90,7 @@ test("aggregates target metadata into a release index", async () => { ...fixture, version: "0.28.0-beta.1", channel: "beta", - tag: "desktop-v0.28.0-beta.1", + tag: "v0.28.0-beta.1", repository: "limecloud/contentcloud", signed: true, }); @@ -99,12 +99,12 @@ test("aggregates target metadata into a release index", async () => { inputDir: fixture.outDir, outDir: aggregateDir, channel: "beta", - tag: "desktop-v0.28.0-beta.1", + tag: "v0.28.0-beta.1", repository: "limecloud/contentcloud", }); assert.deepEqual(Object.keys(index.targets), ["darwin-arm64"]); assert.match( - await readFile(join(aggregateDir, "checksums.txt"), "utf8"), + await readFile(join(aggregateDir, "desktop-checksums.txt"), "utf8"), /desktop-beta-latest\.json/, ); }); @@ -115,7 +115,7 @@ test("rejects an incomplete release matrix", async () => { ...fixture, version: "0.28.0", channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", signed: true, }); await assert.rejects( @@ -123,7 +123,7 @@ test("rejects an incomplete release matrix", async () => { inputDir: fixture.outDir, outDir: join(fixture.root, "release"), channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", requireAllTargets: true, }), /missing targets/, @@ -136,7 +136,7 @@ test("rejects a staged artifact integrity mismatch", async () => { ...fixture, version: "0.28.0", channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", signed: true, }); await writeFile(join(fixture.outDir, metadata.artifacts[0].name), "tampered"); @@ -145,8 +145,22 @@ test("rejects a staged artifact integrity mismatch", async () => { inputDir: fixture.outDir, outDir: join(fixture.root, "release"), channel: "stable", - tag: "desktop-v0.28.0", + tag: "v0.28.0", }), /integrity mismatch/, ); }); + +test("rejects the retired desktop-only release tag", async () => { + const fixture = await fixtureTarget("darwin-arm64"); + await assert.rejects( + stageTarget({ + ...fixture, + version: "0.28.0", + channel: "stable", + tag: "desktop-v0.28.0", + signed: true, + }), + /release tag must be v0\.28\.0/, + ); +}); From abc7a042ac90ecd042a398ef70c26e2e7d3e525c Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 20:00:59 +0800 Subject: [PATCH 02/15] fix: harden cross-platform desktop packaging --- .github/workflows/desktop-release.yml | 16 +++++++++++++++- apps/desktop/package.json | 1 + .../05-release-and-updates.md | 2 +- scripts/check-desktop-release.mjs | 11 +++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 5137f29..1067e52 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -35,6 +35,7 @@ env: NODE_VERSION: "22" PNPM_VERSION: "10.8.1" ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron + DESKTOP_PACKAGE_DESCRIPTION: "ContentCloud project workspace desktop" jobs: resolve: @@ -119,7 +120,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --config.node-linker=hoisted - name: Build macOS DMG native dependency if: matrix.platform == 'darwin' @@ -171,6 +172,19 @@ jobs: exit 1 } + - name: Prepare Linux package metadata + if: matrix.platform == 'linux' + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { readFile, writeFile } from 'node:fs/promises'; + const path = 'apps/desktop/package.json'; + const packageJson = JSON.parse(await readFile(path, 'utf8')); + packageJson.description ||= process.env.DESKTOP_PACKAGE_DESCRIPTION; + await writeFile(path, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); + NODE + - name: Validate macOS signing secrets if: matrix.platform == 'darwin' shell: bash diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64c7526..d36f181 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,6 +2,7 @@ "name": "@limecloud/contentcloud-desktop", "private": true, "version": "0.28.0", + "description": "ContentCloud project workspace desktop", "main": ".vite/build/main.js", "scripts": { "start": "electron-forge start", diff --git a/docs/product/content-work-os-desktop/05-release-and-updates.md b/docs/product/content-work-os-desktop/05-release-and-updates.md index d3027f4..fae838b 100644 --- a/docs/product/content-work-os-desktop/05-release-and-updates.md +++ b/docs/product/content-work-os-desktop/05-release-and-updates.md @@ -83,7 +83,7 @@ resolve (tag / version / channel / source ref) 3. 在 macOS 上执行 `codesign` 与 Gatekeeper assessment,在 Windows 上验证 Authenticode 状态;Linux 仅作为不提供自动更新的预览包。 4. 在 publish job 复验四个目标、版本和每个文件摘要,汇总为 `desktop--latest.json` 和 `desktop-checksums.txt`,再上传 GitHub Release。桌面端使用独立校验文件名,避免覆盖 CLI 的 `checksums.txt`。 -macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。pnpm 的 build-script 白名单保留在根 `package.json`,release job 还会在 `.pnpm` 的实际解析目录显式执行 `node-gyp rebuild` 并分别检查 `volume.node`、`xattr.node`;不能只构建 hoisted 副本,否则 Forge 仍会在 `appdmg` 加载阶段失败。 +macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。release job 使用 pnpm hoisted 布局安装 Forge 的平台可选依赖,再在 `.pnpm` 的实际解析目录显式执行 `node-gyp rebuild`,分别检查 `volume.node`、`xattr.node`。Linux DEB/RPM maker 依赖 `apps/desktop/package.json` 中的 `description`;workflow 会为缺少该字段的既有标签补入同一份固定安装器元数据,但不会改变应用代码或版本。 正式 Release 所需 GitHub Actions secrets: diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 3f5d567..24f45cf 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -5,6 +5,9 @@ const root = resolve(import.meta.dirname, ".."); const policy = JSON.parse( await readFile(resolve(root, "apps/desktop/update-channels.json"), "utf8"), ); +const desktopPackage = JSON.parse( + await readFile(resolve(root, "apps/desktop/package.json"), "utf8"), +); const forge = await readFile( resolve(root, "apps/desktop/forge.config.ts"), "utf8", @@ -33,6 +36,12 @@ if (policy.app_id !== "run.zhongcao.contentcloud.desktop") fail("unexpected app id"); if (!["stable", "beta"].includes(policy.default_channel)) fail("default channel must be stable or beta"); +if ( + typeof desktopPackage.description !== "string" || + desktopPackage.description.trim() === "" +) { + fail("desktop package description is required by Linux installers"); +} for (const channel of ["stable", "beta"]) { const value = policy.channels?.[channel]; if ( @@ -71,6 +80,8 @@ for (const marker of [ "node scripts/resolve-desktop-release.mjs", "Verify published source matches release tag", "desktop-release-publish-${{ needs.resolve.outputs.tag }}", + "--config.node-linker=hoisted", + "Prepare Linux package metadata", "electron-forge make", "actions/upload-artifact@v4", "actions/download-artifact@v4", From 4ad719754e4f7449d2f638f2bf974415d2dfd55f Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 20:13:55 +0800 Subject: [PATCH 03/15] fix: align desktop makers with packaged binaries --- .github/workflows/desktop-release.yml | 35 --------------------------- apps/desktop/forge.config.ts | 19 ++++++++++----- scripts/check-desktop-release.mjs | 30 +++++++++++++++++++++-- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 1067e52..05a5010 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -35,7 +35,6 @@ env: NODE_VERSION: "22" PNPM_VERSION: "10.8.1" ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron - DESKTOP_PACKAGE_DESCRIPTION: "ContentCloud project workspace desktop" jobs: resolve: @@ -122,27 +121,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --config.node-linker=hoisted - - name: Build macOS DMG native dependency - if: matrix.platform == 'darwin' - shell: bash - run: | - set -euo pipefail - for package_name in macos-alias fs-xattr; do - if [[ "$package_name" == macos-alias ]]; then - expected_output=volume.node - else - expected_output=xattr.node - fi - package_json="$(find node_modules/.pnpm -path "*/node_modules/$package_name/package.json" -print -quit)" - if [[ -z "$package_json" ]]; then - echo "::error::$package_name package is missing" - exit 1 - fi - package_dir="$(dirname "$package_json")" - pnpm exec node-gyp rebuild --directory "$package_dir" - test -f "$package_dir/build/Release/$expected_output" - done - - name: Validate desktop release contract run: pnpm desktop:release:check @@ -172,19 +150,6 @@ jobs: exit 1 } - - name: Prepare Linux package metadata - if: matrix.platform == 'linux' - shell: bash - run: | - set -euo pipefail - node --input-type=module <<'NODE' - import { readFile, writeFile } from 'node:fs/promises'; - const path = 'apps/desktop/package.json'; - const packageJson = JSON.parse(await readFile(path, 'utf8')); - packageJson.description ||= process.env.DESKTOP_PACKAGE_DESCRIPTION; - await writeFile(path, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); - NODE - - name: Validate macOS signing secrets if: matrix.platform == 'darwin' shell: bash diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 3eb0968..c321536 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -9,6 +9,13 @@ import { MakerSquirrel } from "@electron-forge/maker-squirrel"; import { MakerZIP } from "@electron-forge/maker-zip"; const releaseSigningEnabled = process.env.CONTENTCLOUD_DESKTOP_SIGN === "1"; +const desktopProductName = "Content Work OS"; +const desktopExecutableName = "content-work-os"; +const linuxMakerOptions = { + name: desktopExecutableName, + productName: desktopProductName, + bin: desktopExecutableName, +}; function macSignOptions() { if (process.platform !== "darwin" || !releaseSigningEnabled) { @@ -69,15 +76,15 @@ function squirrelOptions() { const config: ForgeConfig = { packagerConfig: { asar: true, - name: "Content Work OS", - executableName: "content-work-os", + name: desktopProductName, + executableName: desktopExecutableName, appBundleId: "run.zhongcao.contentcloud.desktop", - protocols: [{ name: "Content Work OS", schemes: ["contentcloud"] }], + protocols: [{ name: desktopProductName, schemes: ["contentcloud"] }], osxSign: macSignOptions(), osxNotarize: macNotarizeOptions(), win32metadata: { CompanyName: "ContentCloud", - ProductName: "Content Work OS", + ProductName: desktopProductName, FileDescription: "ContentCloud project workspace desktop", }, }, @@ -86,8 +93,8 @@ const config: ForgeConfig = { new MakerSquirrel(squirrelOptions()), new MakerZIP({}, ["darwin"]), new MakerDMG({}), - new MakerDeb({}), - new MakerRpm({}), + new MakerDeb({ options: linuxMakerOptions }), + new MakerRpm({ options: linuxMakerOptions }), ], plugins: [ new VitePlugin({ diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 24f45cf..27edc27 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -5,6 +5,9 @@ const root = resolve(import.meta.dirname, ".."); const policy = JSON.parse( await readFile(resolve(root, "apps/desktop/update-channels.json"), "utf8"), ); +const workspacePackage = JSON.parse( + await readFile(resolve(root, "package.json"), "utf8"), +); const desktopPackage = JSON.parse( await readFile(resolve(root, "apps/desktop/package.json"), "utf8"), ); @@ -42,6 +45,13 @@ if ( ) { fail("desktop package description is required by Linux installers"); } +for (const nativeDependency of ["macos-alias", "fs-xattr"]) { + if ( + !workspacePackage.pnpm?.onlyBuiltDependencies?.includes(nativeDependency) + ) { + fail(`${nativeDependency} must be built during dependency installation`); + } +} for (const channel of ["stable", "beta"]) { const value = policy.channels?.[channel]; if ( @@ -72,6 +82,16 @@ for (const maker of [ if (!forge.includes(`new ${maker}`)) fail(`Forge maker ${maker} is not configured`); } +for (const marker of [ + 'const desktopExecutableName = "content-work-os"', + "name: desktopExecutableName", + "bin: desktopExecutableName", + "new MakerDeb({ options: linuxMakerOptions })", + "new MakerRpm({ options: linuxMakerOptions })", +]) { + if (!forge.includes(marker)) + fail(`Forge Linux maker contract is missing ${marker}`); +} if (forge.includes("electron-builder")) fail("electron-builder must not be introduced beside Electron Forge"); for (const marker of [ @@ -81,7 +101,6 @@ for (const marker of [ "Verify published source matches release tag", "desktop-release-publish-${{ needs.resolve.outputs.tag }}", "--config.node-linker=hoisted", - "Prepare Linux package metadata", "electron-forge make", "actions/upload-artifact@v4", "actions/download-artifact@v4", @@ -90,11 +109,18 @@ for (const marker of [ "gh release upload", "mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt", "CONTENTCLOUD_DESKTOP_SIGN", - "node-gyp rebuild", ]) { if (!workflow.includes(marker)) fail(`desktop release workflow is missing ${marker}`); } +if (workflow.includes("node_modules/.pnpm")) + fail("desktop release workflow must not depend on pnpm internal layout"); +if (workflow.includes("node-gyp rebuild")) + fail( + "desktop release workflow must use install-time native dependency builds", + ); +if (workflow.includes("Prepare Linux package metadata")) + fail("Linux package metadata must come from apps/desktop/package.json"); if (workflow.includes("desktop-v")) fail("desktop-only tags must not diverge from the project v* release"); for (const marker of [ From 3d72cb2e0b23fce7e289b03513a4dbd97582503d Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 20:20:21 +0800 Subject: [PATCH 04/15] fix: declare desktop package license --- apps/desktop/package.json | 1 + scripts/check-desktop-release.mjs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d36f181..42d1de3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.28.0", "description": "ContentCloud project workspace desktop", + "license": "Apache-2.0", "main": ".vite/build/main.js", "scripts": { "start": "electron-forge start", diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 27edc27..f68917b 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -45,6 +45,8 @@ if ( ) { fail("desktop package description is required by Linux installers"); } +if (desktopPackage.license !== "Apache-2.0") + fail("desktop package license must match the repository license"); for (const nativeDependency of ["macos-alias", "fs-xattr"]) { if ( !workspacePackage.pnpm?.onlyBuiltDependencies?.includes(nativeDependency) From 6276f019c6577bdd71d471d869ff7ab2d27ec56f Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 21:57:10 +0800 Subject: [PATCH 05/15] fix: allow unsigned desktop preview builds --- .github/workflows/desktop-release.yml | 34 +++++++++++------ .../05-release-and-updates.md | 8 ++-- scripts/check-desktop-release.mjs | 13 +++++++ scripts/stage-desktop-release.mjs | 12 +++++- scripts/stage-desktop-release.test.mjs | 37 +++++++++++++++++++ 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 05a5010..492cb8b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -151,7 +151,7 @@ jobs: } - name: Validate macOS signing secrets - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -173,7 +173,7 @@ jobs: fi - name: Import macOS signing certificate - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -193,7 +193,7 @@ jobs: echo "CONTENTCLOUD_DESKTOP_MAC_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - name: Validate Windows signing secrets - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: bash env: WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} @@ -204,7 +204,7 @@ jobs: [[ -n "${WINDOWS_SIGNING_CERTIFICATE_PASSWORD:-}" ]] || { echo "::error::missing WINDOWS_SIGNING_CERTIFICATE_PASSWORD"; exit 1; } - name: Prepare Windows signing certificate - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: bash env: WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} @@ -222,7 +222,7 @@ jobs: - name: Build Electron packages shell: bash env: - CONTENTCLOUD_DESKTOP_SIGN: ${{ matrix.signing == 'required' && '1' || '' }} + CONTENTCLOUD_DESKTOP_SIGN: ${{ needs.resolve.outputs.publish == 'true' && matrix.signing == 'required' && '1' || '' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -235,7 +235,7 @@ jobs: --targets "${{ matrix.forge_targets }}" - name: Verify macOS signatures and notarization - if: matrix.platform == 'darwin' + if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' shell: bash run: | set -euo pipefail @@ -251,7 +251,7 @@ jobs: done - name: Verify Windows Authenticode signature - if: matrix.platform == 'win32' + if: matrix.platform == 'win32' && needs.resolve.outputs.publish == 'true' shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -272,11 +272,21 @@ jobs: RELEASE_TAG: ${{ needs.resolve.outputs.tag }} RELEASE_VERSION: ${{ needs.resolve.outputs.version }} RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} - RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_REPOSITORY: ${{ needs.resolve.outputs.publish == 'true' && github.repository || '' }} run: | set -euo pipefail - signing_flag=() - if [[ "${{ matrix.signing }}" == required ]]; then signing_flag+=(--signed); fi + repository_flags=() + if [[ -n "$RELEASE_REPOSITORY" ]]; then + repository_flags+=(--repository "$RELEASE_REPOSITORY") + fi + signing_flags=() + if [[ "${{ matrix.signing }}" == required ]]; then + if [[ "${{ needs.resolve.outputs.publish }}" == true ]]; then + signing_flags+=(--signed) + else + signing_flags+=(--preview) + fi + fi node scripts/stage-desktop-release.mjs stage \ --forge-dir apps/desktop/out \ --out-dir "desktop-staged/${{ matrix.target }}" \ @@ -284,8 +294,8 @@ jobs: --version "$RELEASE_VERSION" \ --channel "$RELEASE_CHANNEL" \ --tag "$RELEASE_TAG" \ - --repository "$RELEASE_REPOSITORY" \ - "${signing_flag[@]}" + "${repository_flags[@]}" \ + "${signing_flags[@]}" - name: Upload staged desktop assets uses: actions/upload-artifact@v4 diff --git a/docs/product/content-work-os-desktop/05-release-and-updates.md b/docs/product/content-work-os-desktop/05-release-and-updates.md index fae838b..42e5449 100644 --- a/docs/product/content-work-os-desktop/05-release-and-updates.md +++ b/docs/product/content-work-os-desktop/05-release-and-updates.md @@ -71,7 +71,7 @@ resolve (tag / version / channel / source ref) - 项目只有一套 `v*` 版本标签。推送 `v0.28.0` 后,桌面安装包与 CLI 资产进入同一个 `v0.28.0` Release。 - 推送 `v0.28.0-beta.1` 自动构建并发布 `beta` prerelease。 -- `workflow_dispatch` 默认只生成 Actions artifact;只有显式打开 `publish` 才会创建或更新 GitHub Release。 +- `workflow_dispatch` 默认生成未签名的 Actions preview artifact,供内部安装验证;只有显式打开 `publish` 才会启用签名、创建或更新 GitHub Release。 - 手动运行可填写 `source_ref`;留空时构建对应 `v*` 标签。发布模式强制从不可变的同名标签构建,只有 `publish=false` 的预览 artifact 才允许使用其他 ref。`apps/desktop/package.json` 的版本必须与输入版本完全一致。 - 对已经存在的 GitHub Release,workflow 只追加或重建带桌面前缀的资产,不会改写 Release 标题、正文或 CLI 资产。 - 同一标签的 publish job 使用标签级并发锁串行上传,防止自动触发和手动回填交错覆盖资产。 @@ -80,10 +80,10 @@ resolve (tag / version / channel / source ref) 1. 只接收 `update-channels.json` 声明的格式,并强制要求 DMG+ZIP、Squirrel installer+NUPKG+RELEASES、DEB+RPM 各自完整。 2. 生成带目标前缀的稳定文件名、目标级 `latest.json` 和 `*-checksums.sha256`。 -3. 在 macOS 上执行 `codesign` 与 Gatekeeper assessment,在 Windows 上验证 Authenticode 状态;Linux 仅作为不提供自动更新的预览包。 +3. 正式发布在 macOS 上执行 `codesign` 与 Gatekeeper assessment,在 Windows 上验证 Authenticode 状态;`publish=false` 时跳过签名,只生成不得进入更新通道的预览包。 4. 在 publish job 复验四个目标、版本和每个文件摘要,汇总为 `desktop--latest.json` 和 `desktop-checksums.txt`,再上传 GitHub Release。桌面端使用独立校验文件名,避免覆盖 CLI 的 `checksums.txt`。 -macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。release job 使用 pnpm hoisted 布局安装 Forge 的平台可选依赖,再在 `.pnpm` 的实际解析目录显式执行 `node-gyp rebuild`,分别检查 `volume.node`、`xattr.node`。Linux DEB/RPM maker 依赖 `apps/desktop/package.json` 中的 `description`;workflow 会为缺少该字段的既有标签补入同一份固定安装器元数据,但不会改变应用代码或版本。 +macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。release job 使用 pnpm hoisted 布局,并由 pnpm 的 `onlyBuiltDependencies` 在安装阶段构建这些原生依赖,不读取 `.pnpm` 内部目录。Linux DEB/RPM maker 直接使用 `apps/desktop/package.json` 中的 `description`、`license` 和统一的可执行文件名,不在 workflow 中修补历史标签源码。 正式 Release 所需 GitHub Actions secrets: @@ -97,7 +97,7 @@ macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。rel | `WINDOWS_SIGNING_CERTIFICATE` | base64 编码的 Authenticode `.pfx` | | `WINDOWS_SIGNING_CERTIFICATE_PASSWORD` | `.pfx` 密码 | -缺少任一 macOS/Windows 签名 secret 时,构建 job 失败,不会上传可被误认为正式版本的未签名安装包。GitHub token 只用于 Release 元数据和 asset 上传;不会传入 Electron Renderer,也不会接收 Workspace 正文、设备 token 或 Cloud 凭据。 +`publish=true` 时缺少任一 macOS/Windows 签名 secret,构建 job 必须失败,不会上传可被误认为正式版本的未签名安装包。`publish=false` 的预览构建不读取签名 secret,产物元数据标记为 `unverified-preview`,不包含 Release 下载地址,不能聚合或发布到 stable/beta 更新通道;用户手动安装时也可能看到系统安全提示。GitHub token 只用于 Release 元数据和 asset 上传;不会传入 Electron Renderer,也不会接收 Workspace 正文、设备 token 或 Cloud 凭据。 ## 6. 发布后的外部门禁与回滚 diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index f68917b..3ab21e4 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -111,6 +111,10 @@ for (const marker of [ "gh release upload", "mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt", "CONTENTCLOUD_DESKTOP_SIGN", + "needs.resolve.outputs.publish == 'true' && matrix.signing == 'required'", + 'repository_flags+=(--repository "$RELEASE_REPOSITORY")', + "signing_flags+=(--preview)", + "signing_flags+=(--signed)", ]) { if (!workflow.includes(marker)) fail(`desktop release workflow is missing ${marker}`); @@ -125,6 +129,15 @@ if (workflow.includes("Prepare Linux package metadata")) fail("Linux package metadata must come from apps/desktop/package.json"); if (workflow.includes("desktop-v")) fail("desktop-only tags must not diverge from the project v* release"); +for (const platform of ["darwin", "win32"]) { + const publishGate = `if: matrix.platform == '${platform}' && needs.resolve.outputs.publish == 'true'`; + const count = workflow.split(publishGate).length - 1; + if (count !== 3) { + fail( + `${platform} must gate signing validation, preparation and verification on publish mode`, + ); + } +} for (const marker of [ "published desktop releases must build from", "inputSourceRef", diff --git a/scripts/stage-desktop-release.mjs b/scripts/stage-desktop-release.mjs index b52d049..e7555f1 100644 --- a/scripts/stage-desktop-release.mjs +++ b/scripts/stage-desktop-release.mjs @@ -134,11 +134,15 @@ async function stageTarget({ tag, repository, signed, + preview, }) { const definition = targetDefinitions[target]; if (!definition) throw new Error(`unsupported desktop target: ${target}`); validateReleaseIdentity({ version, channel, tag }); - if (definition.signed && signed !== true) { + if (signed === true && preview === true) { + throw new Error("desktop target cannot be both signed and preview-only"); + } + if (definition.signed && signed !== true && preview !== true) { throw new Error( `${target} requires verified signing before it can enter ${channel}`, ); @@ -195,7 +199,10 @@ async function stageTarget({ arch: definition.arch, generated_at: new Date().toISOString(), signing: definition.signed - ? { required: true, status: "verified" } + ? { + required: true, + status: signed === true ? "verified" : "unverified-preview", + } : { required: false, status: "not-required-for-preview" }, artifacts: stagedArtifacts.map((artifact) => ({ ...artifact, @@ -368,6 +375,7 @@ async function main() { tag: required(args, "tag"), repository: args.repository?.trim(), signed: args.signed === true, + preview: args.preview === true, }); process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); } diff --git a/scripts/stage-desktop-release.test.mjs b/scripts/stage-desktop-release.test.mjs index ddbc29b..df2e4ea 100644 --- a/scripts/stage-desktop-release.test.mjs +++ b/scripts/stage-desktop-release.test.mjs @@ -63,6 +63,43 @@ test("rejects unsigned macOS release staging", async () => { ); }); +test("stages unsigned macOS previews without claiming verified signing", async () => { + const fixture = await fixtureTarget("darwin-arm64"); + const metadata = await stageTarget({ + ...fixture, + version: "0.28.0", + channel: "stable", + tag: "v0.28.0", + signed: false, + preview: true, + }); + assert.deepEqual(metadata.signing, { + required: true, + status: "unverified-preview", + }); + assert.equal(metadata.artifacts[0].download_url, undefined); +}); + +test("rejects unsigned previews when aggregating a release", async () => { + const fixture = await fixtureTarget("darwin-arm64"); + await stageTarget({ + ...fixture, + version: "0.28.0", + channel: "stable", + tag: "v0.28.0", + preview: true, + }); + await assert.rejects( + aggregateRelease({ + inputDir: fixture.outDir, + outDir: join(fixture.root, "release"), + channel: "stable", + tag: "v0.28.0", + }), + /staged target is not signed/, + ); +}); + test("rejects incomplete Forge release output", async () => { const fixture = await fixtureTarget("darwin-arm64"); const incompleteForgeDir = join(fixture.root, "incomplete-forge"); From 78b8f27207df3cf9de67f1c08266995c574dbad8 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 22:02:04 +0800 Subject: [PATCH 06/15] fix: stabilize desktop preview packaging --- .github/workflows/desktop-release.yml | 26 ++++++++++++-------------- apps/desktop/forge.config.ts | 6 ++++-- scripts/check-desktop-release.mjs | 14 +++++++++++--- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 492cb8b..b29e7db 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -275,27 +275,25 @@ jobs: RELEASE_REPOSITORY: ${{ needs.resolve.outputs.publish == 'true' && github.repository || '' }} run: | set -euo pipefail - repository_flags=() + stage_args=( + --forge-dir apps/desktop/out + --out-dir "desktop-staged/${{ matrix.target }}" + --target "${{ matrix.target }}" + --version "$RELEASE_VERSION" + --channel "$RELEASE_CHANNEL" + --tag "$RELEASE_TAG" + ) if [[ -n "$RELEASE_REPOSITORY" ]]; then - repository_flags+=(--repository "$RELEASE_REPOSITORY") + stage_args+=(--repository "$RELEASE_REPOSITORY") fi - signing_flags=() if [[ "${{ matrix.signing }}" == required ]]; then if [[ "${{ needs.resolve.outputs.publish }}" == true ]]; then - signing_flags+=(--signed) + stage_args+=(--signed) else - signing_flags+=(--preview) + stage_args+=(--preview) fi fi - node scripts/stage-desktop-release.mjs stage \ - --forge-dir apps/desktop/out \ - --out-dir "desktop-staged/${{ matrix.target }}" \ - --target "${{ matrix.target }}" \ - --version "$RELEASE_VERSION" \ - --channel "$RELEASE_CHANNEL" \ - --tag "$RELEASE_TAG" \ - "${repository_flags[@]}" \ - "${signing_flags[@]}" + node scripts/stage-desktop-release.mjs stage "${stage_args[@]}" - name: Upload staged desktop assets uses: actions/upload-artifact@v4 diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index c321536..8335cb4 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -11,6 +11,7 @@ import { MakerZIP } from "@electron-forge/maker-zip"; const releaseSigningEnabled = process.env.CONTENTCLOUD_DESKTOP_SIGN === "1"; const desktopProductName = "Content Work OS"; const desktopExecutableName = "content-work-os"; +const desktopSquirrelName = "content_work_os"; const linuxMakerOptions = { name: desktopExecutableName, productName: desktopProductName, @@ -56,8 +57,9 @@ function macNotarizeOptions() { } function squirrelOptions() { + const identity = { name: desktopSquirrelName }; if (process.platform !== "win32" || !releaseSigningEnabled) { - return {}; + return identity; } const certificateFile = @@ -70,7 +72,7 @@ function squirrelOptions() { ); } - return { certificateFile, certificatePassword }; + return { ...identity, certificateFile, certificatePassword }; } const config: ForgeConfig = { diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 3ab21e4..0f8e911 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -86,8 +86,10 @@ for (const maker of [ } for (const marker of [ 'const desktopExecutableName = "content-work-os"', + 'const desktopSquirrelName = "content_work_os"', "name: desktopExecutableName", "bin: desktopExecutableName", + "name: desktopSquirrelName", "new MakerDeb({ options: linuxMakerOptions })", "new MakerRpm({ options: linuxMakerOptions })", ]) { @@ -112,9 +114,10 @@ for (const marker of [ "mv github-release-assets/checksums.txt github-release-assets/desktop-checksums.txt", "CONTENTCLOUD_DESKTOP_SIGN", "needs.resolve.outputs.publish == 'true' && matrix.signing == 'required'", - 'repository_flags+=(--repository "$RELEASE_REPOSITORY")', - "signing_flags+=(--preview)", - "signing_flags+=(--signed)", + 'stage_args+=(--repository "$RELEASE_REPOSITORY")', + "stage_args+=(--preview)", + "stage_args+=(--signed)", + 'node scripts/stage-desktop-release.mjs stage "${stage_args[@]}"', ]) { if (!workflow.includes(marker)) fail(`desktop release workflow is missing ${marker}`); @@ -129,6 +132,11 @@ if (workflow.includes("Prepare Linux package metadata")) fail("Linux package metadata must come from apps/desktop/package.json"); if (workflow.includes("desktop-v")) fail("desktop-only tags must not diverge from the project v* release"); +if ( + workflow.includes("repository_flags=()") || + workflow.includes("signing_flags=()") +) + fail("desktop staging must not expand empty arrays under macOS Bash 3.2"); for (const platform of ["darwin", "win32"]) { const publishGate = `if: matrix.platform == '${platform}' && needs.resolve.outputs.publish == 'true'`; const count = workflow.split(publishGate).length - 1; From 9988bd2ff90d7b22aa20ff24dd2c814098c792f7 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 22:10:55 +0800 Subject: [PATCH 07/15] fix: complete cross-platform desktop preview packaging --- .github/workflows/desktop-release.yml | 28 +++++++++++++++++-- apps/desktop/package.json | 1 + .../05-release-and-updates.md | 4 ++- scripts/check-desktop-release.mjs | 10 +++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b29e7db..58c9d14 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -229,10 +229,32 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - pnpm --dir apps/desktop exec electron-forge make \ + pnpm --dir apps/desktop exec electron-forge package \ --platform "${{ matrix.platform }}" \ - --arch "${{ matrix.arch }}" \ - --targets "${{ matrix.forge_targets }}" + --arch "${{ matrix.arch }}" + + FORGE_MAKE_LOG="$RUNNER_TEMP/electron-forge-make-${{ matrix.target }}.log" + run_forge_make() { + set +e + pnpm --dir apps/desktop exec electron-forge make \ + --skip-package \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --targets "${{ matrix.forge_targets }}" 2>&1 | tee "$FORGE_MAKE_LOG" + forge_status=${PIPESTATUS[0]} + set -e + } + + run_forge_make + if [[ "$forge_status" -ne 0 && "${{ matrix.platform }}" == darwin ]] \ + && grep -q "hdiutil detach /Volumes/Content Work OS" "$FORGE_MAKE_LOG" \ + && grep -q "No such file or directory" "$FORGE_MAKE_LOG"; then + echo "DMG volume cleanup raced with hdiutil; retrying Forge make once." + hdiutil detach "/Volumes/Content Work OS" -force || true + sleep 5 + run_forge_make + fi + exit "$forge_status" - name: Verify macOS signatures and notarization if: matrix.platform == 'darwin' && needs.resolve.outputs.publish == 'true' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 42d1de3..b6354ff 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.28.0", "description": "ContentCloud project workspace desktop", + "author": "ContentCloud", "license": "Apache-2.0", "main": ".vite/build/main.js", "scripts": { diff --git a/docs/product/content-work-os-desktop/05-release-and-updates.md b/docs/product/content-work-os-desktop/05-release-and-updates.md index 42e5449..a338bce 100644 --- a/docs/product/content-work-os-desktop/05-release-and-updates.md +++ b/docs/product/content-work-os-desktop/05-release-and-updates.md @@ -76,13 +76,15 @@ resolve (tag / version / channel / source ref) - 对已经存在的 GitHub Release,workflow 只追加或重建带桌面前缀的资产,不会改写 Release 标题、正文或 CLI 资产。 - 同一标签的 publish job 使用标签级并发锁串行上传,防止自动触发和手动回填交错覆盖资产。 -每个平台 job 都执行 `electron-forge make`,随后由 `scripts/stage-desktop-release.mjs`: +每个平台 job 先执行 `electron-forge package`,再用 `make --skip-package` 生成安装器,随后由 `scripts/stage-desktop-release.mjs`: 1. 只接收 `update-channels.json` 声明的格式,并强制要求 DMG+ZIP、Squirrel installer+NUPKG+RELEASES、DEB+RPM 各自完整。 2. 生成带目标前缀的稳定文件名、目标级 `latest.json` 和 `*-checksums.sha256`。 3. 正式发布在 macOS 上执行 `codesign` 与 Gatekeeper assessment,在 Windows 上验证 Authenticode 状态;`publish=false` 时跳过签名,只生成不得进入更新通道的预览包。 4. 在 publish job 复验四个目标、版本和每个文件摘要,汇总为 `desktop--latest.json` 和 `desktop-checksums.txt`,再上传 GitHub Release。桌面端使用独立校验文件名,避免覆盖 CLI 的 `checksums.txt`。 +macOS DMG 如果遇到 runner 上卷已被清理的 `hdiutil detach ... No such file or directory` 竞态,只重试 `make --skip-package` 一次;其他 Forge 错误不会被重试。Windows Squirrel 使用不带 npm scope 的 `content_work_os` NuGet 名称,桌面包清单必须声明 `author`,否则 NuGet 会拒绝生成安装器。 + macOS 的 DMG maker 间接依赖 `macos-alias` 和 `fs-xattr` 原生模块。release job 使用 pnpm hoisted 布局,并由 pnpm 的 `onlyBuiltDependencies` 在安装阶段构建这些原生依赖,不读取 `.pnpm` 内部目录。Linux DEB/RPM maker 直接使用 `apps/desktop/package.json` 中的 `description`、`license` 和统一的可执行文件名,不在 workflow 中修补历史标签源码。 正式 Release 所需 GitHub Actions secrets: diff --git a/scripts/check-desktop-release.mjs b/scripts/check-desktop-release.mjs index 0f8e911..283e047 100644 --- a/scripts/check-desktop-release.mjs +++ b/scripts/check-desktop-release.mjs @@ -45,6 +45,12 @@ if ( ) { fail("desktop package description is required by Linux installers"); } +if ( + typeof desktopPackage.author !== "string" || + desktopPackage.author.trim() === "" +) { + fail("desktop package author is required by Windows Squirrel"); +} if (desktopPackage.license !== "Apache-2.0") fail("desktop package license must match the repository license"); for (const nativeDependency of ["macos-alias", "fs-xattr"]) { @@ -105,7 +111,11 @@ for (const marker of [ "Verify published source matches release tag", "desktop-release-publish-${{ needs.resolve.outputs.tag }}", "--config.node-linker=hoisted", + "electron-forge package", "electron-forge make", + "--skip-package", + "hdiutil detach /Volumes/Content Work OS", + "retrying Forge make once", "actions/upload-artifact@v4", "actions/download-artifact@v4", "gh release view", From 5394400463fc922871b8e0cefba1eed1d2f78559 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 18 Aug 2026 22:29:42 +0800 Subject: [PATCH 08/15] fix: stage Windows desktop release assets --- .github/workflows/ci.yml | 1 + .github/workflows/desktop-release.yml | 8 ++++- scripts/stage-desktop-release.mjs | 4 ++- scripts/stage-desktop-release.test.mjs | 48 +++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ad7bc..f255d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm desktop:release:check + - run: pnpm test:desktop-release - run: pnpm --dir apps/desktop exec electron-forge package --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 58c9d14..cf3248b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -316,12 +316,18 @@ jobs: fi fi node scripts/stage-desktop-release.mjs stage "${stage_args[@]}" + staged_dir="desktop-staged/${{ matrix.target }}" + if [[ -z "$(find "$staged_dir" -maxdepth 1 -type f -print -quit)" ]]; then + echo "::error::desktop release staging produced no files under $staged_dir" + exit 1 + fi + find "$staged_dir" -maxdepth 1 -type f -print | sort - name: Upload staged desktop assets uses: actions/upload-artifact@v4 with: name: desktop-release-${{ matrix.target }} - path: desktop-staged/${{ matrix.target }}/* + path: desktop-staged/${{ matrix.target }} if-no-files-found: error retention-days: 14 diff --git a/scripts/stage-desktop-release.mjs b/scripts/stage-desktop-release.mjs index e7555f1..412929f 100644 --- a/scripts/stage-desktop-release.mjs +++ b/scripts/stage-desktop-release.mjs @@ -8,6 +8,7 @@ import { copyFile, } from "node:fs/promises"; import { basename, extname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; const metadataSchema = "contentcloud.desktop-update-metadata/1.0"; const indexSchema = "contentcloud.desktop-update-index/1.0"; @@ -380,7 +381,8 @@ async function main() { process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); } -if (import.meta.url === `file://${process.argv[1]}`) { +const entrypoint = process.argv[1]; +if (entrypoint && import.meta.url === pathToFileURL(resolve(entrypoint)).href) { try { await main(); } catch (error) { diff --git a/scripts/stage-desktop-release.test.mjs b/scripts/stage-desktop-release.test.mjs index df2e4ea..159786d 100644 --- a/scripts/stage-desktop-release.test.mjs +++ b/scripts/stage-desktop-release.test.mjs @@ -1,11 +1,15 @@ -import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { promisify } from "node:util"; import assert from "node:assert/strict"; import { aggregateRelease, stageTarget } from "./stage-desktop-release.mjs"; +const execFileAsync = promisify(execFile); + async function fixtureTarget(target) { const root = await mkdtemp(join(tmpdir(), "contentcloud-desktop-release-")); const forgeDir = join(root, "forge"); @@ -80,6 +84,48 @@ test("stages unsigned macOS previews without claiming verified signing", async ( assert.equal(metadata.artifacts[0].download_url, undefined); }); +test("CLI entrypoint stages Windows preview assets", async () => { + const root = await mkdtemp( + join(tmpdir(), "contentcloud-desktop-release-cli-"), + ); + const forgeDir = join(root, "forge"); + const makeDir = join(forgeDir, "make", "squirrel.windows", "x64"); + const outDir = join(root, "staged"); + await mkdir(makeDir, { recursive: true }); + await writeFile(join(makeDir, "ContentWorkOSSetup.exe"), "exe"); + await writeFile(join(makeDir, "content_work_os-0.28.0-full.nupkg"), "nupkg"); + await writeFile(join(makeDir, "RELEASES"), "releases"); + + const { stdout } = await execFileAsync(process.execPath, [ + join(import.meta.dirname, "stage-desktop-release.mjs"), + "stage", + "--forge-dir", + forgeDir, + "--out-dir", + outDir, + "--target", + "win32-x64", + "--version", + "0.28.0", + "--channel", + "stable", + "--tag", + "v0.28.0", + "--preview", + ]); + + const metadata = JSON.parse(stdout); + assert.equal(metadata.target, "win32-x64"); + assert.equal(metadata.artifacts.length, 3); + assert.deepEqual((await readdir(outDir)).sort(), [ + "win32-x64-ContentWorkOSSetup.exe", + "win32-x64-RELEASES", + "win32-x64-checksums.sha256", + "win32-x64-content_work_os-0.28.0-full.nupkg", + "win32-x64-latest.json", + ]); +}); + test("rejects unsigned previews when aggregating a release", async () => { const fixture = await fixtureTarget("darwin-arm64"); await stageTarget({ From 14adbcbf59eb5a4ac312c8a9da1fadf068f9529d Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 24 Aug 2026 10:02:32 +0800 Subject: [PATCH 09/15] release: v0.29.0 layered workbench platform --- CHANGELOG.md | 19 + VERSION | 2 +- apps/desktop/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/admin/AdminShell.tsx | 6 +- apps/web/src/admin/cleanupPage.test.tsx | 18 + apps/web/src/admin/context.tsx | 15 +- apps/web/src/admin/operationsPages.test.tsx | 41 +- apps/web/src/admin/routes.test.ts | 4 +- apps/web/src/admin/routes.ts | 4 +- apps/web/src/admin/runtimePage.test.tsx | 4 +- apps/web/src/admin/views/AdminCleanupPage.tsx | 86 ++++ .../src/admin/views/AdminOperationsPages.tsx | 82 ++- apps/web/src/admin/views/AdminRuntimePage.tsx | 66 ++- apps/web/src/agentHandoff.test.ts | 11 + apps/web/src/agentHandoff.ts | 25 +- apps/web/src/connectBootstrap.test.ts | 20 +- apps/web/src/connectBootstrap.ts | 19 +- .../web/src/platform/PipelineSummary.test.tsx | 13 + apps/web/src/platform/PipelineSummary.tsx | 35 ++ apps/web/src/platform/README.md | 21 + apps/web/src/platform/platform.css | 7 + apps/web/src/router.tsx | 2 + apps/web/src/studio/StudioPages.tsx | 75 ++- apps/web/src/studio/studio.css | 7 +- apps/web/src/studio/studioTypes.ts | 53 ++ apps/web/src/studio/workbenchDefinitions.ts | 22 + apps/web/src/styles.css | 11 + apps/web/src/styles/workSurface.css | 2 +- apps/web/src/types.ts | 13 +- apps/web/src/workbench/README.md | 39 ++ .../workbench/contract/businessBrief.test.ts | 17 + .../src/workbench/contract/businessBrief.ts | 66 +++ .../workbench/contract/businessTaskTypes.ts | 38 ++ apps/web/src/workbench/contract/navigation.ts | 14 + .../host/BusinessBriefFields.test.tsx | 14 + .../workbench/host/BusinessBriefFields.tsx | 32 ++ .../src/workbench/registry/registry.test.ts | 59 +++ apps/web/src/workbench/registry/registry.ts | 102 ++++ .../renderer/BusinessTaskCanvas.test.tsx | 47 ++ .../workbench/renderer/BusinessTaskCanvas.tsx | 24 + apps/web/src/workbench/renderer/workbench.css | 9 + apps/web/src/workbenches/README.md | 33 ++ .../article/ArticleBriefFields.tsx | 11 + .../workbenches/article/ArticleTaskCanvas.tsx | 13 + .../web/src/workbenches/article/definition.ts | 14 + .../commerce/CommerceBriefFields.tsx | 11 + .../commerce/CommerceTaskCanvas.tsx | 13 + .../src/workbenches/commerce/definition.ts | 14 + .../marketing-video/VideoBriefFields.tsx | 11 + .../marketing-video/VideoTaskCanvas.tsx | 15 + .../workbenches/marketing-video/definition.ts | 14 + .../serialized-novel/NovelBriefFields.tsx | 11 + .../serialized-novel/NovelTaskCanvas.tsx | 15 + .../serialized-novel/definition.ts | 14 + cmd/contentcloud-server/main.go | 17 + cmd/contentcloud-server/main_test.go | 29 +- cmd/contentcloud-worker/main.go | 6 +- contracts/commerce-content-1.0.schema.json | 40 ++ contracts/embed.go | 6 + contracts/embed_test.go | 2 + contracts/novel-chapter-1.0.schema.json | 5 +- contracts/openapi.yaml | 303 ++++++++++- contracts/storyboard-package-1.0.schema.json | 15 +- contracts/video-script-1.0.schema.json | 29 ++ .../1.0.0/workbench-plugin.schema.json | 69 +++ ...ntentcloud-novel-workbench-flow.excalidraw | 1 + .../contentcloud-novel-workbench-flow.mmd | 23 + .../contentcloud-novel-workbench-flow.png | Bin 0 -> 111789 bytes .../contentcloud-novel-workbench-flow.svg | 1 + ...ud-product-layered-architecture.excalidraw | 1 + ...tentcloud-product-layered-architecture.mmd | 34 ++ ...tentcloud-product-layered-architecture.png | Bin 0 -> 435379 bytes ...tentcloud-product-layered-architecture.svg | 1 + ...ntentcloud-video-workbench-flow.excalidraw | 1 + .../contentcloud-video-workbench-flow.mmd | 16 + .../contentcloud-video-workbench-flow.png | Bin 0 -> 423454 bytes .../contentcloud-video-workbench-flow.svg | 1 + .../contentcloud-video-workbench-sequence.mmd | 27 + .../contentcloud-video-workbench-sequence.png | Bin 0 -> 214140 bytes .../contentcloud-video-workbench-sequence.svg | 1 + .../contentcloud-video-workbench-state.mmd | 27 + .../contentcloud-video-workbench-state.png | Bin 0 -> 376125 bytes .../contentcloud-video-workbench-state.svg | 1 + ...ud-workbench-registry-lifecycle.excalidraw | 1 + ...tentcloud-workbench-registry-lifecycle.mmd | 17 + ...tentcloud-workbench-registry-lifecycle.png | Bin 0 -> 302414 bytes ...tentcloud-workbench-registry-lifecycle.svg | 1 + docs/content/clients/claude-code.md | 13 +- docs/foundation/01-product-planes.md | 20 +- docs/foundation/03-system-and-runtime.md | 15 + docs/foundation/04-code-organization.md | 89 +++- docs/foundation/05-contracts-and-extension.md | 82 ++- .../06-layered-platform-ownership.md | 185 +++++++ docs/foundation/10-current-state-inventory.md | 27 +- docs/foundation/PLAN.md | 52 +- docs/foundation/README.md | 28 +- docs/infra/01-capability-map.md | 20 +- docs/infra/03-delivery-roadmap.md | 40 +- .../04-architecture-and-flow-diagrams.md | 30 +- docs/infra/README.md | 30 +- docs/plugin/README.md | 2 + docs/product/00-product-narrative.md | 23 + docs/product/README.md | 5 + .../01-product-planes-and-architecture.md | 21 + .../07-business-workbench-ui-design.md | 249 +++++++++ .../08-business-workbench-prototype.md | 136 +++++ .../customer-creation-studio/README.md | 7 + .../business-workbench-prototype.html | 187 +++++++ ...eb-console-observability-and-operations.md | 2 +- .../v8/12-video-production-migration.md | 83 +++ .../v8/13-video-production-execution.md | 225 ++++++++ .../v8/14-video-production-iteration.md | 45 ++ docs/roadmap/v8/15-provider-state-matrix.md | 54 ++ .../v8/16-video-production-progress-plan.md | 109 ++++ .../roadmap/v8/17-stage-e-composition-plan.md | 72 +++ .../18-stage-e1-composition-manifest-plan.md | 87 ++++ .../v8/19-stage-e1d-failure-recovery-plan.md | 65 +++ .../v8/20-video-production-completion-plan.md | 107 ++++ docs/roadmap/v8/21-e1d-b-blob-cleanup-plan.md | 47 ++ .../v8/22-stage-f-jianying-export-plan.md | 97 ++++ docs/roadmap/v8/23-documentation-sync-plan.md | 91 ++++ .../roadmap/v8/24-video-production-worklog.md | 84 +++ .../v8/25-stage-d2-postgres-parity-plan.md | 82 +++ .../v8/26-e1d-b-postgres-final-render-plan.md | 34 ++ .../v8/27-e1d-b-blob-fault-injection-plan.md | 45 ++ .../v8/28-e1d-b-recovery-execution-plan.md | 80 +++ .../v8/29-e1d-b-r2-cleanup-retry-plan.md | 69 +++ .../v8/30-e1d-b-recovery-acceptance-plan.md | 53 ++ ...1-runtime-business-benchmark-acceptance.md | 60 +++ docs/roadmap/v8/PLAN.md | 10 +- docs/roadmap/v8/README.md | 24 +- .../article_retrospective_benchmark_test.go | 176 +++++++ .../application/article_submission_test.go | 32 ++ internal/application/artifact_storage.go | 56 ++ internal/application/artifact_storage_test.go | 126 +++++ .../application/bootstrap_onboarding_test.go | 8 + internal/application/builtin_sops.go | 42 ++ internal/application/channel_infra.go | 19 +- internal/application/channel_infra_test.go | 37 +- .../application/commerce_full_chain_test.go | 103 ++++ internal/application/content_profiles_test.go | 9 + internal/application/customer_studio.go | 482 ++++++++++++++++-- .../application/customer_studio_input_test.go | 284 +++++++++++ internal/application/customer_studio_test.go | 63 +++ .../dev_fixture_marketing_video.go | 10 +- internal/application/devices_test.go | 11 + internal/application/jianying_export.go | 49 ++ internal/application/jianying_export_test.go | 129 +++++ internal/application/lineage.go | 53 ++ .../marketing_campaign_benchmark_test.go | 101 ++++ internal/application/media_pipeline.go | 231 ++++++++- .../application/media_pipeline_batch_test.go | 181 +++++++ .../media_pipeline_blob_failure_test.go | 229 +++++++++ .../application/media_pipeline_budget_test.go | 116 +++++ .../application/media_pipeline_cancel_test.go | 142 +++++- .../application/media_pipeline_output_test.go | 36 ++ .../application/media_pipeline_state_test.go | 205 ++++++++ internal/application/media_pipeline_test.go | 69 ++- internal/application/novel_full_chain_test.go | 86 ++++ internal/application/orchestration.go | 62 ++- internal/application/orchestration_test.go | 33 +- internal/application/performance_results.go | 14 +- internal/application/review_export.go | 31 +- internal/application/runtime_cleanup.go | 258 ++++++++++ internal/application/runtime_cleanup_test.go | 278 ++++++++++ internal/application/runtime_explorer.go | 240 ++++++++- internal/application/runtime_health.go | 7 + internal/application/runtime_health_test.go | 2 +- internal/application/seedance25_inputs.go | 8 +- internal/application/service.go | 122 +++-- internal/application/stage_outputs.go | 6 +- internal/application/storyboard_media.go | 295 ++++++++++- internal/application/submissions.go | 26 + internal/application/task_governance.go | 434 ++++++++++++++-- internal/application/task_governance_test.go | 23 +- internal/application/v3_full_chain_test.go | 29 ++ internal/application/v5_submission_test.go | 26 + internal/application/workbench_registry.go | 196 +++++++ .../application/workbench_registry_test.go | 230 +++++++++ internal/bootstrap/check/check.go | 98 ++-- internal/bootstrap/check/check_test.go | 32 ++ internal/delivery/composition_manifest.go | 214 ++++++++ .../delivery/composition_manifest_test.go | 69 +++ internal/delivery/work_delivery_model.go | 13 +- internal/experience/workbench/manifest.go | 300 +++++++++++ .../experience/workbench/manifest_test.go | 252 +++++++++ internal/experience/workbench/registry.go | 365 +++++++++++++ internal/identity/work_identity_model.go | 2 + internal/integration/agent/adapter_test.go | 12 +- internal/integration/agent/handoff.go | 55 +- internal/integration/agent/registry.go | 1 + internal/integration/composition/worker.go | 187 +++++++ .../integration/composition/worker_test.go | 85 +++ .../integration/pluginhost/claude/native.go | 8 +- internal/local/export/jianying.go | 379 ++++++++++++++ internal/local/export/jianying_test.go | 165 ++++++ internal/local/sync/store.go | 46 +- internal/local/sync/store_test.go | 84 +++ internal/local/workbench/ui/mcp-app.html | 2 +- internal/local/workspace/article_delivery.go | 50 ++ internal/local/workspace/commerce_content.go | 145 ++++++ .../local/workspace/commerce_content_test.go | 71 +++ internal/local/workspace/content.go | 23 +- internal/local/workspace/knowledge.go | 5 + internal/local/workspace/novel.go | 98 ++++ internal/local/workspace/v5_workflow_test.go | 65 ++- .../local/workspace/video_script_delivery.go | 174 +++++++ .../workspace/video_script_delivery_test.go | 29 ++ internal/local/workspace/workspace.go | 6 + internal/local/workspace/workspace_test.go | 10 + internal/persistence/memory/content.go | 63 +++ .../persistence/memory/final_render_test.go | 57 +++ internal/persistence/memory/media.go | 61 +++ internal/persistence/memory/memory.go | 129 ++++- .../persistence/memory/runtime_cleanup.go | 86 ++++ .../memory/runtime_cleanup_test.go | 77 +++ internal/persistence/postgres/delivery.go | 15 + internal/persistence/postgres/media.go | 52 ++ .../postgres/media_integration_test.go | 370 ++++++++++++++ internal/persistence/postgres/migrate.go | 5 +- internal/persistence/postgres/migrate_test.go | 40 +- internal/persistence/postgres/review.go | 28 + .../persistence/postgres/runtime_cleanup.go | 103 ++++ .../runtime_cleanup_integration_test.go | 138 +++++ internal/persistence/postgres/workbench.go | 166 ++++++ .../postgres/workbench_integration_test.go | 62 +++ internal/persistence/repository.go | 21 + internal/platform/fault/error.go | 5 + internal/platform/idgen/id.go | 11 + internal/runtime/dispatch_test.go | 3 +- .../marketing_campaign_benchmark_test.go | 162 ++++++ internal/runtime/model.go | 67 ++- internal/runtime/repository.go | 4 + internal/runtime/service.go | 6 + internal/runtime/worker/runtime_events.go | 29 ++ .../runtime/worker/runtime_events_test.go | 49 +- internal/transport/cli/artifacts.go | 48 ++ internal/transport/cli/bootstrap_commands.go | 6 +- .../transport/cli/bootstrap_commands_test.go | 2 +- internal/transport/cli/bootstrap_runtime.go | 2 +- .../transport/cli/jianying_export_test.go | 45 ++ internal/transport/cli/root.go | 4 +- internal/transport/http/agent_handoff_test.go | 16 +- internal/transport/http/bootstrap.md | 14 +- internal/transport/http/bootstrap_test.go | 2 +- internal/transport/http/cli_dispatch.go | 11 + internal/transport/http/codex.go | 2 +- internal/transport/http/content_handlers.go | 23 + .../http/customer_studio_handlers_test.go | 214 +++++++- .../http/jianying_export_handlers_test.go | 230 +++++++++ .../transport/http/orchestration_handlers.go | 36 ++ .../http/orchestration_handlers_test.go | 2 +- .../http/runtime_cleanup_handlers.go | 28 + internal/transport/http/runtime_handlers.go | 27 + .../transport/http/runtime_handlers_test.go | 274 ++++++++++ internal/transport/http/server.go | 11 + .../transport/http/workbench_handlers_test.go | 41 ++ .../work/storyboard_visual_binding_test.go | 74 +++ internal/work/work_model.go | 98 +++- .../00053_runtime_cleanup_diagnostics.sql | 37 ++ migrations/00054_workbench_registry.sql | 28 + migrations/00055_commerce_content_type.sql | 23 + package.json | 2 +- packages/contentcloud/package.json | 4 +- scripts/check-architecture.mjs | 58 +++ workbench-plugins/README.md | 45 ++ .../examples/article/workbench.json | 27 + .../examples/commerce/workbench.json | 27 + .../examples/marketing-video/workbench.json | 28 + .../examples/serialized-novel/workbench.json | 28 + 271 files changed, 16248 insertions(+), 426 deletions(-) create mode 100644 apps/web/src/admin/cleanupPage.test.tsx create mode 100644 apps/web/src/admin/views/AdminCleanupPage.tsx create mode 100644 apps/web/src/platform/PipelineSummary.test.tsx create mode 100644 apps/web/src/platform/PipelineSummary.tsx create mode 100644 apps/web/src/platform/README.md create mode 100644 apps/web/src/platform/platform.css create mode 100644 apps/web/src/studio/workbenchDefinitions.ts create mode 100644 apps/web/src/workbench/README.md create mode 100644 apps/web/src/workbench/contract/businessBrief.test.ts create mode 100644 apps/web/src/workbench/contract/businessBrief.ts create mode 100644 apps/web/src/workbench/contract/businessTaskTypes.ts create mode 100644 apps/web/src/workbench/contract/navigation.ts create mode 100644 apps/web/src/workbench/host/BusinessBriefFields.test.tsx create mode 100644 apps/web/src/workbench/host/BusinessBriefFields.tsx create mode 100644 apps/web/src/workbench/registry/registry.test.ts create mode 100644 apps/web/src/workbench/registry/registry.ts create mode 100644 apps/web/src/workbench/renderer/BusinessTaskCanvas.test.tsx create mode 100644 apps/web/src/workbench/renderer/BusinessTaskCanvas.tsx create mode 100644 apps/web/src/workbench/renderer/workbench.css create mode 100644 apps/web/src/workbenches/README.md create mode 100644 apps/web/src/workbenches/article/ArticleBriefFields.tsx create mode 100644 apps/web/src/workbenches/article/ArticleTaskCanvas.tsx create mode 100644 apps/web/src/workbenches/article/definition.ts create mode 100644 apps/web/src/workbenches/commerce/CommerceBriefFields.tsx create mode 100644 apps/web/src/workbenches/commerce/CommerceTaskCanvas.tsx create mode 100644 apps/web/src/workbenches/commerce/definition.ts create mode 100644 apps/web/src/workbenches/marketing-video/VideoBriefFields.tsx create mode 100644 apps/web/src/workbenches/marketing-video/VideoTaskCanvas.tsx create mode 100644 apps/web/src/workbenches/marketing-video/definition.ts create mode 100644 apps/web/src/workbenches/serialized-novel/NovelBriefFields.tsx create mode 100644 apps/web/src/workbenches/serialized-novel/NovelTaskCanvas.tsx create mode 100644 apps/web/src/workbenches/serialized-novel/definition.ts create mode 100644 contracts/commerce-content-1.0.schema.json create mode 100644 contracts/video-script-1.0.schema.json create mode 100644 contracts/workbench-plugins/1.0.0/workbench-plugin.schema.json create mode 100644 diagrams/contentcloud-novel-workbench-flow.excalidraw create mode 100644 diagrams/contentcloud-novel-workbench-flow.mmd create mode 100644 diagrams/contentcloud-novel-workbench-flow.png create mode 100644 diagrams/contentcloud-novel-workbench-flow.svg create mode 100644 diagrams/contentcloud-product-layered-architecture.excalidraw create mode 100644 diagrams/contentcloud-product-layered-architecture.mmd create mode 100644 diagrams/contentcloud-product-layered-architecture.png create mode 100644 diagrams/contentcloud-product-layered-architecture.svg create mode 100644 diagrams/contentcloud-video-workbench-flow.excalidraw create mode 100644 diagrams/contentcloud-video-workbench-flow.mmd create mode 100644 diagrams/contentcloud-video-workbench-flow.png create mode 100644 diagrams/contentcloud-video-workbench-flow.svg create mode 100644 diagrams/contentcloud-video-workbench-sequence.mmd create mode 100644 diagrams/contentcloud-video-workbench-sequence.png create mode 100644 diagrams/contentcloud-video-workbench-sequence.svg create mode 100644 diagrams/contentcloud-video-workbench-state.mmd create mode 100644 diagrams/contentcloud-video-workbench-state.png create mode 100644 diagrams/contentcloud-video-workbench-state.svg create mode 100644 diagrams/contentcloud-workbench-registry-lifecycle.excalidraw create mode 100644 diagrams/contentcloud-workbench-registry-lifecycle.mmd create mode 100644 diagrams/contentcloud-workbench-registry-lifecycle.png create mode 100644 diagrams/contentcloud-workbench-registry-lifecycle.svg create mode 100644 docs/foundation/06-layered-platform-ownership.md create mode 100644 docs/product/customer-creation-studio/07-business-workbench-ui-design.md create mode 100644 docs/product/customer-creation-studio/08-business-workbench-prototype.md create mode 100644 docs/product/customer-creation-studio/prototypes/business-workbench-prototype.html create mode 100644 docs/roadmap/v8/12-video-production-migration.md create mode 100644 docs/roadmap/v8/13-video-production-execution.md create mode 100644 docs/roadmap/v8/14-video-production-iteration.md create mode 100644 docs/roadmap/v8/15-provider-state-matrix.md create mode 100644 docs/roadmap/v8/16-video-production-progress-plan.md create mode 100644 docs/roadmap/v8/17-stage-e-composition-plan.md create mode 100644 docs/roadmap/v8/18-stage-e1-composition-manifest-plan.md create mode 100644 docs/roadmap/v8/19-stage-e1d-failure-recovery-plan.md create mode 100644 docs/roadmap/v8/20-video-production-completion-plan.md create mode 100644 docs/roadmap/v8/21-e1d-b-blob-cleanup-plan.md create mode 100644 docs/roadmap/v8/22-stage-f-jianying-export-plan.md create mode 100644 docs/roadmap/v8/23-documentation-sync-plan.md create mode 100644 docs/roadmap/v8/24-video-production-worklog.md create mode 100644 docs/roadmap/v8/25-stage-d2-postgres-parity-plan.md create mode 100644 docs/roadmap/v8/26-e1d-b-postgres-final-render-plan.md create mode 100644 docs/roadmap/v8/27-e1d-b-blob-fault-injection-plan.md create mode 100644 docs/roadmap/v8/28-e1d-b-recovery-execution-plan.md create mode 100644 docs/roadmap/v8/29-e1d-b-r2-cleanup-retry-plan.md create mode 100644 docs/roadmap/v8/30-e1d-b-recovery-acceptance-plan.md create mode 100644 docs/roadmap/v8/31-runtime-business-benchmark-acceptance.md create mode 100644 internal/application/article_retrospective_benchmark_test.go create mode 100644 internal/application/artifact_storage.go create mode 100644 internal/application/artifact_storage_test.go create mode 100644 internal/application/commerce_full_chain_test.go create mode 100644 internal/application/customer_studio_input_test.go create mode 100644 internal/application/jianying_export.go create mode 100644 internal/application/jianying_export_test.go create mode 100644 internal/application/marketing_campaign_benchmark_test.go create mode 100644 internal/application/media_pipeline_batch_test.go create mode 100644 internal/application/media_pipeline_blob_failure_test.go create mode 100644 internal/application/media_pipeline_budget_test.go create mode 100644 internal/application/media_pipeline_state_test.go create mode 100644 internal/application/novel_full_chain_test.go create mode 100644 internal/application/runtime_cleanup.go create mode 100644 internal/application/runtime_cleanup_test.go create mode 100644 internal/application/workbench_registry.go create mode 100644 internal/application/workbench_registry_test.go create mode 100644 internal/delivery/composition_manifest.go create mode 100644 internal/delivery/composition_manifest_test.go create mode 100644 internal/experience/workbench/manifest.go create mode 100644 internal/experience/workbench/manifest_test.go create mode 100644 internal/experience/workbench/registry.go create mode 100644 internal/integration/composition/worker.go create mode 100644 internal/integration/composition/worker_test.go create mode 100644 internal/local/export/jianying.go create mode 100644 internal/local/export/jianying_test.go create mode 100644 internal/local/workspace/article_delivery.go create mode 100644 internal/local/workspace/commerce_content.go create mode 100644 internal/local/workspace/commerce_content_test.go create mode 100644 internal/local/workspace/video_script_delivery.go create mode 100644 internal/local/workspace/video_script_delivery_test.go create mode 100644 internal/persistence/memory/final_render_test.go create mode 100644 internal/persistence/memory/runtime_cleanup.go create mode 100644 internal/persistence/memory/runtime_cleanup_test.go create mode 100644 internal/persistence/postgres/media_integration_test.go create mode 100644 internal/persistence/postgres/runtime_cleanup.go create mode 100644 internal/persistence/postgres/runtime_cleanup_integration_test.go create mode 100644 internal/persistence/postgres/workbench.go create mode 100644 internal/persistence/postgres/workbench_integration_test.go create mode 100644 internal/runtime/marketing_campaign_benchmark_test.go create mode 100644 internal/transport/cli/jianying_export_test.go create mode 100644 internal/transport/http/jianying_export_handlers_test.go create mode 100644 internal/transport/http/runtime_cleanup_handlers.go create mode 100644 internal/transport/http/workbench_handlers_test.go create mode 100644 internal/work/storyboard_visual_binding_test.go create mode 100644 migrations/00053_runtime_cleanup_diagnostics.sql create mode 100644 migrations/00054_workbench_registry.sql create mode 100644 migrations/00055_commerce_content_type.sql create mode 100644 workbench-plugins/README.md create mode 100644 workbench-plugins/examples/article/workbench.json create mode 100644 workbench-plugins/examples/commerce/workbench.json create mode 100644 workbench-plugins/examples/marketing-video/workbench.json create mode 100644 workbench-plugins/examples/serialized-novel/workbench.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c380f..d42bfa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ContentCloud 的重要变更记录在此文件中。 +## [0.29.0] - 2026-08-24 + +### Added + +- 增加按业务定制的视频、文章、电商和连载小说工作台,并通过 Workbench Registry 管理版本、摘要、模板绑定和租户启用。 +- 打通工作台 Action Contract 到 WorkTask、SOP、Gate、Runtime、Review、ApprovedSnapshot、Artifact、Delivery 和 Performance 的统一平台主链。 +- 增加 Claude Code 宿主 bootstrap、preflight、plan、apply、resume 与 Web 连接宿主选择能力。 +- 增加 Runtime 清理诊断、媒体合成、剪映导出和多业务完整链路验证能力。 + +### Changed + +- 更新分层平台架构、代码组织、插件边界、业务工作台 UI 原型、时序图和流程图文档。 +- 管理后台增加工作台、Runtime 恢复、清理和运营诊断视图;客户任务展示统一的流程、产物和效果摘要。 +- Server、Worker、Web、Desktop、CLI 和 npm 启动包统一升级到 `0.29.0`;视频生产场景插件继续固定在已验证的 `0.27.0`。 + +### Fixed + +- 修复客户连接客户端目录在开放 Claude Code 后仍只允许 Codex 的 HTTP 测试断言。 + ## [0.28.0] - 2026-08-17 ### Added diff --git a/VERSION b/VERSION index 697f087..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 +0.29.0 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b6354ff..10a75a5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@limecloud/contentcloud-desktop", "private": true, - "version": "0.28.0", + "version": "0.29.0", "description": "ContentCloud project workspace desktop", "author": "ContentCloud", "license": "Apache-2.0", diff --git a/apps/web/package.json b/apps/web/package.json index 6c50bb0..5b65d1e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@limecloud/contentcloud-web", "private": true, - "version": "0.28.0", + "version": "0.29.0", "type": "module", "scripts": { "dev": "vite --config vite.config.ts --host 0.0.0.0", diff --git a/apps/web/src/admin/AdminShell.tsx b/apps/web/src/admin/AdminShell.tsx index c51a408..7318751 100644 --- a/apps/web/src/admin/AdminShell.tsx +++ b/apps/web/src/admin/AdminShell.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Activity, AlertTriangle, Boxes, CircleDollarSign, FolderKanban, Gauge, GitBranch, LayoutDashboard, LogOut, Menu, RefreshCw, Settings2, ShieldCheck, Users, Workflow, X, type LucideIcon, PlugZap } from 'lucide-react'; +import { Activity, AlertTriangle, Boxes, CircleDollarSign, FolderKanban, Gauge, GitBranch, LayoutDashboard, LogOut, Menu, RefreshCw, Settings2, ShieldCheck, Users, Workflow, X, type LucideIcon, PlugZap, Trash2 } from 'lucide-react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { post } from '../api'; import { Banner, IconButton, Loading } from '../components/ui'; @@ -13,12 +13,14 @@ const routeTitles:Record={ [adminPath('products')]:'创作流程', [adminPath('releases')]:'发布版本', [adminPath('customers')]:'客户设置', + [adminPath('workbenches')]:'业务工作台', [adminPath('capabilities')]:'功能清单', [adminPath('skills')]:'自动化工具', [adminPath('executors')]:'连接的电脑', [adminPath('providers')]:'视频服务', [adminPath('jobs')]:'任务进度', [adminPath('alerts')]:'需要处理', + [adminPath('cleanup')]:'清理诊断', [adminPath('tenants')]:'客户列表', [adminPath('audit')]:'变更记录', [adminPath('costs')]:'任务统计' @@ -40,6 +42,7 @@ export function AdminShell() { setMobileOpen(false)}/> setMobileOpen(false)}/> setMobileOpen(false)}/> + {session.is_platform_admin&&setMobileOpen(false)}/>}
功能设置
setMobileOpen(false)}/> setMobileOpen(false)}/> @@ -48,6 +51,7 @@ export function AdminShell() {
任务跟进
setMobileOpen(false)}/> setMobileOpen(false)}/> + setMobileOpen(false)}/>
账号与记录
setMobileOpen(false)}/> setMobileOpen(false)}/> diff --git a/apps/web/src/admin/cleanupPage.test.tsx b/apps/web/src/admin/cleanupPage.test.tsx new file mode 100644 index 0000000..c9449f8 --- /dev/null +++ b/apps/web/src/admin/cleanupPage.test.tsx @@ -0,0 +1,18 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { AdminCleanupPage, cleanupStatusLabel } from './views/AdminCleanupPage'; + +describe('admin cleanup diagnostics', () => { + it('uses explicit labels for durable cleanup states', () => { + expect(cleanupStatusLabel('pending')).toBe('待清理'); + expect(cleanupStatusLabel('cleaned')).toBe('已清理'); + expect(cleanupStatusLabel('not_found')).toBe('对象已不存在'); + expect(cleanupStatusLabel('unknown')).toBe('unknown'); + }); + + it('renders the operator boundary before loading remote facts', () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain('清理诊断'); + expect(markup).toContain('不会创建任务、审批或产物事实'); + }); +}); diff --git a/apps/web/src/admin/context.tsx b/apps/web/src/admin/context.tsx index 5855f31..7bb3666 100644 --- a/apps/web/src/admin/context.tsx +++ b/apps/web/src/admin/context.tsx @@ -1,12 +1,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from 'react'; import { api, patch } from '../api'; -import type { AdminWorkOSView, ContentType, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, PlatformTenant, Session, Tenant } from '../types'; +import type { AdminWorkOSView, ContentType, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, PlatformTenant, Session, Tenant, WorkbenchRegistryView } from '../types'; import { normalizeAdminWorkOSView, normalizeOperationsExecutorDirectory, normalizeOperationsSkillDirectory } from './operationsData'; interface AdminContextValue { session:Session; data?:PlatformOverview; workOS?:AdminWorkOSView; + workbenchRegistry?:WorkbenchRegistryView; executorDirectory?:OperationsExecutorDirectory; skillDirectory?:OperationsSkillDirectory; executorDirectoryError:string; @@ -30,10 +31,11 @@ async function loadOptional(request:Promise):Promise> { } export async function loadAdminSnapshot(isPlatformAdmin:boolean) { - const [workOSResponse,executorResult,skillResult]=await Promise.all([ + const [workOSResponse,executorResult,skillResult,workbenchResult]=await Promise.all([ api('/api/bff/admin/work-os'), loadOptional(api('/api/bff/operations/executors')), - isPlatformAdmin?loadOptional(api('/api/bff/operations/skills')):Promise.resolve(undefined) + isPlatformAdmin?loadOptional(api('/api/bff/operations/skills')):Promise.resolve(undefined), + isPlatformAdmin?loadOptional(api('/api/bff/admin/workbenches')):Promise.resolve(undefined) ]); const workOS=normalizeAdminWorkOSView(workOSResponse); const executorDirectory=executorResult.ok @@ -44,12 +46,13 @@ export async function loadAdminSnapshot(isPlatformAdmin:boolean) { ?normalizeOperationsSkillDirectory(skillResult.value) :{configured:false,skills:[],generated_at:workOS.generated_at} :undefined; + const workbenchRegistry=isPlatformAdmin&&workbenchResult?.ok?workbenchResult.value:undefined; const data:PlatformOverview={counts:{tenants:1,active_tenants:workOS.environments.filter(item=>item.status==='active').length,users:0,projects:0,online_devices:executorDirectory.executors.filter(item=>item.presence_status==='online').length,active_runs:workOS.usage.running_count},tenants:[],users:[],generated_at:executorDirectory.generated_at||workOS.generated_at}; return { data, workOS, executorDirectory, - skillDirectory, + skillDirectory, workbenchRegistry, executorDirectoryError:executorResult.ok?'':executorResult.error, skillDirectoryError:skillResult&&!skillResult.ok?skillResult.error:'' }; @@ -60,6 +63,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess const [workOS,setWorkOS]=useState(); const [executorDirectory,setExecutorDirectory]=useState(); const [skillDirectory,setSkillDirectory]=useState(); + const [workbenchRegistry,setWorkbenchRegistry]=useState(); const [executorDirectoryError,setExecutorDirectoryError]=useState(''); const [skillDirectoryError,setSkillDirectoryError]=useState(''); const [loading,setLoading]=useState(true); @@ -72,6 +76,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess setWorkOS(snapshot.workOS); setExecutorDirectory(snapshot.executorDirectory); setSkillDirectory(snapshot.skillDirectory); + setWorkbenchRegistry(snapshot.workbenchRegistry); setExecutorDirectoryError(snapshot.executorDirectoryError); setSkillDirectoryError(snapshot.skillDirectoryError); setData(snapshot.data) @@ -90,7 +95,7 @@ export function AdminProvider({session,children}:PropsWithChildren<{session:Sess try{const tenant=await api(`/api/v1/admin/tenants/${tenantID}/content-capabilities/${contentType}`,{method:'PUT',body:JSON.stringify({enabled})});await refresh(true);return tenant} catch(value){setError(value instanceof Error?value.message:'内容能力更新失败');throw value} },[refresh]); - const value=useMemo(()=>({session,data,workOS,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,clearError:()=>setError(''),refresh,setTenantStatus,setTenantContentCapability}),[session,data,workOS,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,refresh,setTenantStatus,setTenantContentCapability]); + const value=useMemo(()=>({session,data,workOS,workbenchRegistry,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,clearError:()=>setError(''),refresh,setTenantStatus,setTenantContentCapability}),[session,data,workOS,workbenchRegistry,executorDirectory,skillDirectory,executorDirectoryError,skillDirectoryError,loading,refreshing,error,refresh,setTenantStatus,setTenantContentCapability]); return {children}; } diff --git a/apps/web/src/admin/operationsPages.test.tsx b/apps/web/src/admin/operationsPages.test.tsx index a1efe2b..2b61e16 100644 --- a/apps/web/src/admin/operationsPages.test.tsx +++ b/apps/web/src/admin/operationsPages.test.tsx @@ -1,14 +1,14 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { Route, Routes, StaticRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AdminWorkOSView, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, Session } from '../types'; +import type { AdminWorkOSView, OperationsExecutorDirectory, OperationsSkillDirectory, PlatformOverview, Session, WorkbenchRegistryView } from '../types'; const adminState=vi.hoisted(()=>({value:{} as Record})); vi.mock('./context',()=>({useAdmin:()=>adminState.value})); import { AdminShell } from './AdminShell'; -import { AdminCapabilityCatalogPage, AdminCapabilityDetailPage, AdminCustomerDetailPage, AdminCustomersPage, AdminExecutorDetailPage, AdminExecutorsPage, AdminOperationsOverview, AdminProductDetailPage, AdminProductReleasesPage, AdminProductsPage, AdminReleaseResultPage, AdminSkillDetailPage, AdminSkillsPage } from './views/AdminOperationsPages'; +import { AdminCapabilityCatalogPage, AdminCapabilityDetailPage, AdminCustomerDetailPage, AdminCustomersPage, AdminExecutorDetailPage, AdminExecutorsPage, AdminOperationsOverview, AdminProductDetailPage, AdminProductReleasesPage, AdminProductsPage, AdminReleaseResultPage, AdminSkillDetailPage, AdminSkillsPage, AdminWorkbenchRegistryPage, buildWorkbenchStatePayload, WorkbenchStateModal, workbenchLifecycleLabel } from './views/AdminOperationsPages'; const session:Session={user:{id:'user-1',email:'operator@example.com',display_name:'运营人员'},tenant:{id:'tenant-1',name:'平台运营',slug:'platform',status:'active',created_at:'2026-08-01T08:00:00Z'},role:'tenant_admin',is_platform_admin:true}; const overview:PlatformOverview={counts:{tenants:1,active_tenants:1,users:1,projects:1,online_devices:1,active_runs:1},tenants:[],users:[],generated_at:'2026-08-08T02:30:00Z'}; @@ -25,6 +25,7 @@ const workOS:AdminWorkOSView={ const executorDirectory:OperationsExecutorDirectory={executors:[{id:'executor-1',tenant_id:'tenant-1',display_name:'分镜工作站',executor_type:'contentcloud_device',status:'online',status_reason:'instance_connected',presence_status:'online',presence_reason:'instance_connected',environment_status:'repair_required',environment_reason:'plugin_drift',runtime_status:'throttled',runtime_reason:'capacity_limit',daemon_instance_id:'instance-1',connection_epoch:3,active_attempt_ids:['attempt-1'],runtimes:[{kind:'codex',version:'codex 1.2.3',status:'healthy',selected:true,capabilities:{events:true,resume:true,mcp_stdio:true,structured_output:true,max_parallel_sessions:8}},{kind:'claude',status:'unhealthy',error_code:'CLAUDE_AUTH_REQUIRED',selected:false,capabilities:{}}],workspaces:[{workspace_id:'workspace-1',project_id:'project-1',status:'repair_required',reason:'skill_drift',generation:'sha256:generation',plugin_receipt_digest:'sha256:plugin-receipt',observed_at:'2026-08-08T02:29:00Z'}],hostname:'storyboard.local',platform:'darwin',arch:'arm64',version:'0.21.0',capabilities:[{id:'inspiration_collection',version:'1.0.0',kind:'business_capability',input_schema:'contentcloud.inspiration-query/1.0',output_schema:'contentcloud.inspiration-result/1.0',presentation_profiles:['candidate-list'],local_only:true,digest:'capability-digest'}],projects:[{id:'project-1',brand_name:'果木食品',product_name:'品牌短片',status:'active'}],last_seen_at:'2026-08-08T02:29:00Z'}],generated_at:'2026-08-08T02:30:00Z',online_window_seconds:45}; const skillDirectory:OperationsSkillDirectory={configured:true,source:'verified_plugin_registry',registry_schema_version:'1.0',generated_at:'2026-08-08T02:30:00Z',skills:[{id:'contentcloud-script-writing',version:'1.2.0',digest:'sha256:skill',kind:'skill_pack',lifecycle:'published',available_for_new_runs:true,source:{repository:'https://github.com/limecloud/contentcloud',ref:'v1.2.0',license:'Apache-2.0'},signature:{status:'verified',algorithm:'ed25519',key_id:'plugin-release'},compatible_profiles:['contentcloud.video-production'],permissions:['workspace:read'],data_flow:{local_by_default:true,cloud_actions:[]},cost:{model:'included',notice:'Included in subscription.'},output_schemas:['contracts/content-item-3.0.schema.json'],evaluation:{status:'passed',report:'.agents/plugins/evaluations/script.json',digest:'sha256:evaluation',evidence:['contract-tests']},revocation:{status:'active'}}]}; +const workbenchRegistry:WorkbenchRegistryView={generated_at:'2026-08-08T02:30:00Z',entries:[{digest:'sha256:workbench',status:'published',template_aliases:['article_content'],tenant_ids:[],manifest:{'$schema':'https://contentcloud.run/schemas/workbench-plugin/1.0.0/workbench-plugin.schema.json',id:'contentcloud-workbench-article',version:'1.0.0',name:'文章创作工作台',content_types:['article'],experience:{template_id:'article_content'},ui:{renderer:'approved',layout:'article-editor',density:'comfortable',theme:'editorial-green',navigation:[{id:'overview',label:'文章首页',icon:'pen-line'}],stages:[{id:'draft',label:'文章草稿',outcome:'完成文章草稿',primary_action:'save_draft'}]}}}]}; function setAdminView(nextWorkOS:AdminWorkOSView=workOS,nextExecutors:OperationsExecutorDirectory=executorDirectory,nextSkills:OperationsSkillDirectory=skillDirectory){ adminState.value={session,data:overview,workOS:nextWorkOS,executorDirectory:nextExecutors,skillDirectory:nextSkills,executorDirectoryError:'',skillDirectoryError:'',loading:false,refreshing:false,error:'',clearError:()=>{},refresh:async()=>{},setTenantStatus:async()=>session.tenant,setTenantContentCapability:async()=>{throw new Error('not used')}}; @@ -77,6 +78,42 @@ describe('operations control plane pages',()=>{ expect(skillMarkup).toContain('可用于新任务'); }); + it('shows workbench registration only to platform administrators',()=>{ + setAdminView(); + adminState.value.workbenchRegistry=workbenchRegistry; + const platformMarkup=render(,'/admin/workbenches'); + expect(platformMarkup).toContain('登记版本'); + expect(platformMarkup).toContain('文章创作工作台'); + + adminState.value={...adminState.value,session:{...session,is_platform_admin:false},workbenchRegistry:undefined}; + const tenantMarkup=render(,'/admin/workbenches'); + expect(tenantMarkup).toContain('业务工作台仅限平台管理员'); + expect(tenantMarkup).not.toContain('登记版本'); + }); + + it('keeps workbench lifecycle actions explicit and refuses unsafe payloads',()=>{ + expect(workbenchLifecycleLabel('draft')).toBe('发布'); + expect(workbenchLifecycleLabel('published')).toBe('正常退役'); + expect(workbenchLifecycleLabel('retired')).toBe('恢复发布'); + expect(workbenchLifecycleLabel('revoked')).toBe('已安全撤销'); + expect(()=>buildWorkbenchStatePayload(workbenchRegistry.entries[0],'revoked','tenant-a','')).toThrow('必须填写原因'); + expect(()=>buildWorkbenchStatePayload({...workbenchRegistry.entries[0],status:'revoked'},'published','tenant-a')).toThrow('不能恢复'); + expect(()=>buildWorkbenchStatePayload(workbenchRegistry.entries[0],'draft','tenant-a')).toThrow('不能退回草稿'); + expect(buildWorkbenchStatePayload(workbenchRegistry.entries[0],'published','tenant-a\ntenant-a, tenant-b',' 来源校验失败 ')).toEqual({status:'published',tenant_ids:['tenant-a','tenant-b'],reason:'来源校验失败'}); + }); + + it('renders the separate scope, retirement, and security-revocation controls',()=>{ + const publishedMarkup=render({}} reason="" setReason={()=>{}} busy={false} notice="" onClose={()=>{}} onUpdate={()=>{}}/>); + expect(publishedMarkup).toContain('保存租户范围'); + expect(publishedMarkup).toContain('正常退役'); + expect(publishedMarkup).toContain('永久安全撤销'); + expect(publishedMarkup).toContain('撤销后不可恢复'); + const revokedMarkup=render({}} reason="" setReason={()=>{}} busy={false} notice="" onClose={()=>{}} onUpdate={()=>{}}/>); + expect(revokedMarkup).toContain('不能恢复、发布或调整租户范围'); + expect(revokedMarkup).toContain('签名验证失败'); + expect(revokedMarkup).not.toContain('安全撤销原因'); + }); + it('keeps an unconfigured skill registry explicit and empty',()=>{ setAdminView(workOS,executorDirectory,{configured:false,skills:[],generated_at:'2026-08-08T02:30:00Z'}); const markup=render(,'/admin/skills'); diff --git a/apps/web/src/admin/routes.test.ts b/apps/web/src/admin/routes.test.ts index cf012ff..d3d39fb 100644 --- a/apps/web/src/admin/routes.test.ts +++ b/apps/web/src/admin/routes.test.ts @@ -8,8 +8,10 @@ describe('admin routes',()=>{ it('maps every admin section to a stable deep link',()=>{ expect(adminPath('dashboard')).toBe('/admin/dashboard'); expect(adminPath('products')).toBe('/admin/products'); + expect(adminPath('workbenches')).toBe('/admin/workbenches'); expect(adminPath('capabilities')).toBe('/admin/capabilities'); expect(adminPath('jobs')).toBe('/admin/jobs'); + expect(adminPath('cleanup')).toBe('/admin/cleanup'); expect(adminPath('providers')).toBe('/admin/providers'); expect(adminPath('tenants')).toBe('/admin/tenants'); expect(adminPath('audit')).toBe('/admin/audit'); @@ -33,7 +35,7 @@ describe('admin routes',()=>{ }); it('mounts the new operations workspaces as independent pages',()=>{ - const paths=['products','releases','customers','capabilities','skills','executors','providers','jobs','alerts','tenants','audit','costs']; + const paths=['products','releases','customers','workbenches','capabilities','skills','executors','providers','jobs','alerts','cleanup','tenants','audit','costs']; for(const path of paths){ const matches=matchRoutes(appRoutes,`/admin/${path}`); expect(matches?.map(item=>item.route.path)).toEqual(['/admin',undefined,path]); diff --git a/apps/web/src/admin/routes.ts b/apps/web/src/admin/routes.ts index 8999ab3..506da78 100644 --- a/apps/web/src/admin/routes.ts +++ b/apps/web/src/admin/routes.ts @@ -1,16 +1,18 @@ -export type AdminRoute = 'dashboard'|'products'|'releases'|'customers'|'capabilities'|'skills'|'executors'|'providers'|'jobs'|'alerts'|'tenants'|'audit'|'costs'; +export type AdminRoute = 'dashboard'|'products'|'releases'|'customers'|'workbenches'|'capabilities'|'skills'|'executors'|'providers'|'jobs'|'alerts'|'cleanup'|'tenants'|'audit'|'costs'; const adminPaths: Record = { dashboard: '/admin/dashboard', products: '/admin/products', releases: '/admin/releases', customers: '/admin/customers', + workbenches: '/admin/workbenches', capabilities: '/admin/capabilities', skills: '/admin/skills', executors: '/admin/executors', providers: '/admin/providers', jobs: '/admin/jobs', alerts: '/admin/alerts', + cleanup: '/admin/cleanup', tenants: '/admin/tenants', audit: '/admin/audit', costs: '/admin/costs' diff --git a/apps/web/src/admin/runtimePage.test.tsx b/apps/web/src/admin/runtimePage.test.tsx index a2615ea..d760920 100644 --- a/apps/web/src/admin/runtimePage.test.tsx +++ b/apps/web/src/admin/runtimePage.test.tsx @@ -7,10 +7,10 @@ const detail: RuntimeJobDetail = { summary: { id: 'job-forked-1', work_task_id: 'task-1', task_title: '恢复任务', customer_name: '果木食品客户', project_id: 'project-1', project_name: '果木食品', product_name: '品牌短视频', product_version: 2, current_step_name: '生成分镜', completed_steps: 1, total_steps: 3, task_status: 'running', task_next_action: '继续处理', state: 'running', status_since: '2026-08-08T02:00:00Z', blocking_reason: '任务正在按计划处理', recommended_action: '继续观察当前任务', cost: { status: 'not_recorded', amount_minor: 0, effect_count: 0 }, plan_digest: 'sha256:plan', binding_digest: 'sha256:binding', input_digest: 'sha256:input', runtime_policy_id: 'runtime-policy/customer-studio-v1', contract_major: 1, contract_minor: 0, root_job_run_id: 'job-source-1', source_job_run_id: 'job-source-1', checkpoint_id: 'checkpoint-1', priority: 1, allowed_actions: ['replay', 'refresh', 'cancel'], node_count: 1, node_states: { running: 1 }, effect_count: 1, checkpoint_count: 1, created_at: '2026-08-08T02:00:00Z', updated_at: '2026-08-08T02:01:00Z' }, - plan: { id: 'plan-1', sop_id: 'sop-1', sop_version: 1, sop_digest: 'sha256:sop', schema_version: 'contentcloud.job-plan/1.0', digest: 'sha256:plan', customer_steps: [], compiled_at: '2026-08-08T02:00:00Z' }, + plan: { id: 'plan-1', graph_version: 1, sop_id: 'sop-1', sop_version: 1, sop_digest: 'sha256:sop', schema_version: 'contentcloud.job-plan/1.0', digest: 'sha256:plan', nodes: [], edges: [], customer_steps: [], limits: { max_nodes: 100, max_depth: 32, max_dynamic_descendants: 100, max_concurrent_nodes: 20, max_attempts_per_node: 3, max_cost_minor: 0 }, compiled_at: '2026-08-08T02:00:00Z' }, nodes: [], attempts: [], events: [], agents: [], gates: [], state_collections: [], effects: [{ id: 'effect-1', node_run_id: 'node-1', kind: 'media.generate', state: 'unknown', request_digest: 'sha256:req', cost_minor: 0, currency: 'CNY', safe_summary: {}, version: 2, allowed_actions: ['reconcile'], created_at: '2026-08-08T02:00:00Z', updated_at: '2026-08-08T02:00:30Z' }], - checkpoints: [{ id: 'checkpoint-1', node_key: 'brief', plan_digest: 'sha256:plan', state_ref_count: 1, output_ref_count: 1, completed_nodes: ['brief'], digest: 'sha256:checkpoint', allowed_actions: [], blocked_reason: '先暂停或结束源执行实例', created_at: '2026-08-08T02:00:00Z' }], + checkpoints: [{ id: 'checkpoint-1', node_key: 'brief', plan_digest: 'sha256:plan', state_ref_count: 1, output_ref_count: 1, completed_nodes: ['brief'], digest: 'sha256:checkpoint', allowed_actions: [], blocked_reason: '先暂停或结束源执行实例', created_at: '2026-08-08T02:00:00Z' }], fanout_sets: [], generated_at: '2026-08-08T02:01:00Z' }; diff --git a/apps/web/src/admin/views/AdminCleanupPage.tsx b/apps/web/src/admin/views/AdminCleanupPage.tsx new file mode 100644 index 0000000..87dae80 --- /dev/null +++ b/apps/web/src/admin/views/AdminCleanupPage.tsx @@ -0,0 +1,86 @@ +import { AlertTriangle, CheckCircle2, Clock3, RefreshCw, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { api, post } from '../../api'; +import type { RuntimeCleanupDiagnostic, RuntimeCleanupStatus } from '../../types'; + +const statusOptions: Array<{ value: '' | RuntimeCleanupStatus; label: string }> = [ + { value: '', label: '全部状态' }, + { value: 'pending', label: '待清理' }, + { value: 'retrying', label: '清理中' }, + { value: 'failed', label: '清理失败' }, + { value: 'cleaned', label: '已清理' }, + { value: 'not_found', label: '对象已不存在' }, +]; + +export function cleanupStatusLabel(value: string): string { + return statusOptions.find(item => item.value === value)?.label || value || '未知状态'; +} + +function statusTone(value: string): string { + if (value === 'cleaned' || value === 'not_found') return 'success'; + if (value === 'failed') return 'danger'; + if (value === 'pending' || value === 'retrying') return 'warning'; + return 'neutral'; +} + +function dateTime(value?: string): string { + if (!value) return '未安排'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '未安排'; + return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date); +} + +function short(value: string, length = 24): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +export function AdminCleanupPage() { + const [status, setStatus] = useState<'' | RuntimeCleanupStatus>(''); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + + const load = async () => { + setLoading(true); + setError(''); + try { + const query = status ? `?status=${encodeURIComponent(status)}&limit=100` : '?limit=100'; + const next = await api(`/api/bff/runtime/cleanup-diagnostics${query}`); + setItems(next || []); + } catch (value) { + setError(value instanceof Error ? value.message : '清理诊断读取失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, [status]); + + const retry = async (item: RuntimeCleanupDiagnostic) => { + if (!window.confirm(`确认重试清理临时对象“${item.object_key}”?这不会重新提交业务任务或生成新的产物。`)) return; + setBusy(item.id); + setError(''); + setNotice(''); + try { + const result = await post<{ diagnostic: RuntimeCleanupDiagnostic; delete_attempted: boolean }>(`/api/bff/runtime/cleanup-diagnostics/${encodeURIComponent(item.id)}/retry`); + setItems(current => current.map(value => value.id === item.id ? result.diagnostic : value)); + setNotice(result.diagnostic.status === 'cleaned' || result.diagnostic.status === 'not_found' ? '清理诊断已收敛,业务事实保持不变。' : '清理仍未完成,系统已记录下一次重试时间。'); + } catch (value) { + setError(value instanceof Error ? value.message : '清理重试失败'); + } finally { + setBusy(''); + } + }; + + return
+
运行治理 / 临时对象

清理诊断

处理数据库事实已经回滚、但临时 Blob 仍需清理的异常。这里不会创建任务、审批或产物事实。

+
Runtime 清理事实状态、版本和重试次数由服务端持久化并通过 CAS 更新;清理成功不会改变 ApprovedSnapshot、Artifact 或 Delivery。
待处理{items.filter(item => item.status === 'pending' || item.status === 'failed').length}清理中{items.filter(item => item.status === 'retrying').length}已收敛{items.filter(item => item.status === 'cleaned' || item.status === 'not_found').length}
+ {error &&
{error}
} + {notice &&
{notice}
} +
持久化诊断

{items.length} 条记录

+ {loading ?
正在读取清理诊断…
: items.length === 0 ?
没有匹配的清理诊断数据库事实和临时对象保持一致。
:
临时对象失败原因状态尝试下次重试更新时间操作
{items.map(item =>
{short(item.object_key)}{item.task_id} · {item.manifest_digest ? short(item.manifest_digest, 18) : '无摘要'}{item.cause_code}{item.cause_summary}{item.cleanup_error && {item.cleanup_error}}{cleanupStatusLabel(item.status)}{item.attempt_count}{dateTime(item.next_retry_at)}{dateTime(item.updated_at)}{(item.status === 'pending' || item.status === 'failed') ? : 无需处理}
)}
} +
+
; +} diff --git a/apps/web/src/admin/views/AdminOperationsPages.tsx b/apps/web/src/admin/views/AdminOperationsPages.tsx index 60cf61d..22d5bdb 100644 --- a/apps/web/src/admin/views/AdminOperationsPages.tsx +++ b/apps/web/src/admin/views/AdminOperationsPages.tsx @@ -1,12 +1,13 @@ -import { Activity, AlertTriangle, ArrowLeft, ArrowRight, Ban, CircleDashed, ClipboardCheck, Copy, FileCheck2, FolderKanban, GitBranch, KeyRound, PackageCheck, RefreshCw, ShieldCheck, Sparkles, Users, Wrench } from 'lucide-react'; +import { Activity, AlertTriangle, ArrowLeft, ArrowRight, Ban, CircleDashed, ClipboardCheck, Copy, FileCheck2, FolderKanban, GitBranch, KeyRound, PackageCheck, Plus, RefreshCw, Save, Settings2, ShieldAlert, ShieldCheck, Sparkles, Users, Wrench } from 'lucide-react'; import { useState, type ReactNode } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { post } from '../../api'; -import { Button, Empty, Status } from '../../components/ui'; +import { patch, post } from '../../api'; +import { Button, Empty, Field, Modal, Status } from '../../components/ui'; import { AdminEnvironmentPanel, AdminSOPPanel, CreateProductModal, createAdminProduct, emptyAdminProductDraft } from '../WorkOSConfigPanels'; import { useAdmin } from '../context'; import { adminCapabilityPath, adminCustomerPath, adminCustomersForProductPath, adminExecutorPath, adminPath, adminProductPath, adminProductVersionPath, adminReleaseResultPath, adminSkillPath } from '../routes'; import { auditActionLabel, auditSubjectLabel } from '../../uiLabels'; +import type { WorkbenchPluginEntry, WorkbenchPluginManifest } from '../../types'; const dateTime = (value:string) => new Intl.DateTimeFormat('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).format(new Date(value)); @@ -39,6 +40,81 @@ export function AdminOperationsOverview() { ; } +export function AdminWorkbenchRegistryPage() { + const {session,workbenchRegistry,refresh}=useAdmin(); + const [busy,setBusy]=useState(''); + const [createOpen,setCreateOpen]=useState(false); + const [manageEntry,setManageEntry]=useState(); + const [manageScope,setManageScope]=useState(''); + const [revokeReason,setRevokeReason]=useState(''); + const [manageNotice,setManageNotice]=useState(''); + const [createNotice,setCreateNotice]=useState(''); + const [pageNotice,setPageNotice]=useState(''); + const [manifestJSON,setManifestJSON]=useState(defaultWorkbenchManifestJSON); + const [templateAliases,setTemplateAliases]=useState(''); + const [tenantIDs,setTenantIDs]=useState(''); + if(!session.is_platform_admin)return ; + if(!workbenchRegistry)return ; + const update=async(entry:WorkbenchPluginEntry,target:string,scopeText:string,reason='')=>{ + const key=`${entry.manifest.id}@${entry.manifest.version}`; + setBusy(key);setManageNotice('');setPageNotice(''); + try{ + const payload=buildWorkbenchStatePayload(entry,target,scopeText,reason); + await patch(`/api/bff/admin/workbenches/${encodeURIComponent(entry.manifest.id)}/versions/${encodeURIComponent(entry.manifest.version)}`,payload); + await refresh(true);setManageEntry(undefined);setRevokeReason(''); + }catch(value){setManageNotice(value instanceof Error?value.message:'工作台版本状态更新失败')} + finally{setBusy('')} + }; + const register=async()=>{ + setBusy('create');setCreateNotice(''); + try{ + const manifest=JSON.parse(manifestJSON) as WorkbenchPluginManifest; + await post('/api/bff/admin/workbenches',{manifest,template_aliases:splitWorkbenchList(templateAliases),tenant_ids:splitWorkbenchList(tenantIDs)}); + await refresh(true); + setCreateOpen(false); + setManifestJSON(defaultWorkbenchManifestJSON);setTemplateAliases('');setTenantIDs(''); + }catch(value){setCreateNotice(value instanceof Error?value.message:'工作台 manifest 不是有效 JSON 或登记失败')} + finally{setBusy('')} + }; + return
}/>
平台 Registry 已连接共 {workbenchRegistry.entries.length} 个声明版本。发布后客户工作台会按租户范围读取固定版本。
已发布{workbenchRegistry.entries.filter(item=>item.status==='published').length}草稿{workbenchRegistry.entries.filter(item=>item.status==='draft').length}已停用或撤销{workbenchRegistry.entries.filter(item=>['retired','revoked'].includes(item.status)).length}
{pageNotice&&
{pageNotice}
}
清单由服务端持久化 Registry 下发}/>
工作台版本状态业务类型租户范围管理
{workbenchRegistry.entries.map(entry=>{const key=entry.manifest.id+'@'+entry.manifest.version;return
{entry.manifest.name}{entry.manifest.id} · {entry.manifest.ui.layout} · {entry.manifest.ui.density} · {entry.manifest.ui.theme}v{entry.manifest.version}{entry.manifest.content_types.join('、')}{entry.tenant_ids.length?`${entry.tenant_ids.length} 个租户`:'全部租户'}
})}
{createOpen&&{setCreateOpen(false);setCreateNotice('')}} onCreate={register}/>} {manageEntry&&setManageEntry(undefined)} onUpdate={(target,reason)=>void update(manageEntry,target,manageScope,reason)}/>}
; +} + +const defaultWorkbenchManifestJSON=JSON.stringify({ + $schema:'https://contentcloud.run/schemas/workbench-plugin/1.0.0/workbench-plugin.schema.json', + id:'custom-content-workbench',version:'1.0.0',name:'自定义内容工作台',content_types:['article'], + experience:{template_id:'article_content'}, + ui:{renderer:'approved',layout:'article-editor',density:'comfortable',theme:'editorial-green',navigation:[ + {id:'overview',label:'工作台首页',icon:'pen-line'},{id:'tasks',label:'任务',icon:'list-checks'},{id:'deliveries',label:'交付',icon:'file-check'} + ],stages:[ + {id:'brief',label:'任务简报',outcome:'固定任务目标',primary_action:'save_brief'}, + {id:'draft',label:'内容草稿',outcome:'完成内容草稿',primary_action:'save_draft'}, + {id:'delivery',label:'交付',outcome:'生成可追溯交付包',primary_action:'create_delivery'} + ]} +},null,2); + +export function splitWorkbenchList(value:string):string[]{return Array.from(new Set(value.split(/[\n,,]+/).map(item=>item.trim()).filter(Boolean)))} + +export function buildWorkbenchStatePayload(entry:WorkbenchPluginEntry,target:string,scopeText:string,reason=''):{status:string;tenant_ids:string[];reason?:string}{ + const tenant_ids=splitWorkbenchList(scopeText); + if(entry.status==='revoked'&&target!=='revoked')throw new Error('已安全撤销的版本不能恢复或修改'); + if(target==='draft')throw new Error('已发布或已停用的版本不能退回草稿'); + if(target==='revoked'&&!reason.trim())throw new Error('永久安全撤销必须填写原因'); + return {status:target,tenant_ids,...(reason.trim()?{reason:reason.trim()}:{})}; +} + +export function workbenchLifecycleLabel(status:string):string{ + return status==='draft'?'发布':status==='published'?'正常退役':status==='retired'?'恢复发布':status==='revoked'?'已安全撤销':'查看'; +} + +export function WorkbenchStateModal({entry,scope,setScope,reason,setReason,busy,notice,onClose,onUpdate}:{entry:WorkbenchPluginEntry;scope:string;setScope:(value:string)=>void;reason:string;setReason:(value:string)=>void;busy:boolean;notice:string;onClose:()=>void;onUpdate:(target:string,reason?:string)=>void}){ + const lifecycleTarget=entry.status==='draft'?'published':entry.status==='published'?'retired':entry.status==='retired'?'published':''; + return

这里仅修改客户工作面的租户范围和生命周期。流程、审批、Runtime、产物、交付与效果数据仍由平台底层事实源拥有。