fix(ci): stabilize v1.86.0 release gates

This commit is contained in:
coso
2026-07-03 08:34:30 +08:00
parent 2d9a655b2e
commit 0faae75bb3
59 changed files with 4754 additions and 810 deletions
@@ -0,0 +1,127 @@
# Writing v2 Workflow 完成计划
更新时间:2026-07-03
状态:Active
路线图:`internal/roadmap/Writing/v2/`
## 目标
`@写文章` 从“一次性 worker 输出后集中投影”升级为段落级产物流式 + 后台 workflow JSONL 审计。最终完成态必须满足:
- 流程由内容工厂插件 manifest / activation request 声明,Lime 宿主不硬编码内容工厂业务步骤。
- 正文生成、标题、大纲、引用组织等内容工厂领域逻辑必须留在内容工厂 Agent App worker / 插件包内,不新增 App Server 宿主 `content_factory_*` 业务模块。
- workflow run / step / tool / connector / hook / evidence 只写入 append-only JSONL 审计日志,不进入普通用户 UI。
- `agentSession/read` 只恢复 artifact / workspace patch / Article Editor,不返回 UI-facing workflow run / step 列表。
- WebSearch / connector / hook / artifact delta 后续进入同一个 workflow audit stream。
- 最终文章仍进入 `ArtifactFrame(articleArtifacts)` 与右侧 Article Editor,不回退普通 assistant 长文。
## 当前事实源
| Surface | 当前状态 | 分类 | 证据 |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 插件 workflow declaration | 前端/安装测试仍主要读取 test-only manifest snapshot;但 seeded runtime package 已改为复制 `testing/fixtures/package-root/` 下同步自真实 `content-factory-app` 的包快照,且 `content-factory-app.json` 的 workflow/workbench/skill 绑定现已与 package-root 五个 `article-*` skill 对齐。package-root 也已同步 release 签名工具链守卫,要求 `plugin.json` / `package.json` 版本一致、`release:sign` 入口存在、`scripts/sign-release.mjs` 和签名测试不漂移。 | partial current / guarded test-only | `src/features/agent-app/testing/fixtures/content-factory-app.json``src/features/agent-app/testing/fixtures/package-root/``src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts` |
| 宿主 workflow audit event | App Server 在 worker 前生成 `workflow.run.started` / `workflow.step.started`,并把 worker progress 中的 `workflow.*` 分流写入 JSONL。 | current / P1 | `lime-rs/crates/app-server/src/runtime/agent_app_worker_workflow.rs``event_log.rs` |
| 硬编码业务步骤 | Rust 宿主不持有 `research / strategy / draft / review / image-plan` fallback;缺 workflow key / steps 时不伪造。 | current guard | `build_agent_app_worker_workflow_context(...) -> Option` |
| worker progress | seeded / runtime fixture 入口仍在 `testing/fixtures/`,但根 worker 已改为委托 `package-root` 当前实现;段落级 `artifact.snapshot` progress 现只在宿主注入真实 `hostManagedGeneration.outputs[]` 后输出 article partial,避免无模型结果时把 deterministic 样例伪装成流式文章。真实外部 `content-factory-app@2.2.2` package smoke 已证明 `.lapp` 包结构、runtime contract、host-managed generation 消费和 worker NDJSON 段落级 partial 输出成立;App Server current local_folder host-generation turn smoke 已证明真实外部包能通过 `agentSession/turn/start` 进入 artifact read model 与 workflow JSONLfixture-signed `cloud_release` host-generation current-turn smoke 进一步证明签名 evidence ready 后,非 local_folder runtime/cache 路径也能进入同一 worker turn。LimeCore 本地已补 `signatureProof` / `agentAppSignatureTrustRoots` 下发契约;远程 / GUI production 安装运行闭环仍待服务端部署、真实可信根配置和 GUI 复测。 | external package + App Server current local_folder/cloud_release host-generation fixture turn verified / LimeCore local contract fixed / remote GUI production pending | `src/features/agent-app/testing/fixtures/src/runtime/content-factory-worker.mjs``src/features/agent-app/testing/fixtures/package-root/src/runtime/content-factory-worker.mjs``scripts/agent-app/content-factory-package-smoke.mjs``scripts/agent-app/content-factory-current-turn-smoke.mjs``internal/roadmap/agentapp/remote-install-evidence-2026-07-03.md` |
| host-managed generation | 内容工厂 worker 不持有 provider keyApp Server 在启动 worker 前通过 direct provider config / Provider 池完成托管正文生成,并把结果注入 `hostManagedGeneration``runtime.hostManagedGenerationResult`。离线 localhost OpenAI-compatible fixture 已证明真实 `prepare_agent_app_worker_request(...)` 会生成并注入 `article-draft-document -> articleDraft.documentText`;worker 包测试现在证明有宿主结果时标题 / 正文来自宿主 Markdown,缺宿主结果时 `content.article.generate` 返回 `HOST_MANAGED_GENERATION_REQUIRED`,不再使用 deterministic fallback 模板。App Server current-turn host-generation smoke、fixture-signed cloud_release-host-generation smoke 和 Claw GUI `content-factory-article-workspace` smoke 均证明受控本地 OpenAI-compatible fixture 下得到 `hostManagedGenerationStatus=completed`。真实 live Provider production 证据仍未完成。 | current host seam + controlled local_folder/cloud_release/GUI fixture verified / live provider production pending | `lime-rs/crates/app-server/src/runtime_backend/agent_app_worker_generation.rs``content-factory-worker.test.mjs``scripts/agent-app/content-factory-current-turn-smoke.mjs``scripts/agent-runtime/claw-chat-current-fixture-content-factory-worker-dogfood.mjs` |
| artifact partial pass-through | App Server 只透传 worker 生成期 `artifact.snapshot` partial,不再用最终 `documentText` 回切伪流式。 | current guard | `worker_progress_events_for_sink(...)``complete_workspace_snapshot_passes_through_without_fake_streaming` |
| 前端过程显示 | 右侧 Article Editor 不渲染 workflow railrenderer event stream 对 `workflow.*` 返回 `null`。 | current / P1 | `WorkspaceArticleEditorSurface.tsx``appServerEventStream.ts` |
| GUI 回归 | 内容工厂文章工作台 fixture 改为断言 workflow UI facts / rail 缺席,且 worker dogfood turn 必须在 `backendMode=runtime` 下通过 host-managed generation 完成;`host_generation_unavailable` 不再是 GUI 成功证据。 | current evidence | `scripts/agent-runtime/claw-chat-current-fixture-content-factory-*.mjs``.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json``.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-content-factory-article-workspace-regression-summary.json` |
| workflow audit JSONL | 当前物理路径沿用 event log 根目录:`sessions/session_<id>/workflow-events.jsonl`。Worker 产生的 `workflow.connector.requested` / `workflow.tool.*` / `workflow.artifact.delta` 必须由 App Server 绑定插件声明里的 `workflowRunId + stepId` 后才能写入 auditRuntimeBackend 回填到 workspace patch 的 `hostSearchEvidence` 会追加为 audit-only `workflow.connector.completed`;插件 hook lifecycle 会转换为 audit-only `workflow.hook.completed`retry lifecycle 会追加 `workflow.run.retrying` / `workflow.step.retrying`。写盘前统一执行 metadata-only 脱敏,`prompt / query / result / providerConfig / message / text / summary` 等 raw content 字段被替换为 redaction 占位,保留 workflow id、step、connector、tool、status 等审计元数据。这些事件不进入普通输出 / read model / workerEvidenceApp Server current `evidence/export` 已能读取 JSONL 并在 `EvidencePackSummary.observabilitySummary.workflow_audit` 输出 metadata-only 摘要。 | current / P1 | `EventLogWriter::append_workflow_audit_events``RuntimeCore::export_evidence(...)``BasicEvidenceExportProvider``AgentAppWorkerWorkflowContext::bind_worker_progress_event``workflow_connector_completed_events_from_artifact_events(...)``workflow_hook_completed_events_from_worker_hook_events(...)``workflow_retry_events(...)` |
| read model workflow facts | `agentSession/read.detail.thread_read` 不再返回 `workflow_runs` / `workflow_steps`。 | current / P1 | `lime-rs/crates/app-server/src/runtime/read_model.rs` |
| step scheduler / retry / cancel / resume | worker 仍是整体 executor,尚未按 step 调度;retry / cancel 已有 audit-only 事件,resume 仍缺真实生命周期入口。 | partial / P2 | `agent_app_worker_workflow_retry.rs``agent_app_worker_workflow_cancel.rs` |
## 完成审计
### 已完成 P0
- [x] `@写文章` turn accepted 后,worker 执行前生成 workflow run / first step started audit event。
- [x] workflow key、title、step id/title/subagent/skill refs/expected output 来自插件声明。
- [x] 宿主不 fallback 写死内容工厂五步。
- [x] worker stdout NDJSON progress 可实时转为 `workflow.step.progress` 等白名单 audit event。
- [x] 前端不再把 `workflow.*` 当普通 runtime event 渲染。
- [x] GUI fixture 证明内容工厂文章工作台不显示流程轨,最终文章和编辑稿仍可恢复。
### 未完成 P1/P2
- [x] 真实内容工厂 Agent App package 通过 Lime current App Server local_folder 运行路径进入 artifact read model 与 workflow JSONL,不再只依赖 fixture worker 作为实现证据。
- [x] 真实内容工厂 Agent App package 通过 fixture-signed `cloud_release` installed state 进入 Lime current App Server 非 local_folder runtime/cache 路径;该证据不等同于生产 LimeCore 签名下发。
- [x] LimeCore 本地服务端契约支持 Agent App release `signatureProof` 与 bootstrap `agentAppSignatureTrustRoots` 下发;生产部署和真实 GUI 复测仍未完成。
- [x] 外部 `content-factory-app` 发布侧具备 `signatureProof` 生成工具,私钥仅来自环境变量或本地文件参数,不落库;Lime 内 package-root 快照已同步该工具和守卫。该证据只证明发布工具链 ready,不等同于真实 production signed release 已发布。
- [ ] 真实内容工厂 Agent App package 接入 Lime 远程 / GUI production 安装运行闭环,不绕过 signature proof / trust roots 门禁。
- [x] App Server host-managed generation 注入链有离线 provider fixture 回归,且 current-turn smoke 已证明受控 provider 下 `hostManagedGenerationStatus=completed`,不需要真实 Provider 凭证也能证明宿主会把生成结果注入外部真实 worker。
- [x] Claw GUI 内容工厂 Article Workspace fixture 已切到 `backendMode=runtime` + 本地 OpenAI-compatible fixture,断言 read model 中 `hostManagedGenerationStatus=completed` 且包含 `article-draft-document`,不再把 `host_generation_unavailable` 当成成功。
- [x] `content.article.generate` 缺少宿主 LLM 结果时 fail closed,不再输出“学习路线 / 基础语法到工程实战” deterministic 模板正文。
- [x] App Server 将 workflow run / step / progress / tool / connector / hook / artifact.delta 事件写入 JSONL audit log。
- [x] `workflow-events.jsonl` 写盘前执行 metadata-only 脱敏,不保存原始 prompt、connector query、provider payload 或正文内容。
- [x] `evidence/export``workflow-events.jsonl` 读取同一 session / turn 的审计事件,并在 Evidence Pack 中导出 metadata-only `workflow_audit` 摘要。
- [x] `agentSession/read` 不返回 workflow facts,普通历史恢复只依赖 workspace patch / artifact metadata。
- [x] Article Editor 不展示 workflow step / task card / 流程轨。
- [x] WebSearch / connector 工具调用以 `workflowRunId + stepId` 强绑定进入 workflow audit log。(已完成 worker `workflow.connector.requested` 与 RuntimeBackend `hostSearchEvidence -> workflow.connector.completed` 审计;外部内容工厂包自带 connector executor 仍不在本阶段。)
- [x] Hook lifecycle 复制 / 转换为 `workflow.hook.completed` audit-only 事件,继续不进入普通 UI / read model / workerEvidence。
- [x] 外部真实内容工厂 Agent App worker 已能以段落级 `artifact.snapshot` 输出 draft 正文增量。
- [x] 上述真实 package worker 增量通过 Lime current App Server local_folder 运行路径进入 artifact read model。
- [ ] 上述真实 package worker 增量通过 Lime 远程 / GUI production 安装运行路径进入 artifact read model。
- [x] retry 绑定 workflow run / step,并进入 JSONL audit log。
- [x] cancel 绑定当前未完成 workflow run / step,并进入 JSONL audit log。
- [ ] resume 绑定 workflow run / step;当前仍缺真实 worker resume lifecycle,不硬造事件。
## 下一刀
1. 发布 / 部署 LimeCore 签名契约变更,配置 production Agent App signature trust roots,使用 `content-factory-app``npm run release:sign` 生成真实 release `signatureProof`,重新发布 signed release,并复走 Lime 远程 / GUI production 安装运行闭环;不能在客户端绕过。
2. 补真实 live Provider / 远程 GUI production 条件下的 host-managed generation 证据;受控 provider current-turn smoke 已完成,但不能替代生产凭证 / GUI 链路。
3. 建模真实 resume audit;当前 worker 没有 resume lifecycle 入口,不硬造假事件,先登记 blocker 和退出条件。
4. 定义 `workflow-events.jsonl` retention 和压缩策略;metadata-only 脱敏与 Evidence Pack 摘要导出已完成。
## 验证门槛
最低验证:
- `cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker`
- `cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server workflow_audit_events_are_metadata_only_redacted -- --nocapture`
- `cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server export_evidence_summarizes_workflow_audit_jsonl_metadata_only -- --nocapture`
- `npm run smoke:content-factory-package -- --timeout-ms 180000`
- `npm run smoke:content-factory-current-turn -- --timeout-ms 180000`
- `npm run smoke:content-factory-current-turn:host-generation -- --timeout-ms 180000`
- `npm run smoke:content-factory-current-turn:cloud-release -- --timeout-ms 180000`
- `npm run smoke:content-factory-current-turn:cloud-release-host-generation -- --timeout-ms 180000`
- `npm test` / `npm run validate:app``src/features/agent-app/testing/fixtures/package-root`
- `npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts`
- `npm test -- src/features/agent-app/install/cloudReleaseSignature.test.ts`
- `npm run test:contracts`
- `npm test -- src/lib/api/agentRuntime/threadClient.test.ts src/lib/api/agentRuntime/appServerReadModelProjection.test.ts`
- `npm test -- src/components/agent/chat/workspace/WorkspaceArticleEditorRightSurface.test.tsx`
- `npm run smoke:claw-chat-current-fixture -- --scenario content-factory-article-workspace --timeout-ms 180000`
- `npm run smoke:agent-runtime-current-fixture`
## 进度日志
- 2026-07-03:收掉 Claw GUI 内容工厂 Article Workspace 的旧无宿主生成成功口径。`scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs``content-factory-article-workspace` 场景切到 `APP_SERVER_BACKEND_MODE=runtime``claw-chat-current-fixture-content-factory-worker-dogfood.mjs` 在 worker dogfood turn 上注入本地 OpenAI-compatible SSE fixture 的 `runtimeOptions.hostOptions.asterChatRequest`,触发 App Server current `prepare_agent_app_worker_request(...)`,并把 fixture provider 请求写入 summary 证据。`claw-chat-current-fixture-content-factory-assertions.mjs` 现在要求 `hostManagedGenerationStatus=completed``hostManagedGenerationOutputIds` 包含 `article-draft-document` 且 fixture provider 至少被请求一次;旧 `host_generation_unavailable` 不再是 GUI smoke 成功条件。证据:`.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-summary.json` 与聚合 `.lime/qc/gui-evidence/claw-chat-current-fixture/claw-chat-current-fixture-content-factory-article-workspace-regression-summary.json`,显示 `backendMode=runtime`、fixture `/v1/chat/completions` 请求 `5` 次、read model `hostManagedGenerationStatus=completed``hostManagedGenerationOutputIds=["article-draft-document"]``contentFactoryArticleWorkspaceWorkflowFactsHidden=true`。验证:`npm test -- scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs``npm run smoke:claw-chat-current-fixture -- --scenario content-factory-article-workspace --timeout-ms 240000``npm run smoke:agent-runtime-current-fixture``npx prettier --check "scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs" "scripts/agent-runtime/claw-chat-current-fixture-content-factory-worker-dogfood.mjs" "scripts/agent-runtime/claw-chat-current-fixture-content-factory-assertions.mjs" "scripts/agent-runtime/claw-chat-current-fixture-content-factory-article-workspace.mjs" "scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs" "internal/exec-plans/writing-v2-workflow-completion-plan.md"`
- 2026-07-03:继续收掉内容工厂旧学习路线模板的 metadata 回流。外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 与 Lime 内 `src/features/agent-app/testing/fixtures/package-root``src/runtime/article-planning.mjs` 已从“Golang / 学习路线 / 基础语法到工程实战 / 第一阶段”模板改为主题中性的文章结构规划;`articleDraft.documentText` 仍必须来自 host-managed generation,缺宿主正文继续 `HOST_MANAGED_GENERATION_REQUIRED` fail closed。同步更新 `contentFactoryWorkerContract.unit.test.ts` 与 App Server `agent_app_worker_turn` 测试:正向断言改为宿主生成正文,负向守卫禁止旧学习路线模板作为成功结果;Rust worker turn 使用测试 backend 注入 host generation,避免单测继续证明无宿主 fallback。根 `testing/fixtures/src/runtime/article-planning.mjs` 也机械同步到 package-root,并由 `contentFactoryFixtureSync.unit.test.ts` 守住,防止再次出现第二套模板。验证:外部包 `npm test``npm run validate:app`Lime package-root `npm test``npm run validate:app``npm test -- src/features/plugin-content-factory/contentFactoryWorkerContract.unit.test.ts``npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts``npm run test:rust:unit -- -p app-server article_generation_worker_emits_initial_streaming_workspace_snapshot``npm run test:rust:unit -- -p app-server article_workspace_turn_runs_installed_worker_and_materializes_workspace_patch``npm run smoke:content-factory-current-turn -- --timeout-ms 180000``npm run smoke:content-factory-current-turn:cloud-release -- --timeout-ms 180000``node scripts/agent-app/content-factory-current-turn-smoke.mjs --content-factory-dir "src/features/agent-app/testing/fixtures/package-root" --host-generation-fixture --timeout-ms 180000 --prefix content-factory-current-turn-smoke-package-root-host-generation`。最新 evidence`.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-host-generation-2026-07-02T23-52-57-467Z.json``.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-cloud-release-host-generation-2026-07-02T23-56-35-608Z.json``.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-package-root-host-generation-host-generation-2026-07-02T23-53-46-075Z.json`。外部包 inspect hash 更新为 `sha256:1145de4914af9d8ede5fb5e089cd6915d93a5e81ddc027258417297f00839a26`package-root inspect hash 更新为 `sha256:2b842c1401459db69e399002eeb2b7ead451e1fe3b8bab192c6a1ca7aa20d354`manifest hash 仍为 `sha256:6df5c193ac89ff4400006a45d8340029446529d9b5953c7c5d03d4349f9dbfc3`
- 2026-07-03:撤掉内容工厂文章生成的 deterministic 模板成功路径。外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 与 Lime 内 `src/features/agent-app/testing/fixtures/package-root``content-factory-worker.mjs` 已改为:`content.article.generate` 必须拿到 `hostManagedGeneration.outputs[]` 中的 `articleDraft.documentText` 才返回 completed artifact 和段落级 `artifact.snapshot`;宿主无 provider、生成失败或未注入结果时返回 `HOST_MANAGED_GENERATION_REQUIRED`,不再把 `article-planning.mjs` 的“学习路线:从基础语法到工程实战 / 第一阶段:打牢基础”样例当正文。示例 `runtime-request.sample.json` 改为携带宿主生成正文;`validate-app.mjs` 显式断言正文来自 host generation 且不包含旧模板;README / development 文档同步改口为 fail closed。`package.json``smoke:content-factory-current-turn``smoke:content-factory-current-turn:cloud-release` 默认都改为 `--host-generation-fixture`,避免继续用 no-host 成功口径。同步把 seeded descriptor / manifest snapshot 推进到 `2.2.2` 并补 `contentFactoryFixtureSync.unit.test.ts` 版本/hash 守卫。验证:外部包 `npm test``npm run validate:app``node --check src/runtime/content-factory-worker.mjs`Lime package-root `npm test``npm run validate:app``node --check src/features/agent-app/testing/fixtures/package-root/src/runtime/content-factory-worker.mjs``npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts``npm run smoke:content-factory-current-turn -- --timeout-ms 180000``npm run smoke:content-factory-current-turn:cloud-release -- --timeout-ms 180000`。最新证据:`.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-host-generation-2026-07-02T23-32-23-024Z.json``.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-cloud-release-host-generation-2026-07-02T23-35-56-358Z.json`
- 2026-07-03:补外部内容工厂发布侧签名工具链,并同步 Lime package-root 快照。`/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 新增 `scripts/sign-release.mjs``tests/sign-release.test.mjs``npm run release:sign`,按 Lime Host verifier canonical payload 生成 `signatureProof` / `app.signature.yaml`,支持 `RSASSA-PKCS1-v1_5-SHA256``RSA-PSS-SHA256``ECDSA-P256-SHA256``Ed25519`;私钥只从 `AGENT_APP_SIGNING_PRIVATE_KEY_PEM``--private-key-file` 读取,不写入仓库。Lime 内 `src/features/agent-app/testing/fixtures/package-root` 同步该签名工具、发布文档和校验脚本,`contentFactoryFixtureSync.unit.test.ts` 新增守卫,防止 `plugin.json` / `package.json` 版本、`release:sign` 入口、签名脚本和签名测试再次漂移;`cloudReleaseSignature.test.ts` 进一步直接加载 package-root `scripts/sign-release.mjs` 生成 proof,并用 Host `verifyCloudReleaseSignature(...)` 验证通过。验证:外部包 `npm test``npm run validate:app`Lime package-root `npm test``npm run validate:app``npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts``npm test -- src/features/agent-app/install/cloudReleaseSignature.test.ts``npx prettier --check "src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts" "src/features/agent-app/install/cloudReleaseSignature.test.ts"`。该证据只证明 signed release 生成工具与宿主验签规则一致,不等同于 production key/trust root 已配置或真实远程 GUI 安装完成。
- 2026-07-03:补 LimeCore 本地 Agent App 签名下发契约。`/Users/coso/Documents/dev/ai/limecloud/limecore` 新增 Agent App release `signatureProof`、bootstrap `agentAppSignatureTrustRoots``agentApp.signatureTrustRoots` / `AGENT_APP_SIGNATURE_TRUST_ROOTS_JSON` 配置入口,并同步 OpenAPI source fragments / bundle、`packages/types` 与 API 文档;`client/agent-apps``client/bootstrap.agentAppCatalog.apps[]` 会透传 proof`client/bootstrap` 顶层会下发可信根。验证:`go test ./services/control-plane-svc/configs ./services/control-plane-svc/internal/repo ./services/control-plane-svc/internal/controller -run "AgentApp|Signature|Snapshot|ClientBootstrap" -count=1``go test ./services/control-plane-svc/internal/service -run "AgentApp|ClientBootstrap" -count=1``make verify-contracts``make verify-client-contract-sync``make verify-version-sync`。该证据只证明 LimeCore 本地服务端契约可用,不等同于 production 部署 / 可信根配置 / Lime Desktop GUI 确认安装完成;`go test ./services/control-plane-svc/internal/service -count=1` 仍被既有 plugin marketplace 脏改动断言阻断,和本次签名契约无关。
- 2026-07-03:补 `workflow-events.jsonl` Evidence Pack 摘要导出。`RuntimeCore::export_evidence(...)` 读取同一 session / turn 的 workflow audit JSONL,并把事件传给 `BasicEvidenceExportProvider``observabilitySummary.workflow_audit` 只输出 event type breakdown、workflow run / key、turn、step、connector、tool、status、sourceEventIds 和 redaction 覆盖统计,不返回原始 workflow audit payload,也不改变普通 `agentSession/read` / Article Editor。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server export_evidence_summarizes_workflow_audit_jsonl_metadata_only -- --nocapture`
- 2026-07-03:补 `workflow-events.jsonl` metadata-only 脱敏。`EventLogWriter::append_workflow_audit_events(...)` 在写盘前统一克隆并脱敏 workflow audit payload`prompt / query / result / providerConfig / message / text / summary` 等 raw content 字段替换为 `{ redacted: true, policy: "workflow_audit_metadata_only" }`,含 `Bearer / api_key / token / secret / password` 的字符串也会替换为 redacted,占位同时注入顶层 `redaction` policy`workflowRunId / workflowKey / stepId / connectorRef / toolName / status / metadata.agentAppWorkflow` 等审计元数据保留。`scripts/agent-app/content-factory-current-turn-smoke.mjs` 同步断言 workflow JSONL 每条事件都有 metadata-only redaction policy,且不会泄露用户 prompt 或 host generation 正文。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server workflow_audit_events_are_metadata_only_redacted -- --nocapture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker -- --nocapture`
- 2026-07-03:补真实外部内容工厂 package 的 fixture-signed cloud_release current-turn smoke。后续 fail-closed 收口已经废弃“无 host generation 也 completed”的旧正向证据;当前 `smoke:content-factory-current-turn:cloud-release` 默认带 `--host-generation-fixture`,只接受 `hostManagedGenerationStatus=completed` 后输出段落级 partial。最新证据:`.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-cloud-release-host-generation-2026-07-02T23-35-56-358Z.json` 及同名 `workflow-events.jsonl`。该证据只证明签名 evidence ready 后的 App Server cloud_release runtime/cache/current-turn 路径,不替代生产 LimeCore 的 `signatureProof` / `agentAppSignatureTrustRoots` 下发和 GUI 确认安装。
- 2026-07-03:补真实外部内容工厂 package 的 App Server current-turn host generation smoke。`scripts/agent-app/content-factory-current-turn-smoke.mjs` 新增 `--host-generation-fixture` 模式,`package.json` 新增 `npm run smoke:content-factory-current-turn:host-generation`;该模式启动本地 OpenAI-compatible SSE fixture,显式用 `backendMode=runtime``runtimeOptions.hostOptions.asterChatRequest.provider_config` 触发 App Server host-managed generation,要求最终 artifact 与 `agentSession/read` 都得到 `hostManagedGenerationStatus=completed`,且正文来自宿主生成结果。证据显示外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app@2.2.2` package hash 为 `sha256:4be73a57bb5d29c5768b13a46c8ed8b07194fa7eda243d79feaf7d824941bdee`、manifest hash 为 `sha256:6df5c193ac89ff4400006a45d8340029446529d9b5953c7c5d03d4349f9dbfc3`,普通 event stream 产生 `19` 条事件、`6``artifact.snapshot``4` 个段落级 streaming partial`workflow-events.jsonl` 写入 `16` 条 audit-only workflow 事件,fixture provider 收到 `5` 次请求。证据:`.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-host-generation-2026-07-02T20-48-56-462Z.json`、同名 `.workflow-events.jsonl`。验证:`npm run smoke:content-factory-current-turn:host-generation -- --timeout-ms 180000`
- 2026-07-03:补真实外部内容工厂 package 的 App Server current local_folder turn smoke。后续 fail-closed 收口已经废弃 `backendMode=unavailable` 下仍生成正文的旧证据;当前 `npm run smoke:content-factory-current-turn` 默认带 `--host-generation-fixture`,使用本地 OpenAI-compatible fixture 证明外部真实包进入 App Server current turn、artifact read model 和 JSONL 审计链路,且正文来自宿主生成结果。最新证据:`.lime/qc/gui-evidence/agent-apps/content-factory-current-turn-smoke-host-generation-2026-07-02T23-32-23-024Z.json` 及同名 `workflow-events.jsonl`
- 2026-07-03:补真实外部内容工厂 package smoke。新增 `scripts/agent-app/content-factory-package-smoke.mjs``npm run smoke:content-factory-package`,默认读取 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`,校验 `content-factory-app@2.2.2``.lapp` 包结构、`plugin.json` / `app.runtime.yaml` 合同、host-managed generation 声明和 worker 入口;同时执行外部包 `npm test``npm run validate:app`,再直接运行 `src/runtime/content-factory-worker.mjs`,断言 `workflow.connector.requested` 为 audit-only、段落级 `artifact.snapshot` partial 递增、最终 `articleDraft.documentText` 消费 host-managed generation。证据:`.lime/qc/gui-evidence/agent-apps/content-factory-package-smoke-2026-07-02T20-22-46-553Z.json` 与同名 `.worker.jsonl`。验证:`npm run smoke:content-factory-package -- --timeout-ms 180000`
- 2026-07-03:补 P2 cancel lifecycle 审计。`agentSession/turn/cancel` 在普通事件流只返回 `turn.canceled`,同时读取既有 `workflow-events.jsonl`,仅对同一 turn 中未完成的 workflow run 追加 audit-only `workflow.step.canceled` / `workflow.run.canceled`;已 completed / failed / canceled 的 run 不重复写取消,已终态 step 不会被更早的 started 误判为 open step。事件仍只进入 `sessions/session_<id>/workflow-events.jsonl`,不进入普通 session JSONL、read model 或右侧 Article Editor。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server workflow_cancel -- --nocapture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server cancel_turn -- --nocapture`
- 2026-07-03:补 host-managed generation 注入链回归。`agent_app_worker_generation.rs` 新增 localhost OpenAI-compatible SSE fixture,走真实 `prepare_agent_app_worker_request(...)`、direct `hostOptions.asterChatRequest.provider_config` 和 Aster streaming 解析,断言 App Server 把 fixture 生成的 Markdown 写入 `hostManagedGeneration.outputs[0]``runtime.hostManagedGenerationResult`,输出目标绑定为 `article-draft-document -> articleDraft.documentText`。同步复跑 Lime 内 package-root 与外部真实包 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 的 worker tests / `validate:app`,证明 worker 会消费宿主结果覆盖 deterministic fallback。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker_generation -- --nocapture``npm test` / `npm run validate:app`Lime package-root)、`npm test` / `npm run validate:app`(外部真实包)。
- 2026-07-03:补 P1 connector completed 审计。App Server 在 RuntimeBackend 完成 workspace patch host search、并把真实 `WebSearch` 工具结果回填为 `hostSearchEvidence` 后,会从最终 artifact 里提取 evidence,去重后生成 audit-only `workflow.connector.completed`,再用插件 workflow manifest 绑定 `workflowRunId + stepId=research` 后追加到 `workflow-events.jsonl`;该事件不进入普通 runtime events、read model 或右侧 Article Workspace。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server builds_connector_completed_audit_events_from_host_search_evidence -- --nocapture`
- 2026-07-03:补 P1 hook lifecycle 审计。App Server 继续执行插件声明的 prompt/task hooks,但不再把 `agent_app_worker.hook` emit 到普通 runtime streamhook lifecycle 会转换为 audit-only `workflow.hook.completed`,按 `prompt -> first step``task -> last step` 绑定插件 workflow step 后追加到 `workflow-events.jsonl`。前端 event stream 对 `workflow.connector.*` / `workflow.hook.*` 显式返回 `null`,避免审计事件误进用户 timeline。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker -- --nocapture``npm test -- src/lib/api/agentRuntime/appServerEventStream.test.ts``npm test -- src/lib/api/agentRuntime/threadClient.test.ts`
- 2026-07-03:补 P1 retry lifecycle 审计。Worker retry 分支继续 emit 既有 `agent_app_worker.retry` 普通事件用于当前 UI / workerEvidence,但同时追加 `workflow.step.retrying``workflow.run.retrying``workflow-events.jsonl`retry 耗尽时最终 `workflow.run.failed` 保留最后一次 `retryAttempt`。前端 event stream 对 retrying workflow 事件显式返回 `null`。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker -- --nocapture``npm test -- src/lib/api/agentRuntime/appServerEventStream.test.ts`
- 2026-07-03:收掉聚合 current fixture 的 `plain-image-intent` 假事实源阻塞。`scripts/agent-runtime/claw-chat-current-fixture-image-command.mjs` 的自然语言图片场景不再把 `plain_image_intent` 当成独立 task `entry_source`,而是对齐 current 发送边界归一后的 `at_image_command``scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs` 同步补静态守卫,防止 smoke 再把场景名误当 payload truthRust 单测 `image_command_workflow_creates_task_from_current_intent_metadata` 也改为 current `at_image_command`,避免测试继续把 compat 值误写成主事实源。验证:`npm test -- "scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs"``npm run smoke:claw-chat-current-fixture -- --scenario plain-image-intent --prefix claw-chat-current-fixture-plain-image-intent-regression --timeout-ms 180000``npm run smoke:agent-runtime-current-fixture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server image_command_workflow_creates_task_from_current_intent_metadata -- --nocapture`
- 2026-07-03:补 P1 workflow audit 绑定守卫和 connector requested 审计。App Server 新增 `AgentAppWorkerWorkflowContext::bind_worker_progress_event(...)`worker progress 中的 `workflow.step.progress / workflow.tool.* / workflow.connector.* / workflow.hook.* / workflow.artifact.delta` 必须能绑定到插件 manifest 声明的 `workflowRunId + stepId`,缺 context、缺 step 或引用未知 step 都 fail closed;内容工厂 worker 在段落级 `artifact.snapshot` 前输出 `workflow.connector.requested`,描述 `research` step 的 `web-research / WebSearch` 请求,事件只进 `workflow-events.jsonl`,不进普通 UI。Lime 内 package-root 与外部真实包 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 已同步 worker/test/validate 和文档。验证:`npm test -- "tests/content-factory-worker.test.mjs"``npm run validate:app`Lime package-root)、`npm test -- "tests/content-factory-worker.test.mjs"``npm run validate:app`(外部真实包)、`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server binds_worker_tool_progress_to_declared_workflow_step -- --nocapture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server rejects_worker_tool_progress_without_declared_step -- --nocapture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server workflow_worker_progress_without_context_fails_closed -- --nocapture``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server article_generation_worker_emits_initial_streaming_workspace_snapshot -- --nocapture`
- 2026-07-03:同步内容工厂插件标准与真实包口径。`internal/tech/plugin/lime-plugin-package-v1.md` 明确 Agent Skills 目录严格匹配、渐进式 `references/ scripts/ assets/` 组织和 audit-only workflow UI 边界;App Server `plugin_manifest.rs` 不再兼容 `skills/article_writing` 这类下划线 legacy 目录,最小包脚手架同步改为 `skills/article-writing`;外部真实包 `content-factory-app``plugin.json``README.md``docs/development.md``workflows/content-article.workflow.md` 同步改口为“正文段落流式 + workflow JSONL 审计”,不再暗示右侧展示流程轨。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server inspect_local_package_reads_plugin_component_content``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server resolve_plugin_package_manifest_rejects_legacy_skill_directory_name``npm test``npm run validate:app`
- 2026-07-03:继续收掉 seeded 假包主链。`src/features/agent-app/testing/fixtures/seeded-agent-apps.json``packageRoot` 改为 `package-root/`,该目录同步自真实 `content-factory-app` 包并带上 `app.workbench.yaml``skills/``subagents/``clis/``connectors/``resources/` 等完整包结构;本地 `saving_seeded_content_factory_materializes_runtime_package_cache` 断言升级为检查 `app.workbench.yaml``skills/article-writing/SKILL.md` 一并落入 cache。与此同时,Lime 内仍被前端/安装测试直接读取的 `plugin.json``content-factory-app.json` 文案同步改成“正文段落流式 + workflow JSONL 审计”。验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server saving_seeded_content_factory_materializes_runtime_package_cache``npm run validate:app``npm test``src/features/agent-app/testing/fixtures/package-root`)。
- 2026-07-03:收口前端 / read-model 对旧内容工厂 skill id 的残留依赖。`src/features/agent-app/testing/fixtures/content-factory-app.json` 的 workflow steps、workbench task、subagent skill 绑定和顶层 `skillRefs` 全部切换到 `article-research / article-strategy / article-writing / article-editing / article-image-plan`;依赖旧 `gongzonghao-article-writer` / `article-image-cheatsheet` 的前端单测、Rust worker/read-model 测试和内联 runtime fixture 一并更新。新增 `src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts` 作为守卫,直接校验 `content-factory-app.json``package-root/app.runtime.yaml``app.workbench.yaml``skills/*/SKILL.md` 的关键 skill/runtime/workbench 字段一致,防止 test-only snapshot 再漂移。验证:`npm test -- src/features/agent-app/projection/projectApp.test.ts src/features/plugin/manifest/pluginContract.unit.test.ts src/components/agent/chat/workspace/workspacePluginActivation.unit.test.ts src/components/agent/chat/workspace/WorkspaceArticleEditorRightSurface.test.tsx src/components/agent/chat/workspace/workspaceArticleWorkspaceWorkflowFacts.unit.test.ts src/features/plugin/marketplace/marketplaceRegistryLoader.unit.test.ts src/features/plugin/marketplace/pluginMarketplaceViewModel.unit.test.ts src/components/skills/SkillsWorkspacePage.localActions.test.tsx src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server agent_app_worker_turn``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server worker_adapter_streams_ndjson_progress_before_final_response``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server read_session_projects_runtime_events_into_thread_read_artifacts``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server read_session_hides_workflow_facts_from_runtime_events`
- 2026-07-03:继续收口根 fixture runtime 双轨。`src/features/agent-app/testing/fixtures/src/runtime/content-factory-worker.mjs` 改为薄代理,只负责稳定 CLI 入口,真正的 `runContentFactoryTask / buildContentFactoryWorkerProgressEvents / handleContentFactoryWorkerRequest` 直接复用 `package-root` 当前 worker 实现;根 `app.runtime.yaml``examples/runtime-request.sample.json` 同步到 package-root current 合同;`ContentFactoryWorkerRuntimeContract` 额外投影 workflow context`buildContentFactoryWorkerRequest(...)` 生成的请求骨架现带 `workflowKey / subagents / skillRefs / cliRefs / connectorRefs / hookPolicy / orchestration`package-root worker progress patch 缩成 article-only partial,避免段落流式重复整份 workspace patch 导致 App Server `MAX_WORKER_STDOUT_BYTES` 超限。`contentFactoryFixtureSync.unit.test.ts` 新增根 runtime 入口委托守卫。验证:`npm test -- src/features/plugin-content-factory/contentFactoryDeliveryPlan.unit.test.ts src/features/plugin-content-factory/contentFactoryPlugin.unit.test.ts src/features/plugin-content-factory/contentFactoryWorkerContract.unit.test.ts src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts``npm test` / `npm run validate:app``src/features/agent-app/testing/fixtures/package-root`)、`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server worker_adapter_streams_ndjson_progress_before_final_response``cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server article_generation_worker_emits_initial_streaming_workspace_snapshot`
- 2026-07-03:历史 note:曾临时让 content article worker 在 non-runtime backend 下把 `hostManagedGeneration.status` 收敛为 `unavailable` 后继续 deterministic fallback。该口径已被 2026-07-03 后续 fail-closed 收口取代,不再作为 current 完成依据;当前要求是缺宿主正文直接 `HOST_MANAGED_GENERATION_REQUIRED`,不输出文章 artifact。
- 2026-07-02:完成 P0 可见性与插件声明边界。聚合 `smoke:agent-runtime-current-fixture` 通过。完成审计确认整体 v2 仍缺 P1 durable workflow read model 与 P2 step scheduler,目标不能标记完成。
- 2026-07-02:补 P1 第一刀:App Server `agentSession/read.detail.thread_read` 从持久化 `workflow.*` runtime events 派生 `workflow_runs` / `workflow_steps`,新增 Rust read model 单测 `read_session_projects_workflow_facts_from_runtime_events`
- 2026-07-02:补 P1 第二刀:新增前端 workflow facts parserArticle Editor 流程轨优先使用宿主 `workflow_runs` / `workflow_steps`,内容工厂 GUI fixture 增加 `contentFactoryArticleWorkspaceWorkflowFactsProjected` 断言并通过。
- 2026-07-02:按最新产品决策反向收口:workflow 不是右侧展示模型,改为只写 JSONL audit log。App Server 将 `workflow.*` 分流到 `sessions/session_<id>/workflow-events.jsonl``agentSession/read` 和 renderer event stream 不再暴露 workflow factsArticle Editor 与 GUI fixture 改为断言 workflow rail 缺席。
- 2026-07-02:纠正实现口径:`src/features/agent-app/fixtures` 不再作为 production currentfixture 移入 `src/features/agent-app/testing/fixtures`;真实内容工厂 Agent App worker 仍未实现,当前 smoke 只能作为 test-only evidence。
- 2026-07-02:纠正宿主 / 插件边界:撤回 host-side 内容工厂正文生成模块方向;App Server 只保留 worker artifact partial 通用透传和 JSONL 审计,不再通过最终 `documentText` 回切伪流式。真实正文生成必须进入内容工厂 Agent App worker / 插件包。
@@ -56,6 +56,7 @@ Lime Desktop current 策略对 `catalogSource=remote` 默认 `signaturePolicy=re
- 私钥只从 `AGENT_APP_SIGNING_PRIVATE_KEY_PEM``--private-key-file` 读取,不写入仓库。
- 可选输出 `agent-app-signature-trust-root.json`,用于后续配置 LimeCore `agentAppSignatureTrustRoots`
- Lime 内 `src/features/agent-app/testing/fixtures/package-root` 已同步该工具链,且 `contentFactoryFixtureSync.unit.test.ts` 覆盖 `plugin.json` / `package.json` 版本一致、`release:sign` 入口、签名脚本、签名测试和发布文档关键字段。
- Lime Host `cloudReleaseSignature.test.ts` 直接加载 package-root `scripts/sign-release.mjs` 生成 proof,并用 `verifyCloudReleaseSignature(...)` 验证通过,证明发布脚本输出与宿主验签 payload 规则一致。
该修复只证明发布工具链已具备生成 `signatureProof` 的能力;尚未使用 production key 签名、尚未把 trust root 配置到生产 LimeCore,也尚未发布新的 signed release。
@@ -72,5 +73,5 @@ Lime Desktop current 策略对 `catalogSource=remote` 默认 `signaturePolicy=re
- 定向回归:`npx vitest run "src/features/agent-app/install/cloudReleaseEvidence.test.ts" "src/features/agent-app/ui/AgentAppsPage.test.tsx" --silent=passed-only --disableConsoleIntercept` 通过,2 files / 37 tests。
- LimeCore 服务端契约验证:`go test ./services/control-plane-svc/configs ./services/control-plane-svc/internal/repo ./services/control-plane-svc/internal/controller -run "AgentApp|Signature|Snapshot|ClientBootstrap" -count=1``go test ./services/control-plane-svc/internal/service -run "AgentApp|ClientBootstrap" -count=1``make verify-contracts``make verify-client-contract-sync``make verify-version-sync` 均通过。
- LimeCore 已知非本轮阻塞:`go test ./services/control-plane-svc/internal/service -count=1` 仍被既有 plugin marketplace 脏改动的 authentication policy 断言阻断,和 Agent App 签名契约无关。
- 内容工厂发布工具验证:外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 已通过 `npm test``npm run validate:app`Lime package-root 快照已通过 `npm test``npm run validate:app``npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts`
- 内容工厂发布工具验证:外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 已通过 `npm test``npm run validate:app`Lime package-root 快照已通过 `npm test``npm run validate:app``npm test -- src/features/agent-app/testing/contentFactoryFixtureSync.unit.test.ts`Host 验签兼容性已通过 `npm test -- src/features/agent-app/install/cloudReleaseSignature.test.ts`
- 文档格式:`npx prettier --check "internal/roadmap/agentapp/remote-install-evidence-2026-07-03.md"` 通过。
+6 -5
View File
@@ -5,11 +5,11 @@
## 1. 定位
Lime 的一级产品概念统一为 **插件**。插件负责安装、授权、发布、启用与分发;`工作台应用` 只作为插件的一种能力形态,表示“带独立 UI 的工作台应用”
Lime 的一级产品概念统一为 **插件**。插件负责安装、授权、发布、启用与分发;`插件工作区能力` 只作为插件内部的一种能力形态,表示“带独立 UI 的工作台能力”。旧 `Agent App / 工作台应用` 命名只允许作为迁移输入或历史证据,不再作为插件中心的产品根对象
```text
插件 = 分发与授权根对象
工作台应用 = 插件子类型
插件工作区能力 = 插件内部 UI 子能力
Right Surface = Host 管理的产物渲染工作区
```
@@ -29,7 +29,7 @@ Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 A
| 文档 | 用途 |
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [`prd.md`](./prd.md) | 插件产品定义、背景、用户路径、需求、里程碑和验收。 |
| [`architecture.md`](./architecture.md) | 插件/工作台应用/Renderer/Claw/Right Surface 的分层架构。 |
| [`architecture.md`](./architecture.md) | 插件/工作区能力/Renderer/Claw/Right Surface 的分层架构。 |
| [`technical-baseline.md`](./technical-baseline.md) | 插件运行的宿主基线、承载方式、current / deprecated / dead 分类。 |
| [`interface-contracts.md`](./interface-contracts.md) | 插件 manifest、激活上下文、renderer contract、surface action contract。 |
| [`implementation-plan.md`](./implementation-plan.md) | 插件中心、composer 激活、renderer host、迁移收口的实施顺序。 |
@@ -63,13 +63,14 @@ Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 A
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 根对象 | 插件是用户侧可安装、可授权、可发布的根对象。 |
| Marketplace 服务端 | `current` 在 LimeCore control-plane,不在 Lime App Server。 |
| 工作台应用 | 降级为插件迁移输入 / 插件内独立 UI 能力;不再作为插件 marketplace 的设计模板。 |
| 插件工作区能力 | 为插件内独立 UI 能力;旧 Agent App / 工作台应用只允许作为迁移输入,不再作为插件 marketplace 的设计模板。 |
| 右侧渲染 | Right Surface 继续作为 Host 的唯一物理右栏,插件只提供数据模型、视图与 action。 |
| 激活方式 | 不再在每次发送消息时全量读取插件列表做语义猜测;激活必须显式。 |
| Renderer 输出合同 | manifest / marketplace summary 可以声明 `outputArtifactKind``paneKind` 和 action,但 runtime 当前只接管内容工厂 workspace patch,不开放任意插件执行。 |
| 内容工厂 | 内容工厂应作为插件重建,而不是复用旧 `旧内容工作台` 代码。 |
| 现有路线上下文 | 现有 `rightsurface` 负责统一右侧 dockplugin 路线只定义该 dock 如何承载插件产物。 |
| 旧插件中心命令 | 历史插件中心命令族继续按 `dead` 处理,不恢复为生产入口。 |
| 旧 Agent App 目录 | `client/agent-apps` 和本地 Agent App manifest 投影只允许作为 compat / migration 输入;插件中心 current 数据源是 `client/plugins/marketplace` 与本地插件安装态。 |
| `@` 命令边界 | 平台 `@` 原子命令仍以 `SkillCatalog.entries.kind=command` 为事实源;插件只可贡献显式 activation command entry,并通过 `agentSession/turn/start` metadata 进入 current 主链。 |
## 4. 与现有路线图关系
@@ -77,7 +78,7 @@ Lime Desktop 只负责本地安装态、显式激活、Right Surface 渲染和 A
| 路线图 | 关系 |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`internal/roadmap/rightsurface/README.md`](../rightsurface/README.md) | 定义唯一右侧 dock、多 tab 和 pane 状态机;plugin 路线复用这层宿主。 |
| [`internal/roadmap/workbench/v4/README.md`](../workbench/v4/README.md) | 定义 工作台应用 在插件体系里的位置,以及内容工厂的工作台形态。 |
| [`internal/roadmap/workbench/v4/README.md`](../workbench/v4/README.md) | 定义插件工作区能力在插件体系里的位置,以及内容工厂的工作台形态。 |
| [`internal/roadmap/workbench/v3/README.md`](../workbench/v3/README.md) | 作为历史参考,帮助理解从 Workbench Profile 过渡到插件分层的原因。 |
## 5. 开发者阅读顺序
+10 -10
View File
@@ -2,12 +2,12 @@
更新时间:2026-06-25
状态:Draft
适用范围:Lime Desktop / Claw / Plugin Center / Right Surface / 工作台应用 v4
适用范围:Lime Desktop / Claw / Plugin Center / Right Surface / 插件工作区能力
## 1. 设计目标
1. 插件作为分发与授权根对象。
2. `工作台应用` 作为插件内部可选 UI 能力。
2. `插件工作区能力` 作为插件内部可选 UI 能力。
3. Claw 继续负责对话、运行、审批和事实链。
4. Right Surface 作为 Host 管理的唯一右栏。
5. 产物渲染足够强,但不把业务逻辑塞进 UI 壳。
@@ -98,7 +98,7 @@ flowchart TD
flowchart TD
subgraph PluginContract[Plugin Contract]
Manifest[manifest]
Apps[agentApps]
WorkspaceApps[workspaceApps]
Skills[skills]
Connectors[connectors]
Renderers[artifactRenderers]
@@ -135,7 +135,7 @@ flowchart TD
end
Manifest --> Parse --> Normalize --> Guard
Guard --> Apps
Guard --> WorkspaceApps
Guard --> Skills
Guard --> Connectors
Guard --> Renderers
@@ -159,16 +159,16 @@ flowchart TD
| --- | --- | --- |
| Plugin Center | `current` | 插件的安装、启用、停用、授权和发布入口。 |
| LimeCore Plugin Marketplace | `current` | 云端只读目录、安装策略、认证策略和 package metadata;不执行插件。 |
| Plugin Manifest / Contract | `current` | 插件、工作台应用、skills、renderers 和 activation entries 的事实源。 |
| 工作台应用 UI | `current` | 作为插件内部 UI 能力,可以有独立页面或 workbench shell,但不是根产品。 |
| Plugin Manifest / Contract | `current` | 插件、工作区能力、skills、renderers 和 activation entries 的事实源。 |
| 插件工作区 UI | `current` | 作为插件内部 UI 能力,可以有独立页面或 workbench shell,但不是根产品。 |
| Right Surface Dock / Tabs | `current` | 右侧唯一物理 dock,承载插件产物和局部编辑。 |
| Host builtin renderer | `current` | 文章、图片、storyboard、checklist 等标准对象首选渲染方式。 |
| App declared pane | `current` | 复杂插件 UI 的受控挂载方式,只能作为右侧 pane。 |
| Semantic guessing activation | `deprecated` | 不能作为新激活机制,只允许历史兼容说明。 |
| 工作台应用 marketplace as design source | `deprecated` | 旧 工作台应用 目录可作为迁移输入,但插件模型按上游市场口径重建。 |
| Agent App / 工作台应用 marketplace as design source | `deprecated` | 旧目录可作为迁移输入;插件中心 current 数据源是 LimeCore `client/plugins/marketplace` 与本地插件安装态。 |
| 独立第二右栏 | `dead for new work` | 不允许把插件 UI 再做成一个完整右栏。 |
| 旧 旧内容工作台 代码 | `dead` | 只允许作为业务参考,不得复用代码、IPC、store 或 renderer。 |
| 旧 工作台应用 同级产品名 | `deprecated` | 用户侧仍可看到“工作台应用”,但它只隶属于插件体系。 |
| 旧 Agent App / 工作台应用同级产品名 | `deprecated` | 用户侧不再把它作为独立市场入口;历史文案只能解释为插件内部工作区能力。 |
## 6. 右侧 Renderer 怎么做强
@@ -288,7 +288,7 @@ Right Surface -X-> raw Tauri command
内容工厂是插件系统里的高价值复杂插件,它说明:
- 一个插件可以有 工作台应用 UI。
- 一个插件可以有插件工作区 UI。
- 同一个插件可以同时贡献多个产物 renderer。
- 右侧 tab 可以承载多个业务对象。
- 中间对话和右侧产物是同一 session 的两个视角。
@@ -307,5 +307,5 @@ Right Surface -X-> raw Tauri command
1. 插件负责“是什么”和“能做什么”。
2. Right Surface 负责“怎么摆”和“怎么恢复”。
3. 工作台应用 负责“一个插件是否需要自己的独立 UI”。
3. 插件工作区能力负责“一个插件是否需要自己的独立 UI”。
4. 内容工厂验证的是插件体系和产物工作区,不是再造一套 App Shell。
+12 -4
View File
@@ -36,7 +36,7 @@ flowchart TD
### 验收
- plugin 路线图完整,包含 PRD、架构、technical baseline、contract、实施计划、历史恢复。
- workbench/v4 路线图完整,清楚表达 工作台应用 是插件内独立 UI 能力。
- workbench/v4 路线图完整,清楚表达插件工作区能力是插件内独立 UI 能力。
-`rightsurface` 路线图边界清晰,不再写成两个右栏。
- 内容工厂被明确为插件 dogfood,不再和 `旧内容工作台` 代码绑定。
- 服务端 marketplace 边界落到 LimeCore,不在 Lime App Server 新增 marketplace JSON-RPC。
@@ -47,7 +47,7 @@ flowchart TD
- 以上游插件 / 市场模型为参照,建立插件 manifest 与 marketplace item 的 current contract。
- 从 LimeCore `client/plugins/marketplace` 获取 available plugin listing。
- 让插件、工作台应用、skills、connectors、renderers、activation entries 同时可投影。
- 让插件、插件工作区能力、skills、connectors、renderers、activation entries 同时可投影。
- 形成统一 registry,供插件中心和右侧面板消费。
### 验收
@@ -55,7 +55,7 @@ flowchart TD
- manifest normalizer 能输出统一 plugin contract。
- 缺少必要字段时 fail closed。
- registry 可区分可安装、可激活、可渲染和只读历史四种状态。
- 工作台应用 catalog 只作为迁移输入,不作为插件 marketplace 设计模板。
- 旧 Agent App / 工作台应用 catalog 只作为迁移输入,不作为插件 marketplace 设计模板。
## 5. P2:显式激活
@@ -105,7 +105,7 @@ flowchart TD
### 目标
- 把历史对话、插件上下文、主产物和 tab 状态统一到 session read model。
- 旧 工作台应用 中无法迁移的入口下架。
- Agent App / 工作台应用中无法迁移的入口下架。
- 旧 right surface 写死逻辑逐步替换为插件 contract。
### 验收
@@ -1006,6 +1006,14 @@ GUI 关注点:
- 该同步只提高 LimeCore live evidence 门禁强度,把“同一真实租户账号验收”变成 evidence 包硬字段;Lime Desktop 不新增协议、不新增 App Server marketplace JSON-RPC、不触发远端运行。
- 验证已覆盖 `node --test scripts/plugin/live-acceptance-evidence.test.mjs`
### 2026-07-03 插件中心去 Agent App catalog fallback 同步
- Lime Desktop 插件中心已新增“安装本地插件”入口,走 current 本地包选择、安装、持久化和刷新链路;离线或未登录时不再依赖旧云端 Agent App catalog 才能看到入口。
- `marketplaceRegistryLoader` 已移除 `getAgentAppCloudCatalog` 云端兜底;插件中心 current 数据源固定为 LimeCore `client/plugins/marketplace` 与本地 installed registry 投影。
- 旧 Agent App / 工作台应用 manifest 仍可作为迁移输入投影成插件 contract,但不再作为 marketplace 设计模板、云端目录兜底或独立产品入口。
- LimeCore 服务端需要同步把 `client/plugins/marketplace``client/plugins/{pluginName}/registration``client/plugins/{pluginName}/install-state` 标为插件 current`client/agent-apps` 与 Agent App 后台面只保留 compat / deprecated 口径,删除公开路由前必须另做兼容影响确认。
- 验证已覆盖插件中心相关 Vitest 与 ESLint 定向检查;全量 typecheck 本轮未完成,不作为通过证据。
## 15. 第二轮完成审计矩阵
| 目标 | 当前状态 | 证明证据 | 剩余缺口 |
+12 -6
View File
@@ -6,7 +6,7 @@
## 1. 架构不变量
1. 插件是安装、授权、发布的根对象。
2. `工作台应用` 是插件子类型,不是另一个并列根产品。
2. `插件工作区能力` 是插件内部的可选 UI 能力,不是另一个并列根产品。
3. 右侧 Renderer 由 Host 管壳子,插件只提供数据和动作。
4. 激活必须显式,不能语义猜测。
5. 历史恢复必须能恢复插件上下文和主产物。
@@ -23,7 +23,7 @@ export interface PluginManifest {
categories?: string[];
capabilities?: string[];
skills?: SkillDeclaration[];
agentApps?: AgentAppDeclaration[];
workspaceApps?: PluginWorkspaceAppDeclaration[];
connectors?: ConnectorDeclaration[];
mcpServers?: McpServerDeclaration[];
artifactRenderers?: ArtifactRendererDeclaration[];
@@ -44,10 +44,10 @@ export interface SkillDeclaration {
}
```
### 2.2 AgentAppDeclaration
### 2.2 PluginWorkspaceAppDeclaration
```ts
export interface AgentAppDeclaration {
export interface PluginWorkspaceAppDeclaration {
id: string;
title: string;
description?: string;
@@ -74,7 +74,7 @@ export interface ConnectorDeclaration {
export interface ActivationEntryDeclaration {
key: string;
title: string;
kind: "plugin" | "agentApp" | "skill" | "command";
kind: "plugin" | "workspaceApp" | "skill" | "command";
intent?: "manual" | "at_command" | "history_restore" | "chip";
defaultObjectKind?: string;
}
@@ -87,6 +87,7 @@ export interface ActivationEntryDeclaration {
- 插件不得把未声明的任意 `@模型名`、自然语言词或 marketplace 名称自动升级成命令入口。
- 插件 command entry 必须走显式 activation metadata,进入 `agentSession/turn/start` current 主链;不得绕过 App Server 或直接调用 provider / filesystem。
- 客户端可以把插件 command entry 合并到 `@` 选择器展示,但解析结果必须保留 provenance:`platform_command``plugin_activation_command` 不能混同。
-`kind="agentApp"` 只允许在迁移输入 adapter 中读取并投影为 `workspaceApp`current manifest 不再声明 `agentApp` entry。
## 3. Activation Context
@@ -94,7 +95,7 @@ export interface ActivationEntryDeclaration {
export interface PluginActivationContext {
sessionId: string;
pluginId: string;
activeAgentAppId?: string;
activeWorkspaceAppId?: string;
activeEntryKey?: string;
selectedSkillKeys?: string[];
selectedObjectRef?: PluginObjectRef;
@@ -104,6 +105,11 @@ export interface PluginActivationContext {
}
```
迁移兼容:
- 旧 manifest 中的 `agentApps?: AgentAppDeclaration[]` 只作为 migration / compat 输入,读取后必须投影为 `workspaceApps?: PluginWorkspaceAppDeclaration[]`
- 旧 session metadata 中的 `activeAgentAppId` 只作为恢复历史会话的只读输入;新写入必须使用 `activeWorkspaceAppId`
## 4. Plugin Object Ref
```ts
+16 -16
View File
@@ -2,11 +2,11 @@
更新时间:2026-06-25
状态:Draft
事实源:`internal/roadmap/rightsurface/README.md``internal/roadmap/workbench/v3/*``/Users/coso/Documents/dev/ai/limecloud/agentapp` v0.11 标准、上游插件模型参考
事实源:`internal/roadmap/rightsurface/README.md``internal/roadmap/workbench/v3/*``internal/tech/plugin/README.md`、上游插件模型参考;旧 Agent App 标准仅作为迁移输入参考
## 1. 一句话目标
让 Lime 进入“插件化工作台”阶段:插件负责安装、授权和分发,工作台应用 作为插件内的独立 UI 能力,Right Surface 作为唯一物理右栏承载产物 Profile 和可执行动作,Claw 继续负责中间对话、运行和审批。
让 Lime 进入“插件化工作台”阶段:插件负责安装、授权和分发,插件工作区能力作为插件内的独立 UI 能力,Right Surface 作为唯一物理右栏承载产物 Profile 和可执行动作,Claw 继续负责中间对话、运行和审批。
```text
用户不是在打开一个孤立 App
@@ -17,7 +17,7 @@
Lime 现在同时存在几个容易互相打架的概念:
1. 插件中心、工作台应用、内容工厂、右侧 Profile 都在表达“可安装且可交互的工作单元”。
1. 插件中心、插件工作区能力、内容工厂、右侧 Profile 都在表达“可安装且可交互的工作单元”。
2. 右侧工作区已经在 `rightsurface` 中被统一为单 dock、多 tab,但业务层仍存在按 App 直连 surface 的写法。
3. 普通会话如果按语义猜测插件,容易在发送前扫描全量列表,造成时延、误激活和状态污染。
4. 内容工厂这类业务要求产物可继续编辑、可恢复、可回流 runtime,不能只把结果塞成一段消息。
@@ -26,7 +26,7 @@ Lime 现在同时存在几个容易互相打架的概念:
| 问题 | 影响 |
| --- | --- |
| 插件与 工作台应用 同级 | 安装、授权、历史恢复和市场入口会重复设计。 |
| 插件与插件工作区能力同级 | 安装、授权、历史恢复和市场入口会重复设计。 |
| 右侧由业务自己包揽 | 容易回到“每个 App 一套壳子”,无法共享 tab / pane / restore。 |
| 语义猜测激活 | 普通会话会偷偷读取全量插件列表,行为不可预测。 |
| 产物只作为消息附件 | 历史任务和继续生成很难成立。 |
@@ -36,7 +36,7 @@ Lime 现在同时存在几个容易互相打架的概念:
### 目标
1. 插件成为 Lime 用户侧一级概念。
2. `工作台应用` 作为插件内独立 UI 能力保留。
2. `插件工作区能力` 作为插件内独立 UI 能力保留。
3. Right Surface 作为唯一右栏,支持插件贡献的产物 tab。
4. 历史会话恢复插件上下文、主产物和选中对象。
5. 内容工厂作为首个重型插件 dogfood,跑通文章、图片、视频脚本 / 分镜最小闭环。
@@ -47,7 +47,7 @@ Lime 现在同时存在几个容易互相打架的概念:
- 不复用 `旧内容工作台` 程序、IPC、store、renderer、Electron main service 或样式。
- 不把每个插件自建一套右侧壳子。
- 不让插件直接接管 Claw 中间聊天。
- 不把 工作台应用 重新做成另一个与插件并列的根产品。
- 不把插件工作区能力重新做成另一个与插件并列的根产品。
- 不在首期实现完整素材平台、多租户协作、计费、发布审核和外部平台网关。
## 4. 用户与场景
@@ -56,7 +56,7 @@ Lime 现在同时存在几个容易互相打架的概念:
| --- | --- | --- |
| 内容创作者 | 从需求生成文章、配图和脚本。 | 先显式激活内容工厂插件,再在 Claw 中间运行,右侧看产物。 |
| 审核者 | 追溯产物来源与风险。 | 在右侧 Profile 看到 object provenance、artifact 和 evidence。 |
| 插件作者 | 发布可安装工作单元。 | 插件 manifest 一次性声明 工作台应用、skills、renderers 和 actions。 |
| 插件作者 | 发布可安装工作单元。 | 插件 manifest 一次性声明工作区能力、skills、renderers 和 actions。 |
| 历史回看用户 | 继续编辑旧产物。 | 打开历史会话后直接恢复主对象和上次选中的 tab。 |
## 5. 核心用户路径
@@ -116,7 +116,7 @@ flowchart TD
| 编号 | 需求 | 验收 |
| --- | --- | --- |
| FR-01 | 插件可安装、启用、停用、卸载和打开。 | 插件中心详情页可完成上述动作。 |
| FR-02 | 插件能按能力分类展示。 | 列表可区分 工作台应用、Skill Pack、Connector、Renderer。 |
| FR-02 | 插件能按能力分类展示。 | 列表可区分工作区能力、Skill Pack、Connector、Renderer。 |
| FR-03 | 插件详情能展示可激活入口。 | 详情页可看到显式激活入口和历史入口。 |
### 6.2 显式激活
@@ -127,13 +127,13 @@ flowchart TD
| FR-05 | 支持 `@插件``@插件:技能`。 | 输入后可选择特定插件或技能。 |
| FR-06 | 历史恢复时保留激活上下文。 | 打开历史不会重新猜插件。 |
### 6.3 工作台应用
### 6.3 插件工作区能力
| 编号 | 需求 | 验收 |
| --- | --- | --- |
| FR-07 | 插件可包含独立 工作台应用 UI。 | 详情页和 surface 都可进入该 UI。 |
| FR-08 | 工作台应用 不单独承担安装/市场/授权。 | 同一安装包里完成分发和激活。 |
| FR-09 | 工作台应用 可携带 skills、connectors 和 renderer。 | manifest 可一次性声明。 |
| FR-07 | 插件可包含独立工作 UI。 | 详情页和 surface 都可进入该 UI。 |
| FR-08 | 插件工作区能力不单独承担安装/市场/授权。 | 同一安装包里完成分发和激活。 |
| FR-09 | 插件工作区能力可携带 skills、connectors 和 renderer。 | manifest 可一次性声明。 |
### 6.4 Right Surface / Artifact Renderer
@@ -170,7 +170,7 @@ flowchart TD
| P1 | 显式激活 | composer chip、`@插件`、session activation context。 |
| P2 | 右侧 renderer | Host builtin renderer、tab/pane registry、history restore。 |
| P3 | 内容工厂 dogfood | 内容工厂插件的文章、图片、视频脚本 / 分镜最小闭环。 |
| P4 | 迁移与收口 | 旧 工作台应用 / 旧 旧内容工作台 退出用户主路径。 |
| P4 | 迁移与收口 | 旧 Agent App / 旧内容工作台退出用户主路径。 |
## 9. 验收标准
@@ -184,7 +184,7 @@ flowchart TD
| 风险 | 应对 |
| --- | --- |
| 插件概念再次扩散 | 明确插件是根对象,工作台应用 只是子类型。 |
| 插件概念再次扩散 | 明确插件是根对象,插件工作区能力只是子能力。 |
| 右侧变成“万能页面系统” | 只接受 object surface / renderer contract,不复刻完整页面。 |
| 激活逻辑回到猜测 | 只保留显式入口,普通发送不做插件扫描。 |
| 历史恢复状态膨胀 | 只保存 workspace index、selection 和 layout,重内容留在 artifact / evidence。 |
@@ -193,7 +193,7 @@ flowchart TD
## 11. 落地口径
1. 插件中心可以复用现有 App Center 的安装壳,但用户侧命名必须收敛为“插件”。
2. 工作台应用 作为 manifest 的一段能力声明存在,不再拥有独立分发体。
2. 插件工作区能力作为 manifest 的一段能力声明存在,不再拥有独立分发体。
3. 当前插件上下文必须由 session metadata / workspace 驱动,而不是由全局缓存或语义推断重建。
4. 插件 action 统一先过 readiness gate,再进入 runtime。
5. 右侧 renderers 只负责对象,不负责用户全局导航。
@@ -201,6 +201,6 @@ flowchart TD
## 12. 设计结论
1. 插件是分发与授权根对象。
2. 工作台应用 是插件内的 UI 能力,不是另一个产品根。
2. 插件工作区能力是插件内的 UI 能力,不是另一个产品根。
3. Right Surface 是唯一物理右栏,plugin 只提供承载内容。
4. 内容工厂是最重要的 dogfood,它验证的是插件化工作台,不是页面壳。
+10 -10
View File
@@ -937,7 +937,7 @@
<p class="rail-label">当前路线</p>
<div class="rail-nav">
<div class="rail-item is-active"><span class="dot"></span>插件是根对象</div>
<div class="rail-item"><span class="dot"></span>工作台应用 是子类型</div>
<div class="rail-item"><span class="dot"></span>工作区能力是子能力</div>
<div class="rail-item"><span class="dot"></span>右侧是唯一 dock</div>
<div class="rail-item"><span class="dot"></span>激活必须显式</div>
<div class="rail-item"><span class="dot"></span>历史恢复同一工作现场</div>
@@ -987,11 +987,11 @@
</div>
<div class="search-row">
<div class="search-box"><strong>搜索插件</strong> / 内容工厂 / 工作台应用 / skills / renderers</div>
<div class="search-box"><strong>搜索插件</strong> / 内容工厂 / 工作区能力 / skills / renderers</div>
<div class="chip-row" aria-label="筛选">
<span class="chip active">已安装</span>
<span class="chip">已启用</span>
<span class="chip blue">工作台应用</span>
<span class="chip blue">工作区能力</span>
<span class="chip amber">Renderer</span>
<span class="chip">历史可恢复</span>
</div>
@@ -1002,7 +1002,7 @@
<div class="plugin-top">
<div>
<div class="plugin-name">内容工厂</div>
<div class="plugin-type">工作台应用 · Renderer · Skills</div>
<div class="plugin-type">工作区能力 · Renderer · Skills</div>
</div>
<span class="status verified">verified</span>
</div>
@@ -1056,7 +1056,7 @@
<div class="value">插件负责安装、授权、发布与启用</div>
</div>
<div class="info-item">
<div class="label">工作台应用</div>
<div class="label">工作区能力</div>
<div class="value">插件里的独立 UI 能力,不是并列根产品</div>
</div>
<div class="info-item">
@@ -1085,7 +1085,7 @@
<section class="band">
<div class="panel-title">
<h3>内容工厂 · Plugin</h3>
<span class="hint">类型:工作台应用 + Renderer + Skills</span>
<span class="hint">类型:工作区能力 + Renderer + Skills</span>
</div>
<div class="chip-row" style="margin-bottom:14px;">
<span class="chip active">verified</span>
@@ -1099,7 +1099,7 @@
<div class="tabbar" role="tablist" aria-label="插件详情 tabs">
<button class="tab-btn is-active" data-plugin-tab="overview" type="button">概览</button>
<button class="tab-btn" data-plugin-tab="app" type="button">工作台应用</button>
<button class="tab-btn" data-plugin-tab="app" type="button">工作区能力</button>
<button class="tab-btn" data-plugin-tab="skills" type="button">Skills</button>
<button class="tab-btn" data-plugin-tab="renderer" type="button">Renderer</button>
<button class="tab-btn" data-plugin-tab="history" type="button">History</button>
@@ -1111,7 +1111,7 @@
<div class="list">
<div class="list-item">
<div>
<div class="list-title">独立工作台应用 UI</div>
<div class="list-title">独立插件工作区 UI</div>
<div class="list-desc">可以是 page、pane 或 webcontents_view,但只能作为插件能力出现。</div>
</div>
<span class="status verified">current</span>
@@ -1306,7 +1306,7 @@
</div>
<div class="toolbar">
<span class="chip active">内容工厂 ▾</span>
<span class="chip blue">工作台应用</span>
<span class="chip blue">工作区能力</span>
<span class="chip">article-writer ▾</span>
<span class="chip">video-storyboard ▾</span>
<button class="action-btn soft" type="button">清除</button>
@@ -1322,7 +1322,7 @@
<div class="list-item">
<div>
<div class="list-title">@ 内容工厂</div>
<div class="list-desc">进入插件并打开默认 agent app</div>
<div class="list-desc">进入插件并打开默认工作区能力</div>
</div>
<span class="status verified">plugin</span>
</div>
+6 -6
View File
@@ -2,7 +2,7 @@
> 状态:proposal
> 更新时间:2026-06-25
> 目标:把插件中心、显式激活、Agent App 子能力、右侧 dock 和历史恢复画成低保真原型,避免路线图只停留在概念解释。
> 目标:把插件中心、显式激活、插件工作区能力、右侧 dock 和历史恢复画成低保真原型,避免路线图只停留在概念解释。
HTML 原型:[prototype.html](./prototype.html)
@@ -36,12 +36,12 @@ HTML 原型:[prototype.html](./prototype.html)
┌────────────────────────────────────────────────────────────────────────┐
│ Plugins │
├────────────────────────────────────────────────────────────────────────┤
│ Search plugins... [内容工厂] [Agent App] [Skills] [Connectors] [Renderer]│
│ Search plugins... [内容工厂] [工作区能力] [Skills] [Connectors] [Renderer]│
├────────────────────────────────────────────────────────────────────────┤
│ Installed │
│ ┌──────────────────────┐ ┌──────────────────────┐ ┌────────────────┐ │
│ │ 内容工厂 │ │ 研究助手 │ │ Chrome │ │
│ │ Agent App · verified │ │ Skill Pack · ready │ │ Connector │ │
│ │ 工作区能力 · verified │ │ Skill Pack · ready │ │ Connector │ │
│ │ 文章 / 图片 / 视频 │ │ 知识检索 / 复核 │ │ 已连接 │ │
│ │ [打开] [停用] │ │ [打开] [停用] │ │ [打开] [停用] │ │
│ └──────────────────────┘ └──────────────────────┘ └────────────────┘ │
@@ -62,12 +62,12 @@ HTML 原型:[prototype.html](./prototype.html)
│ 内容工厂 · Plugin │
├─────────────────────────────────────────────────────────────────────────────┤
│ 状态 verified │
│ 类型 Agent App + Renderer + Skills
│ 类型 工作区能力 + Renderer + Skills │
│ 说明 内容工厂是文章、图片、视频脚本和分镜的生产型插件 │
│ │
│ [激活到当前会话] [安装] [停用] [卸载] │
├─────────────────────────────────────────────────────────────────────────────┤
│ Tabs: [Overview] [Agent App] [Skills] [Renderer] [History] [Permissions] │
│ Tabs: [Overview] [Workspace] [Skills] [Renderer] [History] [Permissions] │
└─────────────────────────────────────────────────────────────────────────────┘
```
@@ -78,7 +78,7 @@ HTML 原型:[prototype.html](./prototype.html)
│ 概览 │
├──────────────────────────────────────────────────────────────┤
│ 这个插件提供: │
│ - 独立 Agent App UI │
│ - 独立插件工作区 UI │
│ - 文章 / 图片 / 视频脚本 / 分镜 renderer │
│ - content brief / checklist / export actions │
│ - 历史会话恢复 │
@@ -486,6 +486,14 @@ mod tests {
use serde_json::json;
use tempfile::TempDir;
const SEEDED_CONTENT_FACTORY_VERSION: &str = "2.2.2";
const SEEDED_CONTENT_FACTORY_SOURCE_URI: &str =
"https://seeded.local/agent-apps/content-factory-app/2.2.2.lapp";
const SEEDED_CONTENT_FACTORY_PACKAGE_HASH: &str =
"sha256:2b842c1401459db69e399002eeb2b7ead451e1fe3b8bab192c6a1ca7aa20d354";
const SEEDED_CONTENT_FACTORY_MANIFEST_HASH: &str =
"sha256:6df5c193ac89ff4400006a45d8340029446529d9b5953c7c5d03d4349f9dbfc3";
const NOW: &str = "2026-03-13T01:00:00Z";
#[test]
@@ -702,11 +710,11 @@ mod tests {
"appId": "content-factory-app",
"identity": {
"sourceKind": "cloud_release",
"sourceUri": "https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
"sourceUri": SEEDED_CONTENT_FACTORY_SOURCE_URI,
"appId": "content-factory-app",
"appVersion": "2.0.0",
"packageHash": "package-fnv1a-a4d0e86f",
"manifestHash": "manifest-fnv1a-b840e5c9"
"appVersion": SEEDED_CONTENT_FACTORY_VERSION,
"packageHash": SEEDED_CONTENT_FACTORY_PACKAGE_HASH,
"manifestHash": SEEDED_CONTENT_FACTORY_MANIFEST_HASH
},
"manifest": {
"runtimePackage": {
@@ -732,7 +740,11 @@ mod tests {
)
.expect("materialize seeded package");
let cache_dir = data_root.join("packages").join("package-fnv1a-a4d0e86f");
let cache_dir = data_root
.join("packages")
.join(agent_app_packages::safe_hash_path_segment(
SEEDED_CONTENT_FACTORY_PACKAGE_HASH,
));
assert!(cache_dir.join("package.json").is_file());
assert!(cache_dir.join("plugin.json").is_file());
assert!(cache_dir.join("app.workbench.yaml").is_file());
@@ -751,7 +763,7 @@ mod tests {
"identity": {
"sourceKind": "cloud_release",
"sourceUri": "https://packages.limecloud.example/content-factory-app.lapp",
"packageHash": "package-fnv1a-a4d0e86f"
"packageHash": SEEDED_CONTENT_FACTORY_PACKAGE_HASH
},
"manifest": {
"runtimePackage": {
@@ -814,14 +826,14 @@ mod tests {
"appId": "content-factory-app",
"identity": {
"sourceKind": "cloud_release",
"sourceUri": "https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
"sourceUri": SEEDED_CONTENT_FACTORY_SOURCE_URI,
"appId": "content-factory-app",
"appVersion": "2.0.0",
"packageHash": "package-fnv1a-a4d0e86f",
"manifestHash": "manifest-fnv1a-b840e5c9"
"appVersion": SEEDED_CONTENT_FACTORY_VERSION,
"packageHash": SEEDED_CONTENT_FACTORY_PACKAGE_HASH,
"manifestHash": SEEDED_CONTENT_FACTORY_MANIFEST_HASH
},
"manifest": {
"version": "2.0.0",
"version": SEEDED_CONTENT_FACTORY_VERSION,
"runtimePackage": {
"worker": {
"entrypoint": "./src/runtime/content-factory-worker.mjs"
@@ -5,6 +5,8 @@ use std::path::Path;
use std::process::Command;
use std::sync::Arc;
const HOST_GENERATED_ARTICLE_MARKDOWN: &str = "# 人才选聘不能只看简历关键词\n\n人才选聘最难的地方,不是筛掉明显不合适的人,而是识别真正能把问题推进的人。\n\n## 先定义岗位要解决的问题\n\n招聘前先写清楚这个岗位未来三个月要交付什么。\n\n## 用任务验证真实能力\n\n面试可以围绕一个小型业务任务展开,让候选人说明拆解思路、取舍依据和风险判断。";
#[tokio::test]
async fn article_workspace_turn_runs_installed_worker_and_materializes_workspace_patch() {
let Some(fixture_root) = content_factory_fixture_root() else {
@@ -101,11 +103,14 @@ async fn article_workspace_turn_runs_installed_worker_and_materializes_workspace
.expect("article object");
assert!(article["source"]["hostManagedGeneration"].is_null());
let article_title = article["title"].as_str().expect("article title");
assert!(article_title.contains("学习路线:从基础语法到工程实战"));
assert!(!article_title.contains("学习路线:从基础语法到工程实战"));
assert!(article_title.contains("先把问题说清楚"));
let article_document = article["source"]["documentText"]
.as_str()
.expect("article documentText");
assert!(article_document.contains("## 第一阶段:打牢基础"));
assert!(!article_document.contains("## 第一阶段:打牢基础"));
assert!(!article_document.contains("学习路线:从基础语法到工程实战"));
assert!(article_document.contains("## 把核心判断写成一句话"));
assert!(article_document.contains("## 结尾"));
assert_eq!(
article["source"]["finalMarkdown"],
@@ -139,7 +144,7 @@ async fn article_generation_worker_emits_initial_streaming_workspace_snapshot()
let event_log_root = tempfile::tempdir().expect("event log root");
let event_log_writer =
Arc::new(EventLogWriter::new(event_log_root.path()).expect("event log writer"));
let core = runtime_core_with_sidecar(&sidecar_root)
let core = runtime_core_with_sidecar_and_host_generation(&sidecar_root)
.with_event_log_writer(event_log_writer.clone())
.with_app_data_source(Arc::new(data_source));
let session = core
@@ -428,8 +433,10 @@ async fn article_generation_worker_emits_initial_streaming_workspace_snapshot()
let article_document = article["source"]["documentText"]
.as_str()
.expect("article documentText");
assert!(article_document.contains("## 第一阶段:打牢基础"));
assert!(article_document.contains("## 结尾"));
assert!(article_document.contains("## 先定义岗位要解决的问题"));
assert!(article_document.contains("## 用任务验证真实能力"));
assert!(!article_document.contains("## 第一阶段:打牢基础"));
assert!(!article_document.contains("学习路线:从基础语法到工程实战"));
assert_eq!(
article["source"]["finalMarkdown"],
article["source"]["documentText"]
@@ -980,6 +987,74 @@ fn runtime_core_with_sidecar(sidecar_root: &tempfile::TempDir) -> RuntimeCore {
))
}
fn runtime_core_with_sidecar_and_host_generation(sidecar_root: &tempfile::TempDir) -> RuntimeCore {
RuntimeCore::with_backend(Arc::new(HostManagedArticleGenerationBackend)).with_sidecar_store(
Arc::new(SidecarStore::new(sidecar_root.path()).expect("sidecar store")),
)
}
struct HostManagedArticleGenerationBackend;
#[async_trait]
impl ExecutionBackend for HostManagedArticleGenerationBackend {
async fn start_turn(
&self,
_request: ExecutionRequest,
_sink: &mut dyn RuntimeEventSink,
) -> Result<(), RuntimeCoreError> {
Ok(())
}
async fn cancel_turn(
&self,
_request: CancelExecutionRequest,
_sink: &mut dyn RuntimeEventSink,
) -> Result<(), RuntimeCoreError> {
Ok(())
}
async fn respond_action(
&self,
_request: ActionRespondRequest,
_sink: &mut dyn RuntimeEventSink,
) -> Result<(), RuntimeCoreError> {
Ok(())
}
async fn prepare_agent_app_worker_request(
&self,
_request: &ExecutionRequest,
worker_request: &mut serde_json::Value,
) -> Result<(), RuntimeCoreError> {
if worker_request["taskKind"] != "content.article.generate" {
return Ok(());
}
let payload = json!({
"schemaVersion": "lime.agent_app.host_managed_generation.v1",
"source": "test_host_generation",
"status": "completed",
"provider": "test-provider",
"model": "test-model",
"outputs": [
{
"id": "article-draft-document",
"kind": "markdown_document",
"targetObjectKind": "articleDraft",
"outputField": "documentText",
"contentType": "text/markdown",
"content": HOST_GENERATED_ARTICLE_MARKDOWN
}
]
});
worker_request["hostManagedGeneration"] = payload.clone();
if !worker_request["runtime"].is_object() {
worker_request["runtime"] = json!({});
}
worker_request["runtime"]["hostManagedGenerationResult"] = payload;
Ok(())
}
}
fn content_factory_installed_state(fixture_root: &std::path::Path) -> serde_json::Value {
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(fixture_root.join("content-factory-app.json"))
+3 -2
View File
@@ -242,10 +242,11 @@
"agent-app:connector-production-preflight": "node scripts/agent-app/connector-production-preflight.mjs",
"agent-app:connector-production-delivery-gate": "node scripts/agent-app/connector-production-delivery-gate.mjs",
"agent-app:connector-production-webhook-delivery": "node scripts/agent-app/connector-production-webhook-delivery.mjs",
"agent-app:content-factory-signed-release-gate": "node scripts/agent-app/content-factory-signed-release-gate.mjs",
"smoke:content-factory-package": "node scripts/agent-app/content-factory-package-smoke.mjs",
"smoke:content-factory-current-turn": "node scripts/agent-app/content-factory-current-turn-smoke.mjs",
"smoke:content-factory-current-turn": "node scripts/agent-app/content-factory-current-turn-smoke.mjs --host-generation-fixture",
"smoke:content-factory-current-turn:host-generation": "node scripts/agent-app/content-factory-current-turn-smoke.mjs --host-generation-fixture",
"smoke:content-factory-current-turn:cloud-release": "node scripts/agent-app/content-factory-current-turn-smoke.mjs --cloud-release-fixture",
"smoke:content-factory-current-turn:cloud-release": "node scripts/agent-app/content-factory-current-turn-smoke.mjs --cloud-release-fixture --host-generation-fixture",
"smoke:content-factory-current-turn:cloud-release-host-generation": "node scripts/agent-app/content-factory-current-turn-smoke.mjs --cloud-release-fixture --host-generation-fixture",
"smoke:agent-app-lab": "node scripts/agent-app/lab-smoke.mjs"
},
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,737 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import zlib from "node:zlib";
const APP_ID = "content-factory-app";
const WORKER_ENTRY = "src/runtime/content-factory-worker.mjs";
const DEFAULT_TIMEOUT_MS = 120_000;
function resolveDefaultContentFactoryDir() {
if (process.env.CONTENT_FACTORY_APP_DIR) {
return path.resolve(process.env.CONTENT_FACTORY_APP_DIR);
}
return path.resolve(
process.cwd(),
"..",
"..",
"limecloud",
"content-factory-app",
);
}
const DEFAULTS = {
contentFactoryDir: resolveDefaultContentFactoryDir(),
packageFile: "",
evidenceDir: path.join(
process.cwd(),
".lime",
"qc",
"gui-evidence",
"agent-apps",
),
prefix: "content-factory-package-smoke",
timeoutMs: DEFAULT_TIMEOUT_MS,
runPackageTests: true,
};
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === "--help") {
printHelp();
process.exit(0);
}
if (arg === "--content-factory-dir" && next) {
options.contentFactoryDir = path.resolve(next.trim());
index += 1;
continue;
}
if (arg === "--package-file" && next) {
options.packageFile = path.resolve(next.trim());
index += 1;
continue;
}
if (arg === "--evidence-dir" && next) {
options.evidenceDir = path.resolve(next.trim());
index += 1;
continue;
}
if (arg === "--prefix" && next) {
options.prefix = next.trim();
index += 1;
continue;
}
if (arg === "--timeout-ms" && next) {
options.timeoutMs = Number(next);
index += 1;
continue;
}
if (arg === "--skip-package-tests") {
options.runPackageTests = false;
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error("--timeout-ms must be a positive number");
}
return options;
}
function printHelp() {
console.log(`Usage:
node scripts/agent-app/content-factory-package-smoke.mjs [options]
Options:
--content-factory-dir <dir> content-factory-app directory, default ../../limecloud/content-factory-app
--package-file <file> .lapp package file, default dist-package/content-factory-app-<version>.lapp
--evidence-dir <dir> evidence output directory
--prefix <name> evidence filename prefix
--timeout-ms <ms> command timeout, default 120000
--skip-package-tests skip npm test and npm run validate:app in the external package
`);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function commandName(command) {
return process.platform === "win32" && command === "npm"
? "npm.cmd"
: command;
}
function tail(value, maxLength = 6_000) {
const text = String(value ?? "");
return text.length > maxLength ? text.slice(-maxLength) : text;
}
async function runCommand(command, args, options = {}) {
const startedAt = Date.now();
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return await new Promise((resolve, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...(options.env ?? {}) },
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.kill("SIGTERM");
reject(
new Error(
`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
reject(error);
});
child.on("close", (code, signal) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const result = {
command,
args,
cwd: options.cwd,
exitCode: code,
signal,
durationMs: Date.now() - startedAt,
stdout,
stderr,
};
if (code === 0) {
resolve(result);
return;
}
const error = new Error(
`${command} ${args.join(" ")} failed with exitCode=${code} signal=${signal ?? ""}`,
);
error.result = result;
reject(error);
});
if (options.input) {
child.stdin.end(options.input);
} else {
child.stdin.end();
}
});
}
async function readJson(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
async function resolvePackageFile(
contentFactoryDir,
packageJson,
explicitPackageFile,
) {
if (explicitPackageFile) {
assert(
fsSync.existsSync(explicitPackageFile),
`package file missing: ${explicitPackageFile}`,
);
return explicitPackageFile;
}
const preferred = path.join(
contentFactoryDir,
"dist-package",
`${APP_ID}-${packageJson.version}.lapp`,
);
if (fsSync.existsSync(preferred)) {
return preferred;
}
const distDir = path.join(contentFactoryDir, "dist-package");
const entries = await fs.readdir(distDir, { withFileTypes: true });
const packages = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".lapp"))
.map(async (entry) => {
const filePath = path.join(distDir, entry.name);
const stat = await fs.stat(filePath);
return { filePath, mtimeMs: stat.mtimeMs };
}),
);
packages.sort((left, right) => right.mtimeMs - left.mtimeMs);
assert(packages.length > 0, `no .lapp package found under ${distDir}`);
return packages[0].filePath;
}
function findEndOfCentralDirectory(buffer) {
const minOffset = Math.max(0, buffer.length - 65_557);
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
if (buffer.readUInt32LE(offset) === 0x06054b50) {
return offset;
}
}
throw new Error("zip end of central directory not found");
}
function readZipEntries(buffer) {
const eocdOffset = findEndOfCentralDirectory(buffer);
const entryCount = buffer.readUInt16LE(eocdOffset + 10);
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
const entries = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
assert(
buffer.readUInt32LE(offset) === 0x02014b50,
"invalid zip central directory",
);
const compressionMethod = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const fileNameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer
.subarray(nameStart, nameStart + fileNameLength)
.toString("utf8")
.replace(/\\/g, "/");
entries.push({
name,
compressionMethod,
compressedSize,
uncompressedSize,
localHeaderOffset,
});
offset = nameStart + fileNameLength + extraLength + commentLength;
}
return entries;
}
function findZipEntry(entries, expectedName) {
return (
entries.find((entry) => entry.name === expectedName) ??
entries.find((entry) => entry.name.endsWith(`/${expectedName}`)) ??
null
);
}
function extractZipEntry(buffer, entry) {
const offset = entry.localHeaderOffset;
assert(
buffer.readUInt32LE(offset) === 0x04034b50,
`invalid local header for ${entry.name}`,
);
const fileNameLength = buffer.readUInt16LE(offset + 26);
const extraLength = buffer.readUInt16LE(offset + 28);
const dataStart = offset + 30 + fileNameLength + extraLength;
const compressed = buffer.subarray(
dataStart,
dataStart + entry.compressedSize,
);
if (entry.compressionMethod === 0) {
return compressed;
}
if (entry.compressionMethod === 8) {
return zlib.inflateRawSync(compressed);
}
throw new Error(
`unsupported zip compression method ${entry.compressionMethod} for ${entry.name}`,
);
}
function extractZipText(buffer, entries, expectedName) {
const entry = findZipEntry(entries, expectedName);
assert(entry, `.lapp missing ${expectedName}`);
return extractZipEntry(buffer, entry).toString("utf8");
}
function sha256(buffer) {
return `sha256:${crypto.createHash("sha256").update(buffer).digest("hex")}`;
}
function assertRuntimeContract(runtimeText) {
const requiredTokens = [
"entrypoint: ./src/runtime/content-factory-worker.mjs",
"rightSurface: articleWorkspace",
"key: content_article_workflow",
"hostManagedGeneration:",
"enabled: true",
"article-draft-document",
"outputField: documentText",
"connectorRefs:",
"hookPolicy:",
];
for (const token of requiredTokens) {
assert(
runtimeText.includes(token),
`app.runtime.yaml missing token: ${token}`,
);
}
}
function buildWorkerRequest(sampleRequest) {
const hostMarkdown = [
"# 宿主生成标题",
"",
"导语先说明为什么内容生产链路必须收敛到同一个可审计产物。",
"",
"## 第一节",
"",
"这里是宿主托管生成的第一段正文,用于证明 worker 消费真实 hostManagedGeneration 输出。",
"",
"## 第二节",
"",
"这里是第二段正文,段落级 partial 应该先于最终 response 输出。",
"",
"## 第三节",
"",
"这里是第三段正文,最终文章仍然回到 articleDraft.documentText。",
].join("\n");
const hostManagedGeneration = {
schemaVersion: "lime.agent_app.host_managed_generation.v1",
source: "content_factory_package_smoke",
status: "completed",
provider: "package-smoke-provider",
model: "package-smoke-model",
outputs: [
{
id: "article-draft-document",
kind: "markdown_document",
targetObjectKind: "articleDraft",
outputField: "documentText",
contentType: "text/markdown",
content: hostMarkdown,
},
],
};
return {
...sampleRequest,
sessionId: "session-content-factory-package-smoke",
turnId: "turn-content-factory-package-smoke",
taskId: "task-content-factory-package-smoke",
requestedAt: new Date().toISOString(),
hostManagedGeneration,
runtime: {
...(sampleRequest.runtime ?? {}),
hostManagedGeneration: {
enabled: true,
requests: [
{
id: "article-draft-document",
kind: "markdown_document",
targetObjectKind: "articleDraft",
outputField: "documentText",
},
],
},
hostManagedGenerationResult: hostManagedGeneration,
},
};
}
function parseWorkerNdjson(stdout) {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
function articleFromArtifact(artifact) {
const patch = artifact?.metadata?.contentFactoryWorkspacePatch;
const article = patch?.objects?.find(
(object) => object?.ref?.kind === "articleDraft",
);
assert(article, "artifact snapshot missing articleDraft object");
return article;
}
function assertWorkerOutput(events) {
assert(
events.length >= 3,
"worker output should contain progress events and final response",
);
const response = events.at(-1);
assert(
response.schemaVersion === "content-factory.worker-response.v1",
"final response schema mismatch",
);
assert(response.appId === APP_ID, "final response appId mismatch");
assert(response.status === "completed", "final response must be completed");
const workflowConnectorEvents = events.filter(
(event) =>
event.kind === "runtime.event" &&
event.eventType === "workflow.connector.requested",
);
assert(
workflowConnectorEvents.length >= 1,
"worker must emit workflow.connector.requested audit event",
);
assert(
workflowConnectorEvents.every((event) => event.payload?.auditOnly === true),
"workflow connector progress must be audit-only",
);
const artifactEvents = events.filter(
(event) =>
event.kind === "runtime.event" && event.eventType === "artifact.snapshot",
);
assert(
artifactEvents.length >= 2,
"worker must emit paragraph-level artifact.snapshot progress",
);
const partialLengths = artifactEvents.map((event, index) => {
const artifact = event.payload?.artifact;
assert(
artifact?.metadata?.complete === false,
"partial snapshot must not be complete",
);
assert(
artifact.metadata.writePhase === "streaming",
"partial snapshot writePhase mismatch",
);
assert(
artifact.metadata.streamSource === "worker_delta",
"partial snapshot streamSource mismatch",
);
assert(
artifact.metadata.streamSequence === index + 1,
"partial snapshot sequence mismatch",
);
const article = articleFromArtifact(artifact);
assert(
article.status === "generating",
"partial article status must be generating",
);
return String(article.source?.documentText ?? "").length;
});
for (let index = 1; index < partialLengths.length; index += 1) {
assert(
partialLengths[index] > partialLengths[index - 1],
"partial document length must increase",
);
}
const finalArtifact = response.artifacts?.[0];
assert(
finalArtifact?.metadata?.complete === true,
"final artifact must be complete",
);
const finalArticle = articleFromArtifact(finalArtifact);
const finalText = String(finalArticle.source?.documentText ?? "");
assert(
finalText.includes("# 宿主生成标题"),
"final article must use host-managed generation text",
);
assert(
finalText.includes("第三段正文"),
"final article must include full host-generated body",
);
assert(
finalArticle.source?.hostManagedGeneration?.status === "completed",
"final article must preserve hostManagedGeneration metadata",
);
assert(
partialLengths.at(-1) < finalText.length,
"last partial must remain smaller than final document text",
);
return {
workflowConnectorEventCount: workflowConnectorEvents.length,
paragraphPartialCount: artifactEvents.length,
firstPartialLength: partialLengths[0],
lastPartialLength: partialLengths.at(-1),
finalDocumentLength: finalText.length,
hostManagedGenerationStatus:
finalArticle.source.hostManagedGeneration.status,
};
}
async function runPackageSmoke(options) {
const contentFactoryDir = options.contentFactoryDir;
assert(
fsSync.existsSync(contentFactoryDir),
`content factory dir missing: ${contentFactoryDir}`,
);
const packageJson = await readJson(
path.join(contentFactoryDir, "package.json"),
);
const pluginJson = await readJson(
path.join(contentFactoryDir, "plugin.json"),
);
assert(packageJson.name === APP_ID, "package.json name mismatch");
assert(pluginJson.id === APP_ID, "plugin.json id mismatch");
assert(
pluginJson.version === packageJson.version,
"plugin.json version must match package.json",
);
const runtimeText = await fs.readFile(
path.join(contentFactoryDir, "app.runtime.yaml"),
"utf8",
);
assertRuntimeContract(runtimeText);
const packageFile = await resolvePackageFile(
contentFactoryDir,
packageJson,
options.packageFile,
);
const packageBuffer = await fs.readFile(packageFile);
const packageHash = sha256(packageBuffer);
const zipEntries = readZipEntries(packageBuffer);
const requiredPackageEntries = [
"plugin.json",
"app.runtime.yaml",
"app.workbench.yaml",
"src/runtime/content-factory-worker.mjs",
"src/runtime/article-planning.mjs",
"examples/runtime-request.sample.json",
"workflows/content-article.workflow.md",
"skills/article-writing/SKILL.md",
"connectors/connectors.json",
"hooks/prompt-submit.mjs",
];
const missingPackageEntries = requiredPackageEntries.filter(
(entry) => !findZipEntry(zipEntries, entry),
);
assert(
missingPackageEntries.length === 0,
`.lapp missing required entries: ${missingPackageEntries.join(", ")}`,
);
const zipPluginJson = JSON.parse(
extractZipText(packageBuffer, zipEntries, "plugin.json"),
);
const zipRuntimeText = extractZipText(
packageBuffer,
zipEntries,
"app.runtime.yaml",
);
assert(zipPluginJson.id === APP_ID, ".lapp plugin.json id mismatch");
assert(
zipPluginJson.version === packageJson.version,
".lapp plugin.json version mismatch",
);
assertRuntimeContract(zipRuntimeText);
const commandResults = [];
if (options.runPackageTests) {
commandResults.push(
await runCommand("npm", ["test"], {
cwd: contentFactoryDir,
timeoutMs: options.timeoutMs,
}),
);
commandResults.push(
await runCommand("npm", ["run", "validate:app"], {
cwd: contentFactoryDir,
timeoutMs: options.timeoutMs,
}),
);
}
const sampleRequest = await readJson(
path.join(contentFactoryDir, "examples", "runtime-request.sample.json"),
);
const workerRequest = buildWorkerRequest(sampleRequest);
const workerResult = await runCommand("node", [WORKER_ENTRY], {
cwd: contentFactoryDir,
input: `${JSON.stringify(workerRequest)}\n`,
timeoutMs: options.timeoutMs,
});
const workerEvents = parseWorkerNdjson(workerResult.stdout);
const workerSummary = assertWorkerOutput(workerEvents);
await fs.mkdir(options.evidenceDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const workerJsonlPath = path.join(
options.evidenceDir,
`${options.prefix}-${stamp}.worker.jsonl`,
);
const evidencePath = path.join(
options.evidenceDir,
`${options.prefix}-${stamp}.json`,
);
await fs.writeFile(workerJsonlPath, workerResult.stdout, "utf8");
const evidence = {
schemaVersion: "content-factory-package-smoke.v1",
status: "passed",
generatedAt: new Date().toISOString(),
platform: {
os: os.platform(),
arch: os.arch(),
node: process.version,
},
contentFactoryDir,
packageFile,
packageHash,
appId: APP_ID,
version: packageJson.version,
packageEntryCount: zipEntries.length,
requiredPackageEntries,
packageManifest: {
id: zipPluginJson.id,
version: zipPluginJson.version,
runtimeContribution: zipPluginJson.contributions?.runtime,
workbenchContribution: zipPluginJson.contributions?.workbench,
},
commandResults: commandResults.map((result) => ({
command: [result.command, ...result.args].join(" "),
durationMs: result.durationMs,
stdoutTail: tail(result.stdout),
stderrTail: tail(result.stderr),
})),
worker: {
command: `node ${WORKER_ENTRY}`,
durationMs: workerResult.durationMs,
outputJsonl: workerJsonlPath,
eventCount: workerEvents.length,
...workerSummary,
},
};
await fs.writeFile(
evidencePath,
`${JSON.stringify(evidence, null, 2)}\n`,
"utf8",
);
return { evidence, evidencePath, workerJsonlPath };
}
async function writeFailureEvidence(options, error) {
if (!options) {
return "";
}
await fs.mkdir(options.evidenceDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const failurePath = path.join(
options.evidenceDir,
`${options.prefix}-${stamp}.failure.json`,
);
const result = error?.result;
const evidence = {
schemaVersion: "content-factory-package-smoke.v1",
status: "failed",
generatedAt: new Date().toISOString(),
contentFactoryDir: options.contentFactoryDir,
packageFile: options.packageFile || "",
error: {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : "",
},
commandResult: result
? {
command: [result.command, ...result.args].join(" "),
durationMs: result.durationMs,
exitCode: result.exitCode,
signal: result.signal,
stdoutTail: tail(result.stdout),
stderrTail: tail(result.stderr),
}
: null,
};
await fs.writeFile(
failurePath,
`${JSON.stringify(evidence, null, 2)}\n`,
"utf8",
);
return failurePath;
}
let options;
try {
options = parseArgs(process.argv.slice(2));
const result = await runPackageSmoke(options);
console.log(
`[content-factory-package-smoke] status=passed version=${result.evidence.version} partials=${result.evidence.worker.paragraphPartialCount}`,
);
console.log(
`[content-factory-package-smoke] evidence=${result.evidencePath}`,
);
console.log(
`[content-factory-package-smoke] workerJsonl=${result.workerJsonlPath}`,
);
} catch (error) {
const failurePath = await writeFailureEvidence(options, error).catch(
() => "",
);
if (failurePath) {
console.error(
`[content-factory-package-smoke] failureEvidence=${failurePath}`,
);
}
console.error(
`[content-factory-package-smoke] failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
process.exit(1);
}
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import {
buildContentFactorySignedReleaseGate,
readOptionalJsonFile,
writeJsonFile,
} from "../lib/agent-app-content-factory-signed-release-gate-core.mjs";
function parseArgs(argv) {
const options = {
appId: "content-factory-app",
bootstrap: "",
catalog: "",
check: false,
contentFactoryDir: process.env.CONTENT_FACTORY_APP_DIR || "",
expectedVersion: "",
fetchCloud: "",
guiEvidence: "",
output: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
if (arg === "--app-id" && next) {
options.appId = next.trim();
index += 1;
} else if (arg === "--catalog" && next) {
options.catalog = next;
index += 1;
} else if (arg === "--bootstrap" && next) {
options.bootstrap = next;
index += 1;
} else if (arg === "--fetch-cloud" && next) {
options.fetchCloud = next;
index += 1;
} else if (arg === "--gui-evidence" && next) {
options.guiEvidence = next;
index += 1;
} else if (arg === "--expected-version" && next) {
options.expectedVersion = next.trim();
index += 1;
} else if (arg === "--content-factory-dir" && next) {
options.contentFactoryDir = next;
index += 1;
} else if (arg === "--output" && next) {
options.output = next;
index += 1;
} else if (arg === "--check") {
options.check = true;
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printHelp() {
console.log(`Usage:
node scripts/agent-app/content-factory-signed-release-gate.mjs [options]
Options:
--catalog <path> Production client/agent-apps or catalog JSON.
--bootstrap <path> Production bootstrap JSON with signature trust roots.
--fetch-cloud <path> agentAppPackage/fetchCloud/package verification evidence JSON.
--gui-evidence <path> Real Lime Desktop GUI install/run evidence JSON.
--expected-version <version> Expected content-factory-app version.
--content-factory-dir <dir> Optional package dir used only to read package.json version.
--output <path> Write gate JSON.
--check Exit non-zero unless production evidence is ready.
Ready requires signed cloud_release catalog metadata, matching bootstrap trust root,
verified fetchCloud hashes/signature, GUI Article Workspace evidence, workflow-events.jsonl,
and live Provider hostManagedGeneration. Fixture cloud releases, localhost provider
fixtures, signature_missing/not_configured, and host_generation_unavailable stay blocked.`);
}
function readPackageVersion(packageDir) {
const candidates = [
packageDir ? path.join(packageDir, "package.json") : "",
path.join(
process.cwd(),
"src/features/agent-app/testing/fixtures/package-root/package.json",
),
].filter(Boolean);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
if (typeof pkg.version === "string" && pkg.version.trim()) {
return pkg.version.trim();
}
}
return "";
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const result = buildContentFactorySignedReleaseGate({
appId: options.appId,
bootstrap: readOptionalJsonFile(options.bootstrap),
catalog: readOptionalJsonFile(options.catalog),
expectedVersion:
options.expectedVersion || readPackageVersion(options.contentFactoryDir),
fetchCloud: readOptionalJsonFile(options.fetchCloud),
guiEvidence: readOptionalJsonFile(options.guiEvidence),
});
if (options.output) {
writeJsonFile(options.output, result);
}
console.log(
`[content-factory-signed-release-gate] status=${result.status} appId=${result.appId} expectedVersion=${result.expectedVersion || "unknown"} missing=${result.missingRequirements.length}`,
);
if (result.missingRequirements.length > 0) {
console.log(
`[content-factory-signed-release-gate] missingCodes=${result.missingRequirements
.map((item) => item.code)
.join(",")}`,
);
}
if (options.output) {
console.log(`[content-factory-signed-release-gate] output=${options.output}`);
}
if (options.check && result.status !== "ready") {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(
`[content-factory-signed-release-gate] failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
process.exitCode = 1;
});
@@ -74,7 +74,7 @@ import { writeJsonFile } from "./claw-chat-current-fixture-utils.mjs";
export const LOCAL_IMAGE_SERVER_API_KEY = "pc_claw_image_fixture_local_key";
export const IMAGE_PROVIDER_FIXTURE_API_KEY = "sk-claw-image-fixture";
export const IMAGE_PROVIDER_FIXTURE_DATA_URL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAbCAMAAAANt/xAAAAAsVBMVEW398yz9M6v8tCr79Kn7dSj6taf6Nif59eE0bhWqYEuh1Efej+b5dqH1MEuh1KX4tx3x7IxhU2Bt4zV6s33/+iT4N6B0MVsqXuP3eCP3d8thlKL2+J1x8CH2ORMo4aD1uYrhVN/0+d70OkqhFR3zutFnolzy+1iuslvye9vyO8phFRrxvFfutVnw/NUsMSCt4xjwfVYttgng1VfvvdSsNE7mI8nglVbvPlXuftTt/1PtP9WydZkAAAA4UlEQVQ4y5XSx27DQAwE0EmcuI2binvv3XFP+f8PSwIb1EqgDnxnDhZLDmD1YoRXI2SM8GaEdyNkY3L5QpHFQj6XTYOSq1zhQ6VcSoGqo0Z6fhAGvkfWqjrUIw2yGT40yUZdhZZod2T+P9FptzToih69MOKx19WgLwb0nYDPQV+DoRgxcAIBR0MNxoIMXeRYg4mYJl6YTjSYiXniD/OZBguxTGxpudBgJdab2B0265UG28gudundVoW94/Ds0sdflw57HY6u0/nZ1vPpmAKXuOvtzvvtekmFTyN8GeHbCD9Gv4DxhnNTaC+HAAAAAElFTkSuQmCC";
function writeFixtureConfig(configPath, overrides = {}) {
const serverHost = overrides.serverHost ?? "127.0.0.1";
@@ -213,6 +213,8 @@ export async function runContentFactoryArticleWorkspaceScenario({
contentFactoryArticleWorkspaceRuntimeEventsAppend:
summarizeRuntimeEventsAppend(runtimeEventsAppend.result),
contentFactoryArticleWorkspaceWorkerTurnStart: workerTurnStart,
contentFactoryArticleWorkspaceWorkerHostGenerationFixture:
workerTurnStart.hostGenerationFixture,
contentFactoryArticleWorkspaceRemoteRuntimeRejection:
remoteRuntimeRejection,
contentFactoryArticleWorkspaceActionResultRuntimeEventsAppend:
@@ -1926,25 +1928,25 @@ function summarizeContentFactoryArticleWorkspaceReadModel(result) {
workerArticleSource.finalMarkdown,
workerArticleSource.final_markdown,
).length > 300,
markdownIncludesEditedDraftMarker: workerArticleSourceText.includes(
CONTENT_FACTORY_ARTICLE_WORKSPACE_EDITED_DRAFT_MARKER,
),
sourceEdited: readBoolean(workerArticleSource.edited),
hostManagedGenerationStatus: readString(
workerArticleHostManagedGeneration.status,
),
hostManagedGenerationReasonCode: readString(
workerArticleHostManagedGeneration.reasonCode,
workerArticleHostManagedGeneration.reason_code,
),
hostManagedGenerationOutputIds: readStringArray(
workerArticleHostManagedGeneration.outputIds,
workerArticleHostManagedGeneration.output_ids,
),
updatedAt: readString(
workerArticleSource.updatedAt,
workerArticleSource.updated_at,
),
markdownIncludesEditedDraftMarker: workerArticleSourceText.includes(
CONTENT_FACTORY_ARTICLE_WORKSPACE_EDITED_DRAFT_MARKER,
),
sourceEdited: readBoolean(workerArticleSource.edited),
hostManagedGenerationStatus: readString(
workerArticleHostManagedGeneration.status,
),
hostManagedGenerationReasonCode: readString(
workerArticleHostManagedGeneration.reasonCode,
workerArticleHostManagedGeneration.reason_code,
),
hostManagedGenerationOutputIds: readStringArray(
workerArticleHostManagedGeneration.outputIds,
workerArticleHostManagedGeneration.output_ids,
),
updatedAt: readString(
workerArticleSource.updatedAt,
workerArticleSource.updated_at,
),
researchRoundCount: workerArticleResearchRoundCount,
imageSlotCount: workerArticleImageSlotCount,
}
@@ -249,11 +249,13 @@ export function buildContentFactoryArticleWorkspaceScenarioAssertions({
readModel.workerArticleObject?.markdownIncludesResearch === true &&
readModel.workerArticleObject?.markdownIncludesDraft === true &&
readModel.workerArticleObject?.hostManagedGenerationStatus ===
"unavailable" &&
readModel.workerArticleObject?.hostManagedGenerationReasonCode ===
"host_generation_unavailable" &&
readModel.workerArticleObject?.hostManagedGenerationOutputIds?.length ===
0 &&
"completed" &&
!readModel.workerArticleObject?.hostManagedGenerationReasonCode &&
readModel.workerArticleObject?.hostManagedGenerationOutputIds?.includes(
"article-draft-document",
) === true &&
summary.contentFactoryArticleWorkspaceWorkerTurnStart
?.hostGenerationFixture?.requestCount >= 1 &&
readModel.workerArticleObject?.researchRoundCount >= 3 &&
readModel.workerArticleObject?.imageSlotCount >= 3,
contentFactoryArticleWorkspaceActionResultPatchProjected:
@@ -1,5 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import {
contentFactoryHostGenerationAsterChatRequest,
startContentFactoryHostGenerationFixture,
} from "../lib/content-factory-host-generation-fixture.mjs";
import {
APP_SERVER_METHOD_AGENT_APP_INSTALLED_SAVE,
APP_SERVER_METHOD_SESSION_READ,
@@ -49,47 +53,62 @@ export async function runWorkspacePatchWorkerDogfoodTurn({
workspace,
requestLog,
}) {
const response = await invokeAppServerFromPage(
page,
APP_SERVER_METHOD_SESSION_TURN_START,
{
sessionId: CONTENT_FACTORY_ARTICLE_WORKSPACE_SESSION_ID,
turnId: CONTENT_FACTORY_ARTICLE_WORKSPACE_WORKER_TURN_ID,
input: {
text: "通过已安装内容工厂 worker 写一篇完整公众号文章。",
const hostGenerationFixture = await startContentFactoryHostGenerationFixture();
try {
const response = await invokeAppServerFromPage(
page,
APP_SERVER_METHOD_SESSION_TURN_START,
{
sessionId: CONTENT_FACTORY_ARTICLE_WORKSPACE_SESSION_ID,
turnId: CONTENT_FACTORY_ARTICLE_WORKSPACE_WORKER_TURN_ID,
input: {
text: "通过已安装内容工厂 worker 写一篇完整公众号文章。",
},
runtimeOptions: {
metadata: buildArticleWorkspaceWorkerMetadata(workspace),
hostOptions: {
asterChatRequest: contentFactoryHostGenerationAsterChatRequest(
hostGenerationFixture.baseUrl,
),
},
},
queueIfBusy: false,
skipPreSubmitResume: true,
},
runtimeOptions: {
metadata: buildArticleWorkspaceWorkerMetadata(workspace),
},
queueIfBusy: false,
skipPreSubmitResume: true,
},
requestLog,
);
requestLog,
);
const eventTypes = response.messages
.filter((message) => message?.method === "agentSession/event")
.map((message) => message?.params?.event?.type)
.filter(Boolean);
const turn =
response.result?.turn && typeof response.result.turn === "object"
? response.result.turn
: {};
const readModel = options
? await waitForWorkspacePatchWorkerTurnCompleted(page, options, requestLog)
: null;
const eventTypes = response.messages
.filter((message) => message?.method === "agentSession/event")
.map((message) => message?.params?.event?.type)
.filter(Boolean);
const turn =
response.result?.turn && typeof response.result.turn === "object"
? response.result.turn
: {};
const readModel = options
? await waitForWorkspacePatchWorkerTurnCompleted(
page,
options,
requestLog,
)
: null;
return sanitizeJson({
method: APP_SERVER_METHOD_SESSION_TURN_START,
turnId: turn.turnId ?? turn.turn_id ?? null,
turnStatus: turn.status ?? null,
taskId: CONTENT_FACTORY_ARTICLE_WORKSPACE_WORKER_TASK_ID,
eventTypes,
completed: eventTypes.includes("turn.completed"),
artifactSnapshotEmitted: eventTypes.includes("artifact.snapshot"),
runtimeErrorEmitted: eventTypes.includes("runtime.error"),
readModel,
});
return sanitizeJson({
method: APP_SERVER_METHOD_SESSION_TURN_START,
turnId: turn.turnId ?? turn.turn_id ?? null,
turnStatus: turn.status ?? null,
taskId: CONTENT_FACTORY_ARTICLE_WORKSPACE_WORKER_TASK_ID,
eventTypes,
completed: eventTypes.includes("turn.completed"),
artifactSnapshotEmitted: eventTypes.includes("artifact.snapshot"),
runtimeErrorEmitted: eventTypes.includes("runtime.error"),
hostGenerationFixture: hostGenerationFixture.summary(),
readModel,
});
} finally {
await hostGenerationFixture.close();
}
}
async function waitForWorkspacePatchWorkerTurnCompleted(
@@ -586,6 +586,27 @@ export async function waitForGuiImageCommandTerminal(
const hasPreviewImage = cards.some((card) =>
Boolean(card.querySelector("img")),
);
const imageMetrics = cards.flatMap((card) =>
Array.from(card.querySelectorAll("img")).map((image) => {
const rect = image.getBoundingClientRect();
return {
complete: image.complete,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
width: rect.width,
height: rect.height,
dataImage: image.currentSrc.startsWith("data:image/"),
};
}),
);
const hasLoadedVisiblePreviewImage = imageMetrics.some(
(image) =>
image.complete === true &&
image.naturalWidth > 1 &&
image.naturalHeight > 1 &&
image.width > 16 &&
image.height > 16,
);
const hasNaturalCompletionCaption =
cardText.includes("搞定") && cardText.includes("已经做好了");
const visiblePendingStatus =
@@ -611,6 +632,8 @@ export async function waitForGuiImageCommandTerminal(
cardCount: cards.length,
mediaCount: mediaNodes.length,
hasPreviewImage,
hasLoadedVisiblePreviewImage,
imageMetrics,
cardText,
taskIdVisible: text.includes(taskId),
terminalMessageVisible:
@@ -642,6 +665,7 @@ export async function waitForGuiImageCommandTerminal(
snapshot.cardCount === 1 &&
snapshot.mediaCount >= 1 &&
snapshot.hasPreviewImage === true &&
snapshot.hasLoadedVisiblePreviewImage === true &&
hasTerminalResultCaption &&
snapshot.visiblePendingStatus === false &&
snapshot.templateTaskIdVisible === false &&
@@ -405,6 +405,8 @@ export function buildScenarioAssertions(context) {
summary.guiImageCommandTerminal?.cardCount === 1 &&
summary.guiImageCommandTerminal?.mediaCount >= 1 &&
summary.guiImageCommandTerminal?.hasPreviewImage === true &&
summary.guiImageCommandTerminal
?.hasLoadedVisiblePreviewImage === true &&
summary.guiImageCommandTerminal?.visiblePendingStatus ===
false,
guiImageCommandSingleTaskCard:
@@ -418,6 +420,8 @@ export function buildScenarioAssertions(context) {
summary.guiImageCommandRestoredAfterReload?.mediaCount >= 1 &&
summary.guiImageCommandRestoredAfterReload
?.hasPreviewImage === true &&
summary.guiImageCommandRestoredAfterReload
?.hasLoadedVisiblePreviewImage === true &&
summary.guiImageCommandRestoredAfterReload
?.visiblePendingStatus === false,
guiImageCommandNoDraftCard:
@@ -195,6 +195,14 @@ function resolveScenarioBackendEnv(options, runtimeEnv) {
APP_SERVER_BACKEND_TIMEOUT_MS: "10000",
};
}
if (options.scenario === CONTENT_FACTORY_ARTICLE_WORKSPACE_SCENARIO) {
return {
APP_SERVER_BACKEND_MODE: "runtime",
APP_SERVER_BACKEND_COMMAND: "",
APP_SERVER_BACKEND_ARGS: "",
APP_SERVER_BACKEND_TIMEOUT_MS: "10000",
};
}
return {
APP_SERVER_BACKEND_MODE: "external",
@@ -338,6 +346,7 @@ async function run() {
contentFactoryArticleWorkspaceInstalledStateSave: null,
contentFactoryArticleWorkspaceRuntimeEventsAppend: null,
contentFactoryArticleWorkspaceWorkerTurnStart: null,
contentFactoryArticleWorkspaceWorkerHostGenerationFixture: null,
contentFactoryArticleWorkspaceRightSurfaceRequest: null,
guiContentFactoryArticleWorkspaceSessionVisible: null,
guiContentFactoryArticleWorkspaceSessionOpened: null,
@@ -188,6 +188,8 @@ describe("claw chat current Electron fixture smoke guard", () => {
expect(content).toContain("localImageServerApiKey");
expect(content).toContain("/v1/images/generations");
expect(content).toContain("IMAGE_PROVIDER_FIXTURE_DATA_URL");
expect(content).toContain("ADAAAAAbCAMAAAANt/x");
expect(content).not.toContain("AAAAEAAAABCAQAAAC1HAw");
});
it("proves agentSession/event notifications align with the same turn read model", () => {
@@ -419,10 +421,14 @@ describe("claw chat current Electron fixture smoke guard", () => {
expect(content).toContain("image_command_intent");
expect(content).toContain("imageCommandLegacySkillLaunchNotSubmitted");
expect(content).toContain("image_task");
expect(imageCommandContent).not.toContain('entrySource: "plain_image_intent"');
expect(imageCommandContent).not.toContain(
'entrySource: "plain_image_intent"',
);
expect(imageCommandContent).toContain('entrySource: "at_image_command"');
expect(imageCommandContent).not.toContain("IMAGE_COMMAND_SKILL_NAME");
expect(imageCommandContent).not.toContain("IMAGE_COMMAND_SKILL_TOOL_CALL_ID");
expect(imageCommandContent).not.toContain(
"IMAGE_COMMAND_SKILL_TOOL_CALL_ID",
);
expect(imageCommandContent).toContain("image_command_workflow");
expect(content).toContain("lime_create_image_generation_task");
expect(content).toContain("IMAGE_COMMAND_CREATE_TASK_TOOL_CALL_ID");
@@ -472,14 +478,15 @@ describe("claw chat current Electron fixture smoke guard", () => {
expect(content).toContain("guiImageCommandTaskCardTerminal");
expect(content).toContain("guiImageCommandSingleTaskCard");
expect(content).toContain("guiImageCommandRestoredAfterReload");
expect(content).toContain("hasLoadedVisiblePreviewImage");
expect(content).toContain("guiImageCommandNoDraftCard");
expect(content).toContain("guiImageCommandNoTemplateTaskId");
expect(imageCommandContent).toContain("suppresses submission-summary chat");
expect(imageCommandContent).toContain(
"suppresses submission-summary chat",
"snapshot.hasVisibleImageTaskProcess",
);
expect(imageCommandContent).toContain("snapshot.hasVisibleImageTaskProcess");
expect(imageCommandContent).not.toContain(
'(snapshot.hasAssistantSummary || snapshot.hasDoneText) &&',
"(snapshot.hasAssistantSummary || snapshot.hasDoneText) &&",
);
expect(imageCommandContent).not.toContain(
"snapshot.hasPresentationCaption === true",
@@ -892,10 +899,29 @@ describe("claw chat current Electron fixture smoke guard", () => {
expect(content).toContain("image_regenerate_job_1");
expect(content).toContain("worker_dogfood");
expect(content).toContain("contentFactoryArticleWorkspaceWorkerTurnStart");
expect(content).toContain(
"contentFactoryArticleWorkspaceWorkerHostGenerationFixture",
);
expect(content).toContain("contentFactoryHostGenerationAsterChatRequest");
expect(content).toContain("startContentFactoryHostGenerationFixture");
expect(content).toContain("fixture-openai");
expect(content).toContain("article-draft-document");
expect(content).not.toContain("受控宿主生成标题");
expect(content).not.toContain("内容工厂插件化写作:让文章生产可审计");
expect(content).toContain(
"contentFactoryArticleWorkspaceWorkerTurnExecuted",
);
expect(content).toContain("content.article.generate");
expect(content).toContain(
"options.scenario === CONTENT_FACTORY_ARTICLE_WORKSPACE_SCENARIO",
);
expect(content).toContain(
"readModel.workerArticleObject?.hostManagedGenerationStatus ===",
);
expect(content).toContain('"completed"');
expect(content).not.toContain(
'readModel.workerArticleObject?.hostManagedGenerationStatus ===\n "unavailable"',
);
expect(content).toContain(
"CONTENT_FACTORY_ARTICLE_WORKSPACE_REMOTE_REJECT_TURN_ID",
);
@@ -0,0 +1,507 @@
import fs from "node:fs";
import path from "node:path";
const APP_ID = "content-factory-app";
const HASH_RE = /^sha256:[a-f0-9]{64}$/i;
const SUPPORTED_SIGNATURE_ALGORITHMS = new Set([
"RSASSA-PKCS1-v1_5-SHA256",
"RSA-PSS-SHA256",
"ECDSA-P256-SHA256",
"Ed25519",
]);
function valueAtPath(root, parts) {
let current = root;
for (const part of parts) {
if (!current || typeof current !== "object") return undefined;
current = current[part];
}
return current;
}
function firstStringAtPaths(root, paths) {
for (const parts of paths) {
const value = valueAtPath(root, parts);
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function firstBoolAtPaths(root, paths) {
for (const parts of paths) {
const value = valueAtPath(root, parts);
if (typeof value === "boolean") return value;
}
return false;
}
function firstOptionalBoolAtPaths(root, paths) {
for (const parts of paths) {
const value = valueAtPath(root, parts);
if (typeof value === "boolean") return value;
}
return undefined;
}
function firstObjectAtPaths(root, paths) {
for (const parts of paths) {
const value = valueAtPath(root, parts);
if (value && typeof value === "object" && !Array.isArray(value)) {
return value;
}
}
return null;
}
function firstArrayAtPaths(root, paths) {
for (const parts of paths) {
const value = valueAtPath(root, parts);
if (Array.isArray(value)) return value;
}
return [];
}
function visit(root, fn, seen = new Set()) {
if (!root || typeof root !== "object") return null;
if (seen.has(root)) return null;
seen.add(root);
if (fn(root)) return root;
if (Array.isArray(root)) {
for (const item of root) {
const found = visit(item, fn, seen);
if (found) return found;
}
return null;
}
for (const value of Object.values(root)) {
const found = visit(value, fn, seen);
if (found) return found;
}
return null;
}
function findAppRecord(root, appId) {
return visit(root, (value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const id = firstStringAtPaths(value, [
["appId"],
["app_id"],
["id"],
["manifest", "appId"],
["identity", "appId"],
]);
return id === appId;
});
}
function recursiveArrayByKey(root, keys, seen = new Set()) {
if (!root || typeof root !== "object") return [];
if (seen.has(root)) return [];
seen.add(root);
if (!Array.isArray(root)) {
for (const key of keys) {
if (Array.isArray(root[key])) return root[key];
}
}
const values = Array.isArray(root) ? root : Object.values(root);
for (const value of values) {
const found = recursiveArrayByKey(value, keys, seen);
if (found.length > 0) return found;
}
return [];
}
function validSha256(value) {
return HASH_RE.test(value || "");
}
function isFixtureText(value) {
return /fixture|localhost|127\.0\.0\.1|hostGenerationFixture|cloudReleaseFixture/i.test(
String(value || ""),
);
}
function packageUrlIsProductionHttps(value) {
try {
const url = new URL(value);
return url.protocol === "https:" && !isFixtureText(url.href);
} catch {
return false;
}
}
function summarizeSignatureProof(appRecord) {
const proof =
firstObjectAtPaths(appRecord, [
["signatureProof"],
["signature_proof"],
["identity", "signatureProof"],
["identity", "signature_proof"],
["release", "signatureProof"],
["release", "signature_proof"],
["setup", "cloudReleaseEvidence", "signatureProof"],
]) || {};
const algorithm = firstStringAtPaths(proof, [["algorithm"], ["alg"]]);
const publicKeyId = firstStringAtPaths(proof, [
["publicKeyId"],
["public_key_id"],
["keyId"],
["key_id"],
]);
const payloadHash = firstStringAtPaths(proof, [
["payloadHash"],
["payload_hash"],
]);
const signature = firstStringAtPaths(proof, [["signature"], ["sig"]]);
const signedAt = firstStringAtPaths(proof, [["signedAt"], ["signed_at"]]);
return {
algorithm: algorithm || null,
present: Boolean(publicKeyId && algorithm && payloadHash && signature && signedAt),
publicKeyId: publicKeyId || null,
payloadHash: payloadHash || null,
signaturePresent: Boolean(signature),
signedAt: signedAt || null,
supportedAlgorithm: SUPPORTED_SIGNATURE_ALGORITHMS.has(algorithm),
};
}
function summarizeCatalog(catalog, expectedVersion, appId) {
const appRecord = findAppRecord(catalog, appId);
const signatureProof = summarizeSignatureProof(appRecord || {});
const packageUrl = firstStringAtPaths(appRecord || {}, [
["packageUrl"],
["package_url"],
["sourceUri"],
["source_uri"],
["identity", "sourceUri"],
["identity", "source_uri"],
]);
const packageHash = firstStringAtPaths(appRecord || {}, [
["packageHash"],
["package_hash"],
["identity", "packageHash"],
["identity", "package_hash"],
]);
const manifestHash = firstStringAtPaths(appRecord || {}, [
["manifestHash"],
["manifest_hash"],
["identity", "manifestHash"],
["identity", "manifest_hash"],
]);
const sourceKind = firstStringAtPaths(appRecord || {}, [
["sourceKind"],
["source_kind"],
["identity", "sourceKind"],
["identity", "source_kind"],
["source", "kind"],
]);
const version = firstStringAtPaths(appRecord || {}, [
["version"],
["appVersion"],
["app_version"],
["identity", "appVersion"],
["identity", "app_version"],
["manifest", "version"],
]);
const signatureRef = firstStringAtPaths(appRecord || {}, [
["signatureRef"],
["signature_ref"],
["identity", "signatureRef"],
["identity", "signature_ref"],
]);
const releaseId = firstStringAtPaths(appRecord || {}, [
["releaseId"],
["release_id"],
["identity", "releaseId"],
["identity", "release_id"],
]);
const channel = firstStringAtPaths(appRecord || {}, [
["channel"],
["releaseChannel"],
["release_channel"],
]);
return {
appFound: Boolean(appRecord),
channel: channel || null,
expectedVersion: expectedVersion || null,
manifestHash: manifestHash || null,
manifestHashValid: validSha256(manifestHash),
packageHash: packageHash || null,
packageHashValid: validSha256(packageHash),
packageUrl: packageUrl || null,
packageUrlProductionHttps: packageUrlIsProductionHttps(packageUrl),
releaseId: releaseId || null,
signatureProof,
signatureRef: signatureRef || null,
sourceKind: sourceKind || null,
sourceKindReady: sourceKind === "cloud_release" || sourceKind === "remote",
version: version || null,
versionMatches: expectedVersion ? version === expectedVersion : Boolean(version),
};
}
function trustRootMatches(root, signatureProof, appId) {
const publicKeyId = firstStringAtPaths(root, [["publicKeyId"], ["public_key_id"]]);
const algorithm = firstStringAtPaths(root, [["algorithm"], ["alg"]]);
const revoked = root?.revoked === true;
const appIds = firstArrayAtPaths(root, [["appIds"], ["app_ids"]]).filter(
(item) => typeof item === "string" && item.trim(),
);
const signedAtTime = Date.parse(signatureProof.signedAt || "");
const notBefore = firstStringAtPaths(root, [["notBefore"], ["not_before"]]);
const notAfter = firstStringAtPaths(root, [["notAfter"], ["not_after"]]);
const afterStart =
!notBefore || Number.isNaN(signedAtTime) || signedAtTime >= Date.parse(notBefore);
const beforeEnd =
!notAfter || Number.isNaN(signedAtTime) || signedAtTime <= Date.parse(notAfter);
return (
publicKeyId === signatureProof.publicKeyId &&
(!algorithm || algorithm === signatureProof.algorithm) &&
!revoked &&
(appIds.length === 0 || appIds.includes(appId)) &&
afterStart &&
beforeEnd
);
}
function summarizeBootstrap(bootstrap, signatureProof, appId) {
const directTrustRoots = firstArrayAtPaths(bootstrap || {}, [
["agentAppSignatureTrustRoots"],
["agentApps", "signatureTrustRoots"],
["agent_apps", "signature_trust_roots"],
["signatureTrustRoots"],
["signature_trust_roots"],
]);
const trustRoots =
directTrustRoots.length > 0
? directTrustRoots
: recursiveArrayByKey(bootstrap || {}, [
"agentAppSignatureTrustRoots",
"signatureTrustRoots",
"signature_trust_roots",
]);
const matchingTrustRoot = trustRoots.find((root) =>
trustRootMatches(root, signatureProof, appId),
);
return {
matchingTrustRoot: Boolean(matchingTrustRoot),
ready: trustRoots.length > 0 && Boolean(matchingTrustRoot),
trustRootCount: trustRoots.length,
};
}
function summarizeFetchCloud(fetchCloud) {
const sourceKind = firstStringAtPaths(fetchCloud || {}, [
["sourceKind"],
["source_kind"],
["identity", "sourceKind"],
["identity", "source_kind"],
["installedState", "sourceKind"],
["installed_state", "source_kind"],
["cloudReleaseFixture", "sourceKind"],
]);
const status = firstStringAtPaths(fetchCloud || {}, [
["status"],
["evidenceStatus"],
["evidence_status"],
["cloudReleaseEvidence", "status"],
["setup", "cloudReleaseEvidence", "status"],
["cloudReleaseFixture", "evidenceStatus"],
]);
const signatureVerificationStatus = firstStringAtPaths(fetchCloud || {}, [
["signatureVerificationStatus"],
["signature_verification_status"],
["cloudReleaseEvidence", "signatureVerificationStatus"],
["setup", "cloudReleaseEvidence", "signatureVerificationStatus"],
["cloudReleaseFixture", "signatureVerificationStatus"],
]);
const packageVerificationStatus = firstStringAtPaths(fetchCloud || {}, [
["packageVerificationStatus"],
["package_verification_status"],
["cloudReleaseEvidence", "packageVerificationStatus"],
["setup", "cloudReleaseEvidence", "packageVerificationStatus"],
["cloudReleaseFixture", "packageVerificationStatus"],
]);
const packageHashMatched = firstOptionalBoolAtPaths(fetchCloud || {}, [
["packageHashMatched"],
["package_hash_matched"],
["cloudReleaseEvidence", "packageHashMatched"],
["setup", "cloudReleaseEvidence", "packageHashMatched"],
]);
const manifestHashMatched = firstOptionalBoolAtPaths(fetchCloud || {}, [
["manifestHashMatched"],
["manifest_hash_matched"],
["cloudReleaseEvidence", "manifestHashMatched"],
["setup", "cloudReleaseEvidence", "manifestHashMatched"],
]);
return {
fixtureLike: isFixtureText(JSON.stringify(fetchCloud || {})),
manifestHashMatched: manifestHashMatched === true,
packageHashMatched: packageHashMatched === true,
packageVerificationStatus: packageVerificationStatus || null,
ready:
sourceKind === "cloud_release" &&
status === "ready" &&
signatureVerificationStatus === "verified" &&
packageVerificationStatus === "verified" &&
packageHashMatched === true &&
manifestHashMatched === true,
signatureVerificationStatus: signatureVerificationStatus || null,
sourceKind: sourceKind || null,
status: status || "missing",
};
}
function acceptedStatus(status) {
return new Set(["ready", "passed", "success", "ok", "completed"]).has(status);
}
function summarizeGuiEvidence(guiEvidence) {
const json = JSON.stringify(guiEvidence || {});
const status = firstStringAtPaths(guiEvidence || {}, [["status"], ["gui", "status"]]);
const sourceKind = firstStringAtPaths(guiEvidence || {}, [
["installedState", "sourceKind"],
["installed_state", "source_kind"],
["cloudRelease", "sourceKind"],
["cloudReleaseFixture", "sourceKind"],
]);
const signatureVerificationStatus = firstStringAtPaths(guiEvidence || {}, [
["signatureVerificationStatus"],
["cloudRelease", "signatureVerificationStatus"],
["cloudReleaseFixture", "signatureVerificationStatus"],
["installedState", "signatureVerificationStatus"],
]);
const hostManagedGenerationStatus = firstStringAtPaths(guiEvidence || {}, [
["readModel", "hostManagedGenerationStatus"],
["currentTurn", "hostManagedGenerationStatus"],
["threadRead", "hostManagedGenerationStatus"],
["contentFactoryArticleWorkspaceReadModel", "workerArticleObject", "hostManagedGenerationStatus"],
]);
const workflowFactsHidden =
firstOptionalBoolAtPaths(guiEvidence || {}, [
["contentFactoryArticleWorkspaceWorkflowFactsHidden"],
["assertions", "contentFactoryArticleWorkspaceWorkflowFactsHidden"],
]) !== false;
const workflowJsonlPresent = Boolean(
firstStringAtPaths(guiEvidence || {}, [
["eventLogs", "workflowJsonl"],
["workflowJsonl"],
["workflowJsonlPath"],
]) || json.includes("workflow-events.jsonl"),
);
const articleDraftDocumentPresent =
json.includes("article-draft-document") ||
firstBoolAtPaths(guiEvidence || {}, [
["readModel", "articleDraftDocumentPresent"],
["assertions", "articleDraftDocumentPresent"],
]);
const liveProviderUsed =
firstBoolAtPaths(guiEvidence || {}, [
["liveProviderUsed"],
["providerEvidence", "liveProviderUsed"],
["assertions", "liveProviderUsed"],
]) ||
(firstBoolAtPaths(guiEvidence || {}, [["providerEvidence", "productionRoute"]]) &&
!json.includes("hostGenerationFixture"));
return {
articleDraftDocumentPresent,
fixtureLike: isFixtureText(json),
hostManagedGenerationStatus: hostManagedGenerationStatus || null,
liveProviderUsed,
ready:
acceptedStatus(status) &&
sourceKind === "cloud_release" &&
signatureVerificationStatus === "verified" &&
hostManagedGenerationStatus === "completed" &&
articleDraftDocumentPresent &&
workflowFactsHidden &&
workflowJsonlPresent &&
liveProviderUsed &&
!isFixtureText(json),
signatureVerificationStatus: signatureVerificationStatus || null,
sourceKind: sourceKind || null,
status: status || "missing",
statusReady: acceptedStatus(status),
workflowFactsHidden,
workflowJsonlPresent,
};
}
function add(missingRequirements, code, detail) {
missingRequirements.push({ code, detail });
}
export function buildContentFactorySignedReleaseGate(input = {}) {
const appId = input.appId || APP_ID;
const expectedVersion = input.expectedVersion || "";
const catalog = summarizeCatalog(input.catalog || {}, expectedVersion, appId);
const bootstrap = summarizeBootstrap(
input.bootstrap || {},
catalog.signatureProof,
appId,
);
const fetchCloud = summarizeFetchCloud(input.fetchCloud || {});
const guiEvidence = summarizeGuiEvidence(input.guiEvidence || {});
const missingRequirements = [];
if (!catalog.appFound) add(missingRequirements, "production_catalog_missing", "Production catalog must include content-factory-app.");
if (!catalog.versionMatches) add(missingRequirements, "production_version_mismatch", "Production catalog version must match expected content-factory-app version.");
if (!catalog.sourceKindReady) add(missingRequirements, "production_catalog_not_remote_release", "Catalog sourceKind must be cloud_release/remote.");
if (!catalog.packageUrlProductionHttps) add(missingRequirements, "production_package_url_not_https", "Catalog packageUrl must be non-fixture HTTPS.");
if (!catalog.packageHashValid) add(missingRequirements, "production_package_hash_missing", "Catalog packageHash must be sha256:<64 hex>.");
if (!catalog.manifestHashValid) add(missingRequirements, "production_manifest_hash_missing", "Catalog manifestHash must be sha256:<64 hex>.");
if (!catalog.signatureRef) add(missingRequirements, "production_signature_ref_missing", "Catalog identity must include signatureRef.");
if (!catalog.signatureProof.present) add(missingRequirements, "production_signature_proof_missing", "Catalog must include signatureProof with publicKeyId, algorithm, payloadHash, signature, and signedAt.");
if (catalog.signatureProof.algorithm && !catalog.signatureProof.supportedAlgorithm) add(missingRequirements, "production_signature_algorithm_unsupported", "signatureProof algorithm is not supported by the Host verifier.");
if (!bootstrap.trustRootCount) add(missingRequirements, "production_trust_roots_missing", "Bootstrap must include Agent App signature trust roots.");
if (bootstrap.trustRootCount && !bootstrap.matchingTrustRoot) add(missingRequirements, "production_signature_trust_root_missing", "Bootstrap trust roots must match signatureProof publicKeyId/algorithm/appId/time window.");
if (!input.fetchCloud) add(missingRequirements, "production_fetch_cloud_evidence_missing", "agentAppPackage/fetchCloud or package verification evidence is required.");
if (input.fetchCloud && !fetchCloud.ready) add(missingRequirements, "production_release_evidence_not_ready", "fetchCloud evidence must prove cloud_release, verified hashes, verified signature, and ready status.");
if (fetchCloud.fixtureLike) add(missingRequirements, "fixture_cloud_release_not_allowed", "Fixture cloud_release evidence is not production evidence.");
if (!input.guiEvidence) add(missingRequirements, "production_gui_evidence_missing", "Real Lime Desktop GUI install/run evidence is required.");
if (input.guiEvidence && !guiEvidence.statusReady) add(missingRequirements, "production_gui_evidence_not_ready", "GUI evidence status must be passed/ready/completed.");
if (input.guiEvidence && guiEvidence.sourceKind !== "cloud_release") add(missingRequirements, "production_gui_not_cloud_release", "GUI evidence must run the cloud_release installed app.");
if (input.guiEvidence && guiEvidence.signatureVerificationStatus !== "verified") add(missingRequirements, "production_gui_signature_not_verified", "GUI evidence must prove signature verification status is verified.");
if (input.guiEvidence && guiEvidence.hostManagedGenerationStatus !== "completed") add(missingRequirements, "production_host_generation_not_completed", "GUI/read model must show hostManagedGeneration completed.");
if (input.guiEvidence && (!guiEvidence.liveProviderUsed || guiEvidence.fixtureLike)) add(missingRequirements, "production_host_generation_not_live", "Production evidence must prove live Provider generation, not a localhost fixture.");
if (input.guiEvidence && guiEvidence.fixtureLike) add(missingRequirements, "fixture_cloud_release_not_allowed", "GUI evidence contains fixture/localhost markers and cannot close production.");
if (input.guiEvidence && !guiEvidence.workflowJsonlPresent) add(missingRequirements, "production_workflow_jsonl_missing", "GUI evidence must point to workflow-events.jsonl audit output.");
if (input.guiEvidence && !guiEvidence.workflowFactsHidden) add(missingRequirements, "production_workflow_facts_visible", "Right-side Article Editor must not display workflow steps.");
if (input.guiEvidence && !guiEvidence.articleDraftDocumentPresent) add(missingRequirements, "production_article_draft_document_missing", "GUI/read model must include article-draft-document output.");
const ready =
missingRequirements.length === 0 &&
catalog.signatureProof.present &&
bootstrap.ready &&
fetchCloud.ready &&
guiEvidence.ready;
return {
schemaVersion: "content-factory-signed-release-gate.v1",
appId,
expectedVersion: expectedVersion || null,
ready,
status: ready ? "ready" : "blocked",
catalog,
bootstrap,
fetchCloud,
guiEvidence,
missingRequirements,
note:
"This gate accepts only production signed cloud_release + trust roots + fetchCloud verification + GUI live Provider evidence. Localhost fixtures remain blocked.",
};
}
export function readOptionalJsonFile(filePath) {
if (!filePath) return null;
const resolvedPath = path.resolve(process.cwd(), filePath);
if (!fs.existsSync(resolvedPath)) return null;
return JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
}
export function writeJsonFile(filePath, value) {
const resolvedPath = path.resolve(process.cwd(), filePath);
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
fs.writeFileSync(resolvedPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
@@ -0,0 +1,243 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
buildContentFactorySignedReleaseGate,
writeJsonFile,
} from "./agent-app-content-factory-signed-release-gate-core.mjs";
const SIGNATURE_PROOF = {
algorithm: "Ed25519",
payloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
publicKeyId: "content-factory-prod-root-2026",
signature: "base64-signature",
signedAt: "2026-07-03T00:00:00.000Z",
};
const READY_CATALOG = {
apps: [
{
appId: "content-factory-app",
appVersion: "2.2.2",
identity: {
manifestHash:
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
packageHash:
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
signatureRef: "sigstore:content-factory-app@2.2.2:prod",
sourceKind: "cloud_release",
sourceUri:
"https://updates.limeai.run/agent-apps/content-factory-app/prod/content-factory-app-2.2.2.lapp",
},
signatureProof: SIGNATURE_PROOF,
},
],
};
const READY_BOOTSTRAP = {
agentAppSignatureTrustRoots: [
{
algorithm: "Ed25519",
appIds: ["content-factory-app"],
notBefore: "2026-01-01T00:00:00.000Z",
notAfter: "2027-01-01T00:00:00.000Z",
publicKeyId: "content-factory-prod-root-2026",
revoked: false,
},
],
};
const READY_FETCH_CLOUD = {
manifestHashMatched: true,
packageHashMatched: true,
packageVerificationStatus: "verified",
signatureVerificationStatus: "verified",
sourceKind: "cloud_release",
status: "ready",
};
const READY_GUI_EVIDENCE = {
assertions: {
articleDraftDocumentPresent: true,
contentFactoryArticleWorkspaceWorkflowFactsHidden: true,
liveProviderUsed: true,
},
eventLogs: {
workflowJsonl:
"/tmp/lime-runtime/events/sessions/session_prod/workflow-events.jsonl",
},
installedState: {
sourceKind: "cloud_release",
},
providerEvidence: {
liveProviderUsed: true,
productionRoute: true,
},
readModel: {
hostManagedGenerationStatus: "completed",
},
signatureVerificationStatus: "verified",
status: "passed",
};
describe("content factory signed release gate", () => {
it("缺少 production evidence 时 blocked", () => {
const result = buildContentFactorySignedReleaseGate({
expectedVersion: "2.2.2",
});
expect(result).toMatchObject({
ready: false,
status: "blocked",
missingRequirements: expect.arrayContaining([
expect.objectContaining({ code: "production_catalog_missing" }),
expect.objectContaining({ code: "production_trust_roots_missing" }),
expect.objectContaining({
code: "production_fetch_cloud_evidence_missing",
}),
expect.objectContaining({ code: "production_gui_evidence_missing" }),
]),
});
});
it("拒绝 fixture cloud release 和 localhost host generation evidence", () => {
const result = buildContentFactorySignedReleaseGate({
bootstrap: READY_BOOTSTRAP,
catalog: {
apps: [
{
...READY_CATALOG.apps[0],
identity: {
...READY_CATALOG.apps[0].identity,
signatureRef: "sigstore:content-factory-app@2.2.2:fixture",
sourceUri:
"https://updates.limeai.run/agent-apps/content-factory-app/fixture/content-factory-app-2.2.2.lapp",
},
releaseId: "content-factory-fixture-2.2.2",
},
],
},
expectedVersion: "2.2.2",
fetchCloud: {
...READY_FETCH_CLOUD,
cloudReleaseFixture: { channel: "fixture" },
},
guiEvidence: {
...READY_GUI_EVIDENCE,
hostGenerationFixture: {
baseUrl: "http://127.0.0.1:48999",
requestCount: 1,
},
},
});
expect(result).toMatchObject({
ready: false,
status: "blocked",
missingRequirements: expect.arrayContaining([
expect.objectContaining({ code: "production_package_url_not_https" }),
expect.objectContaining({ code: "fixture_cloud_release_not_allowed" }),
expect.objectContaining({ code: "production_host_generation_not_live" }),
]),
});
});
it("接受 production signed release + trust root + live Provider GUI evidence", () => {
const result = buildContentFactorySignedReleaseGate({
bootstrap: READY_BOOTSTRAP,
catalog: READY_CATALOG,
expectedVersion: "2.2.2",
fetchCloud: READY_FETCH_CLOUD,
guiEvidence: READY_GUI_EVIDENCE,
});
expect(result).toMatchObject({
ready: true,
status: "ready",
bootstrap: {
matchingTrustRoot: true,
},
fetchCloud: {
ready: true,
},
guiEvidence: {
liveProviderUsed: true,
ready: true,
},
missingRequirements: [],
});
});
it("CLI --check 写入 blocked gate JSON 并返回非零", () => {
const outputDir = fs.mkdtempSync(
path.join(os.tmpdir(), "content-factory-signed-release-gate-"),
);
const outputPath = path.join(outputDir, "gate.json");
const result = spawnSync(
process.execPath,
[
path.resolve("scripts/agent-app/content-factory-signed-release-gate.mjs"),
"--expected-version",
"2.2.2",
"--output",
outputPath,
"--check",
],
{ encoding: "utf8", env: { PATH: process.env.PATH } },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("missingCodes=");
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toMatchObject({
ready: false,
status: "blocked",
});
});
it("CLI --check 在全部 production evidence ready 时返回 0", () => {
const outputDir = fs.mkdtempSync(
path.join(os.tmpdir(), "content-factory-signed-release-gate-ready-"),
);
const catalogPath = path.join(outputDir, "catalog.json");
const bootstrapPath = path.join(outputDir, "bootstrap.json");
const fetchCloudPath = path.join(outputDir, "fetch-cloud.json");
const guiEvidencePath = path.join(outputDir, "gui.json");
const outputPath = path.join(outputDir, "gate.json");
writeJsonFile(catalogPath, READY_CATALOG);
writeJsonFile(bootstrapPath, READY_BOOTSTRAP);
writeJsonFile(fetchCloudPath, READY_FETCH_CLOUD);
writeJsonFile(guiEvidencePath, READY_GUI_EVIDENCE);
const result = spawnSync(
process.execPath,
[
path.resolve("scripts/agent-app/content-factory-signed-release-gate.mjs"),
"--catalog",
catalogPath,
"--bootstrap",
bootstrapPath,
"--fetch-cloud",
fetchCloudPath,
"--gui-evidence",
guiEvidencePath,
"--expected-version",
"2.2.2",
"--output",
outputPath,
"--check",
],
{ encoding: "utf8", env: { PATH: process.env.PATH } },
);
expect(result.status).toBe(0);
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toMatchObject({
ready: true,
status: "ready",
missingRequirements: [],
});
});
});
@@ -0,0 +1,171 @@
import { createHash } from "node:crypto";
import {
DEFAULT_FIXTURE_API_KEY,
DEFAULT_FIXTURE_MODEL,
startOpenAiCompatibleFixtureServer,
} from "./openai-compatible-fixture-server.mjs";
const PROVIDER_ID = "fixture-openai";
const PROVIDER_NAME = "openai";
function normalizeText(value, fallback = "") {
const text = typeof value === "string" ? value.trim() : "";
return text || fallback;
}
function flattenMessageContent(content) {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (!part || typeof part !== "object") {
return "";
}
return normalizeText(part.text ?? part.content ?? "");
})
.filter(Boolean)
.join("\n");
}
function requestMessagesText(body) {
return Array.isArray(body?.messages)
? body.messages
.map((message) => flattenMessageContent(message?.content))
.filter(Boolean)
.join("\n\n")
: "";
}
function extractOriginalPrompt(text) {
const normalized = normalizeText(text);
const marker = "用户原始请求:";
const index = normalized.indexOf(marker);
if (index < 0) {
return normalized;
}
const afterMarker = normalized.slice(index + marker.length).trim();
const endIndex = afterMarker.indexOf("\n\n生成目标:");
return normalizeText(endIndex >= 0 ? afterMarker.slice(0, endIndex) : afterMarker);
}
function compactWhitespace(value) {
return normalizeText(value).replace(/\s+/g, " ");
}
function promptFingerprint(prompt) {
return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
}
function topicFromPrompt(prompt) {
const compact = compactWhitespace(prompt);
const match = compact.match(/(?:关于|围绕)(.+?)(?:的(?:公众号)?文章||。|$)/);
const topic = normalizeText(match?.[1], compact);
return topic.slice(0, 42) || "内容生产任务";
}
export function buildContentFactoryHostGenerationFixtureMarkdown(body) {
const messages = requestMessagesText(body);
const prompt = extractOriginalPrompt(messages);
const topic = topicFromPrompt(prompt);
const fingerprint = promptFingerprint(prompt || messages || "content-factory");
return [
`# ${topic}fixture-only 托管生成草稿`,
"",
`fixturePromptFingerprint: ${fingerprint}`,
"",
"> 这是本地 OpenAI-compatible fixture 按请求动态生成的测试正文,只用于证明 hostManagedGeneration 注入链路;生产完成度必须使用 live Provider evidence。",
"",
"## 请求摘要",
"",
compactWhitespace(prompt || "未提供用户原始请求。"),
"",
"## 资料检索",
"",
`- 围绕“${topic}”确认读者、交付边界和可验证依据。`,
"- 将检索过程保留在 workflow JSONL 审计中,不展示到右侧编辑器。",
"",
"## 正文草稿",
"",
`这篇文章应从“${topic}”的真实使用场景切入,说明为什么读者现在需要理解它,以及它能解决什么具体问题。`,
"",
"正文不应来自 worker 内置模板,也不应由插件持有 Provider Key。宿主负责模型调用与权限边界,worker 只消费宿主回填的 Markdown,并把它转换为 Article Workspace Patch。",
"",
"段落级流式应先输出可编辑的文章对象,再随着宿主生成结果递增更新 artifact snapshot;没有宿主正文时必须 fail closed。",
"",
"## 交付检查",
"",
"- 正文来自 hostManagedGeneration.outputs[].content。",
"- workflow 过程只写入 workflow-events.jsonl。",
"- 右侧 Article Editor 只展示文章对象,不展示 workflow 步骤。",
].join("\n");
}
export function contentFactoryHostGenerationAsterChatRequest(baseUrl) {
return {
provider_config: {
provider_id: PROVIDER_ID,
provider_name: PROVIDER_NAME,
model_name: DEFAULT_FIXTURE_MODEL,
api_key: DEFAULT_FIXTURE_API_KEY,
base_url: baseUrl,
tool_call_strategy: "native",
},
provider_preference: PROVIDER_ID,
model_preference: DEFAULT_FIXTURE_MODEL,
reasoning_effort: "low",
};
}
export async function startContentFactoryHostGenerationFixture() {
const responses = [];
const fixture = await startOpenAiCompatibleFixtureServer({
scriptedResponses: [
({ body }) => {
const content = buildContentFactoryHostGenerationFixtureMarkdown(body);
const fingerprint =
content.match(/fixturePromptFingerprint:\s*([a-f0-9]+)/)?.[1] ?? null;
responses.push({
fingerprint,
contentLength: content.length,
title: content.split(/\r?\n/)[0]?.replace(/^#\s*/, "") ?? null,
});
return { type: "text", content };
},
],
});
return {
baseUrl: fixture.baseUrl,
requests: fixture.requests,
responses,
summary: () => ({
provider: PROVIDER_ID,
model: DEFAULT_FIXTURE_MODEL,
requestCount: fixture.requests.length,
requests: fixture.requests.map((request) => ({
method: request.method,
url: request.path,
authorization: request.authorization ? "present" : "missing",
bodyIncludesModel: JSON.stringify(request.body || {}).includes(
DEFAULT_FIXTURE_MODEL,
),
bodyIncludesPrompt: JSON.stringify(request.body || {}).includes(
"内容工厂",
),
})),
responseFingerprints: responses.map((response) => response.fingerprint),
responseTitles: responses.map((response) => response.title).filter(Boolean),
fixtureOnly: true,
}),
close: fixture.close,
};
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
buildContentFactoryHostGenerationFixtureMarkdown,
contentFactoryHostGenerationAsterChatRequest,
} from "./content-factory-host-generation-fixture.mjs";
function providerBody(prompt) {
return {
model: "lime-fixture-chat",
stream: true,
messages: [
{
role: "system",
content: "只输出 Markdown 正文。",
},
{
role: "user",
content: [
"用户原始请求:",
prompt,
"",
"生成目标:",
"- targetObjectKind: articleDraft",
"- outputField: documentText",
].join("\n"),
},
],
};
}
describe("content factory host generation fixture", () => {
it("按 provider 请求动态生成 fixture-only Markdown", () => {
const first = buildContentFactoryHostGenerationFixtureMarkdown(
providerBody("写一篇关于 AI Agent 工作流如何让内容生产可审计的公众号文章"),
);
const second = buildContentFactoryHostGenerationFixtureMarkdown(
providerBody("写一篇关于团队知识库治理的公众号文章"),
);
expect(first).toContain("fixture-only");
expect(first).toContain("fixturePromptFingerprint:");
expect(first).toContain("AI Agent 工作流如何让内容生产可审计");
expect(second).toContain("团队知识库治理");
expect(second).toContain("fixturePromptFingerprint:");
expect(second).not.toBe(first);
expect(first).not.toContain("受控宿主生成标题");
expect(first).not.toContain("内容工厂插件化写作:让文章生产可审计");
});
it("生成 Direct provider request 时只指向本地 fixture", () => {
const request = contentFactoryHostGenerationAsterChatRequest(
"http://127.0.0.1:41234",
);
expect(request).toMatchObject({
provider_config: {
provider_id: "fixture-openai",
provider_name: "openai",
model_name: "lime-fixture-chat",
api_key: "fixture-key",
base_url: "http://127.0.0.1:41234",
},
provider_preference: "fixture-openai",
model_preference: "lime-fixture-chat",
});
});
});
+5 -1
View File
@@ -71,7 +71,11 @@ export function buildVitestLayerReport({
bucket.files.push(entry.file);
const unitMigrationHints = entry.unitMigrationHints ?? [];
if (entry.layer === "component" && unitMigrationHints.length > 0) {
if (
entry.layer === "component" &&
entry.explicitLayer !== "unit" &&
unitMigrationHints.length > 0
) {
componentUnitMigrationCandidates.total += 1;
componentUnitMigrationCandidates.files.push({
file: entry.file,
@@ -136,4 +136,37 @@ describe("vitest-layer-report", () => {
},
]);
});
it("显式 unit 测试不应再计入 component VM 迁移候选", () => {
const report = buildVitestLayerReport({
entries: [
{
file: "src/components/FooViewModel.unit.test.ts",
layer: "component",
explicitLayer: "unit",
live: false,
reasons: ["browser-dom"],
unitMigrationHints: ["large-component-file"],
},
{
file: "src/components/Foo.test.tsx",
layer: "component",
explicitLayer: null,
live: false,
reasons: ["react-testing-library"],
unitMigrationHints: ["large-component-file"],
},
],
});
expect(report.componentUnitMigrationCandidates).toMatchObject({
total: 1,
files: [
{
file: "src/components/Foo.test.tsx",
hints: ["large-component-file"],
},
],
});
});
});
@@ -41,12 +41,15 @@ function readAgentRuntimeCopy(key: string, fallback: string): string {
return fallback;
}
return String(
i18n.t(key, {
ns: "agent",
defaultValue: fallback,
}),
);
const translate = i18n.t as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
return translate(key, {
ns: "agent",
defaultValue: fallback,
});
}
function isLikelyProviderAuthError(message: string): boolean {
@@ -1,4 +1,4 @@
import { getLimeI18n } from "@/i18n/createI18n";
import { resolveAgentChatCopy } from "./agentChatCopy";
import { resolveContentWorkbenchToolCopy } from "./contentWorkbenchToolCopy";
const LIME_CREATE_TASK_RE = /\blime_create_([a-z0-9_]+?)_task\b/i;
@@ -211,10 +211,10 @@ export function isLimeTaskProtocolFailureResidue(
}
export function resolveImageGenerationFailureDisplayText(): string {
return getLimeI18n().t("agentChat.imageWorkbenchPreview.placeholder.failed", {
ns: "agent",
defaultValue: "生成失败",
});
return resolveAgentChatCopy(
"imageWorkbenchPreview.placeholder.failed",
"生成失败",
);
}
export function resolveLimeTaskProtocolFailureDisplayText(params: {
@@ -44,7 +44,11 @@ export function LatestRunStatusBadge({
className,
pollMs = 15_000,
}: LatestRunStatusBadgeProps) {
const { t } = useTranslation("common");
const { t: translate } = useTranslation("common");
const t = translate as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
const [latestRun, setLatestRun] = useState<AgentRun | null>(null);
const resolvedLabel =
label ?? t("common.execution.latestRunStatus.defaultLabel");
@@ -1,5 +1,7 @@
import { Buffer } from "node:buffer";
import { webcrypto } from "node:crypto";
import { generateKeyPairSync, webcrypto } from "node:crypto";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import type {
AgentAppCloudReleaseSignatureProof,
@@ -16,6 +18,29 @@ const PACKAGE_HASH =
const MANIFEST_HASH =
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
interface ContentFactoryReleaseSignatureModule {
buildReleaseSignature(params: {
release: {
appId: string;
version: string;
releaseId?: string;
tenantId?: string;
tenantEnablementRef?: string;
channel?: string;
packageUrl: string;
packageHash: string;
manifestHash: string;
signatureRef: string;
};
privateKeyPem: string;
publicKeyId: string;
signedAt: string;
}): {
proof: AgentAppCloudReleaseSignatureProof;
trustRoot: AgentAppCloudReleaseSignatureTrustRoot;
};
}
interface SignedFixtureKeyMaterial {
privateKey: CryptoKey;
publicKeyBase64: string;
@@ -75,10 +100,12 @@ async function getSignedFixtureKeyMaterial(): Promise<SignedFixtureKeyMaterial>
return signedFixtureKeyMaterial;
}
async function buildSignedFixture(options: {
proof?: Partial<AgentAppCloudReleaseSignatureProof>;
trustRoot?: Partial<AgentAppCloudReleaseSignatureTrustRoot>;
} = {}): Promise<{
async function buildSignedFixture(
options: {
proof?: Partial<AgentAppCloudReleaseSignatureProof>;
trustRoot?: Partial<AgentAppCloudReleaseSignatureTrustRoot>;
} = {},
): Promise<{
app: CloudBootstrapApp;
trustRoot: AgentAppCloudReleaseSignatureTrustRoot;
}> {
@@ -126,6 +153,15 @@ function toBase64(value: ArrayBuffer): string {
return Buffer.from(value).toString("base64");
}
function buildRsaPrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
return privateKey;
}
describe("cloudReleaseSignature", () => {
it("应通过 trust root 验证 Cloud release 签名证明", async () => {
const { app, trustRoot } = await buildSignedFixture();
@@ -301,4 +337,46 @@ describe("cloudReleaseSignature", () => {
}),
).resolves.toBe("declared");
});
it("应接受 content-factory release:sign 生成的签名证明", async () => {
const signatureModule = (await import(
pathToFileURL(
resolve(
process.cwd(),
"src/features/agent-app/testing/fixtures/package-root/scripts/sign-release.mjs",
),
).href
)) as ContentFactoryReleaseSignatureModule;
const release = {
appId: "content-factory-app",
version: "2.2.2",
releaseId: "agent-app-release-test",
tenantId: "tenant-0001",
tenantEnablementRef: "tenant-agent-app-test",
channel: "stable",
packageUrl:
"https://updates.limeai.run/agent-apps/content-factory-app-2.2.2.lapp",
packageHash: PACKAGE_HASH,
manifestHash: MANIFEST_HASH,
signatureRef: "sigstore:content-factory-app@2.2.2",
};
const signature = signatureModule.buildReleaseSignature({
release,
privateKeyPem: buildRsaPrivateKeyPem(),
publicKeyId: "agent-app-root-2026",
signedAt: "2026-07-03T00:00:00.000Z",
});
await expect(
verifyCloudReleaseSignature({
app: buildCloudApp({
...release,
signatureProof: signature.proof,
registrationState: "active",
}),
trustRoots: [signature.trustRoot],
crypto: webcrypto as unknown as Crypto,
}),
).resolves.toBe("verified");
});
});
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { describe, expect, it } from "vitest";
import contentFactoryFixture from "./fixtures/content-factory-app.json";
import seededAgentApps from "./fixtures/seeded-agent-apps.json";
import type { AppManifest } from "../types";
type ContentFactoryWorkflow = {
@@ -193,9 +194,17 @@ describe("contentFactoryFixtureSync", () => {
const rootWorkerSource = readFixtureText(
"src/runtime/content-factory-worker.mjs",
);
const rootArticlePlanningSource = readFixtureText(
"src/runtime/article-planning.mjs",
);
const packageArticlePlanningSource = readFileSync(
resolve(packageRoot, "src/runtime/article-planning.mjs"),
"utf8",
);
expect(rootRuntime).toEqual(packageRuntime);
expect(rootSampleRequest).toEqual(packageSampleRequest);
expect(rootArticlePlanningSource).toBe(packageArticlePlanningSource);
expect(rootWorkerSource).toContain(
"../../package-root/src/runtime/content-factory-worker.mjs",
);
@@ -248,4 +257,27 @@ describe("contentFactoryFixtureSync", () => {
expect(releaseDoc).toContain("signatureProof");
expect(releaseDoc).toContain("agentAppSignatureTrustRoots");
});
it("seeded descriptor 应与 package-root 当前版本保持一致", () => {
const packageJson = JSON.parse(
readFileSync(resolve(packageRoot, "package.json"), "utf8"),
);
const pluginJson = JSON.parse(
readFileSync(resolve(packageRoot, "plugin.json"), "utf8"),
);
const manifest = contentFactoryFixture as AppManifest;
const seededApp = seededAgentApps.apps.find(
(app) => app.appId === "content-factory-app",
);
expect(seededApp).toBeTruthy();
expect(pluginJson.version).toBe(packageJson.version);
expect(manifest.version).toBe(packageJson.version);
expect(seededApp?.version).toBe(packageJson.version);
expect(seededApp?.packageUrl).toContain(packageJson.version);
expect(seededApp?.packageSourceUri).toContain(packageJson.version);
expect(seededApp?.releaseId).toContain(packageJson.version);
expect(seededApp?.packageHash).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(seededApp?.manifestHash).toMatch(/^sha256:[a-f0-9]{64}$/);
});
});
@@ -2,7 +2,7 @@
"manifestVersion": "0.11.0",
"name": "content-factory-app",
"displayName": "内容工厂",
"version": "2.0.0",
"version": "2.2.2",
"status": "ready",
"appType": "domain-app",
"profiles": ["workbench"],
@@ -6,7 +6,7 @@
"turnId": "turn-demo-runtime-001",
"taskId": "task-demo-runtime-001",
"taskKind": "content.article.generate",
"prompt": "写一篇关于 Golang 学习路线的公众号文章,强调从基础语法到工程实战。",
"prompt": "写一篇微信公众号文章200字,关于人才选聘。",
"expectedOutput": {
"artifactKind": "content_factory.workspace_patch"
},
@@ -15,5 +15,22 @@
"directProviderAccess": false,
"directFilesystemAccess": false
},
"hostManagedGeneration": {
"schemaVersion": "lime.agent_app.host_managed_generation.v1",
"source": "runtime_sample",
"status": "completed",
"provider": "sample-provider",
"model": "sample-model",
"outputs": [
{
"id": "article-draft-document",
"kind": "markdown_document",
"targetObjectKind": "articleDraft",
"outputField": "documentText",
"contentType": "text/markdown",
"content": "# 人才选聘不能只看简历关键词\n\n人才选聘最难的地方,不是筛掉明显不合适的人,而是识别真正能把问题推进的人。简历只能说明经历,不能直接证明判断力、协作方式和学习速度。\n\n## 先定义岗位要解决的问题\n\n招聘前先写清楚这个岗位未来三个月要交付什么,而不是只罗列技能标签。目标越具体,面试越不容易变成主观印象。\n\n## 用任务验证真实能力\n\n面试可以围绕一个小型业务任务展开,让候选人说明拆解思路、取舍依据和风险判断。这样比单纯追问经历更接近真实工作。\n\n## 保留复盘证据\n\n每次选聘都应记录评价依据、分歧点和录用后的表现反馈。长期看,这些证据会帮助团队校准人才标准。"
}
]
},
"requestedAt": "2026-07-02T00:00:00.000Z"
}
@@ -41,7 +41,7 @@ npm run cli:inspect
npm run cli:run
```
这些校验证明插件包骨架文件完整、子智能体 / skills / CLI / connectors / hooks 随包落盘、worker 能生成包含检索轮次、标题候选、大纲、正文、配图占位和交付清单的 Article Workspace Patchworker 自己输出 audit-only connector 请求和段落级 artifact partial如果宿主注入 `hostManagedGeneration.outputs[]`worker 会直接使用宿主生成的 Markdown 正文;如果宿主当前 backend 没返回结果,worker 会把 `hostManagedGeneration.status` 收敛为 `unavailable` 后继续 deterministic fallback。完整应用中心安装、`@写作` 激活和 Claw 历史恢复仍由 Lime 宿主侧验证。
这些校验证明插件包骨架文件完整、子智能体 / skills / CLI / connectors / hooks 随包落盘、worker 能生成包含检索轮次、标题候选、大纲、正文、配图占位和交付清单的 Article Workspace Patchworker 自己输出 audit-only connector 请求和段落级 artifact partial;宿主必须注入 `hostManagedGeneration.outputs[]`worker 才会把宿主生成的 Markdown 正文写入文章草稿。若宿主当前 backend 没返回结果,`content.article.generate` 会 fail closed,不再用 deterministic fallback 伪造文章正文。完整应用中心安装、`@写作` 激活和 Claw 历史恢复仍由 Lime 宿主侧验证。
## 下一步开发
@@ -63,7 +63,7 @@ worker 不直接访问 provider key、Electron IPC、文件系统或 secret。
- 宿主声明 `app.runtime.yaml#agentRuntime.worker.hostManagedGeneration`
- 宿主按当前模型路由生成正文后,把 `status/provider/model/outputs[]` 写回 worker request。
- worker 优先使用 `articleDraft.documentText` 对应 output。
- 如果宿主无可用 provider、生成失败,或当前 backend 没返回 `hostManagedGenerationResult`worker 会把状态收敛为 `status=unavailable`,继续使用 deterministic fallback 生成合同样例,仅用于本地验证和宿主回归
- 如果宿主无可用 provider、生成失败,或当前 backend 没返回 `hostManagedGenerationResult``content.article.generate` 必须 fail closed,不得使用 deterministic fallback 生成合同样例或伪造文章正文
Lime 宿主会从 `plugin.json#contributions.runtime``app.runtime.yaml#agentRuntime.worker/tasks` 读取 task runtime 合同,并在插件安装态和 runtime 状态中暴露 worker readiness。worker 输出的 partial snapshot 必须由宿主通用透传和持久化,宿主不得用最终 `documentText` 二次切片来伪造流式。
@@ -6,7 +6,7 @@
"turnId": "turn-demo-runtime-001",
"taskId": "task-demo-runtime-001",
"taskKind": "content.article.generate",
"prompt": "写一篇关于 Golang 学习路线的公众号文章,强调从基础语法到工程实战。",
"prompt": "写一篇微信公众号文章200字,关于人才选聘。",
"expectedOutput": {
"artifactKind": "content_factory.workspace_patch"
},
@@ -15,5 +15,22 @@
"directProviderAccess": false,
"directFilesystemAccess": false
},
"hostManagedGeneration": {
"schemaVersion": "lime.agent_app.host_managed_generation.v1",
"source": "runtime_sample",
"status": "completed",
"provider": "sample-provider",
"model": "sample-model",
"outputs": [
{
"id": "article-draft-document",
"kind": "markdown_document",
"targetObjectKind": "articleDraft",
"outputField": "documentText",
"contentType": "text/markdown",
"content": "# 人才选聘不能只看简历关键词\n\n人才选聘最难的地方,不是筛掉明显不合适的人,而是识别真正能把问题推进的人。简历只能说明经历,不能直接证明判断力、协作方式和学习速度。\n\n## 先定义岗位要解决的问题\n\n招聘前先写清楚这个岗位未来三个月要交付什么,而不是只罗列技能标签。目标越具体,面试越不容易变成主观印象。\n\n## 用任务验证真实能力\n\n面试可以围绕一个小型业务任务展开,让候选人说明拆解思路、取舍依据和风险判断。这样比单纯追问经历更接近真实工作。\n\n## 保留复盘证据\n\n每次选聘都应记录评价依据、分歧点和录用后的表现反馈。长期看,这些证据会帮助团队校准人才标准。"
}
]
},
"requestedAt": "2026-07-02T00:00:00.000Z"
}
@@ -0,0 +1,247 @@
#!/usr/bin/env node
import { createHash, createPrivateKey, createPublicKey, constants, sign } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
export const SIGNATURE_PAYLOAD_SCHEMA = "agent-app-cloud-release-signature-payload/v2";
export const SIGNATURE_PROOF_SCHEMA = "agent-app-cloud-release-signature/v1";
export const DEFAULT_SIGNATURE_ALGORITHM = "RSASSA-PKCS1-v1_5-SHA256";
const supportedAlgorithms = new Set([
"RSASSA-PKCS1-v1_5-SHA256",
"RSA-PSS-SHA256",
"ECDSA-P256-SHA256",
"Ed25519"
]);
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) {
throw new Error(`unknown positional argument: ${arg}`);
}
const key = arg.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
result[key] = "true";
continue;
}
result[key] = next;
index += 1;
}
return result;
}
function required(value, name) {
const text = String(value ?? "").trim();
if (!text) {
throw new Error(`${name} is required`);
}
return text;
}
function normalizeHash(value, name) {
const text = required(value, name).toLowerCase();
if (!/^sha256:[a-f0-9]{64}$/.test(text)) {
throw new Error(`${name} must be a complete sha256:<64 hex> digest`);
}
return text;
}
async function readPackageDefaults(rootDir) {
const packageJson = JSON.parse(
await readFile(path.join(rootDir, "package.json"), "utf8")
);
const pluginJson = JSON.parse(
await readFile(path.join(rootDir, "plugin.json"), "utf8")
);
return {
appId: pluginJson.id ?? packageJson.name,
version: pluginJson.version ?? packageJson.version
};
}
export function buildReleaseSignaturePayload(release, proofDraft) {
return JSON.stringify({
schemaVersion: SIGNATURE_PAYLOAD_SCHEMA,
appId: release.appId,
version: release.version,
releaseId: release.releaseId ?? null,
tenantId: release.tenantId ?? null,
tenantEnablementRef: release.tenantEnablementRef ?? null,
channel: release.channel ?? null,
packageUrl: release.packageUrl,
packageHash: release.packageHash.toLowerCase(),
manifestHash: release.manifestHash.toLowerCase(),
signatureRef: release.signatureRef ?? null,
signatureProof: proofDraft
? {
schemaVersion: proofDraft.schemaVersion ?? null,
publicKeyId: proofDraft.publicKeyId,
algorithm: proofDraft.algorithm,
signedAt: proofDraft.signedAt ?? null
}
: null
});
}
export function sha256Digest(value) {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function signPayload(payload, privateKeyPem, algorithm) {
const key = createPrivateKey(privateKeyPem);
if (algorithm === "RSASSA-PKCS1-v1_5-SHA256") {
return sign("sha256", Buffer.from(payload), {
key,
padding: constants.RSA_PKCS1_PADDING
}).toString("base64");
}
if (algorithm === "RSA-PSS-SHA256") {
return sign("sha256", Buffer.from(payload), {
key,
padding: constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32
}).toString("base64");
}
if (algorithm === "ECDSA-P256-SHA256") {
return sign("sha256", Buffer.from(payload), key).toString("base64");
}
if (algorithm === "Ed25519") {
return sign(null, Buffer.from(payload), key).toString("base64");
}
throw new Error(`unsupported signature algorithm: ${algorithm}`);
}
export function buildReleaseSignature({
release,
privateKeyPem,
publicKeyId,
algorithm = DEFAULT_SIGNATURE_ALGORITHM,
signedAt = new Date().toISOString()
}) {
if (!supportedAlgorithms.has(algorithm)) {
throw new Error(`unsupported signature algorithm: ${algorithm}`);
}
const normalizedRelease = {
...release,
appId: required(release.appId, "appId"),
version: required(release.version, "version"),
packageUrl: required(release.packageUrl, "packageUrl"),
packageHash: normalizeHash(release.packageHash, "packageHash"),
manifestHash: normalizeHash(release.manifestHash, "manifestHash"),
signatureRef: required(release.signatureRef, "signatureRef")
};
const proofDraft = {
schemaVersion: SIGNATURE_PROOF_SCHEMA,
publicKeyId: required(publicKeyId, "publicKeyId"),
algorithm,
signature: "",
signedAt: required(signedAt, "signedAt")
};
if (!Number.isFinite(Date.parse(proofDraft.signedAt))) {
throw new Error("signedAt must be an ISO 8601 timestamp");
}
const payload = buildReleaseSignaturePayload(normalizedRelease, proofDraft);
const proof = {
...proofDraft,
payloadHash: sha256Digest(payload),
signature: signPayload(payload, privateKeyPem, algorithm)
};
const publicKey = createPublicKey(createPrivateKey(privateKeyPem)).export({
type: "spki",
format: "pem"
});
return {
release: normalizedRelease,
payload,
proof,
trustRoot: {
publicKeyId: proof.publicKeyId,
algorithm: proof.algorithm,
publicKey,
appIds: [normalizedRelease.appId]
}
};
}
function yamlString(value) {
return JSON.stringify(String(value));
}
export function buildAppSignatureYaml(signature) {
const { release, proof } = signature;
return [
"signature:",
" package:",
` schemaVersion: ${yamlString(proof.schemaVersion)}`,
` signatureRef: ${yamlString(release.signatureRef)}`,
` publicKeyId: ${yamlString(proof.publicKeyId)}`,
` algorithm: ${yamlString(proof.algorithm)}`,
` signature: ${yamlString(proof.signature)}`,
` payloadHash: ${yamlString(proof.payloadHash)}`,
` signedAt: ${yamlString(proof.signedAt)}`,
""
].join("\n");
}
async function readPrivateKey(args) {
if (args["private-key-file"]) {
return readFile(path.resolve(args["private-key-file"]), "utf8");
}
const envName = args["private-key-env"] ?? "AGENT_APP_SIGNING_PRIVATE_KEY_PEM";
const value = process.env[envName];
if (!value) {
throw new Error(`missing private key: set ${envName} or pass --private-key-file`);
}
return value.replace(/\\n/g, "\n");
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const rootDir = path.resolve(args.root ?? process.cwd());
const defaults = await readPackageDefaults(rootDir);
const release = {
appId: args["app-id"] ?? defaults.appId,
version: args.version ?? defaults.version,
releaseId: args["release-id"] ?? null,
tenantId: args["tenant-id"] ?? null,
tenantEnablementRef: args["tenant-enablement-ref"] ?? null,
channel: args.channel ?? "stable",
packageUrl: args["package-url"],
packageHash: args["package-hash"],
manifestHash: args["manifest-hash"],
signatureRef: args["signature-ref"]
};
const signature = buildReleaseSignature({
release,
privateKeyPem: await readPrivateKey(args),
publicKeyId: args["public-key-id"],
algorithm: args.algorithm ?? DEFAULT_SIGNATURE_ALGORITHM,
signedAt: args["signed-at"] ?? new Date().toISOString()
});
const appSignatureYaml = buildAppSignatureYaml(signature);
if (args.out) {
await writeFile(path.resolve(args.out), appSignatureYaml);
} else {
process.stdout.write(appSignatureYaml);
}
if (args["trust-root-out"]) {
await writeFile(
path.resolve(args["trust-root-out"]),
`${JSON.stringify(signature.trustRoot, null, 2)}\n`
);
}
if (args["payload-out"]) {
await writeFile(path.resolve(args["payload-out"]), `${signature.payload}\n`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
@@ -537,12 +537,13 @@ assert(
"articleDraft processMarkdown must include research and orchestration sections"
);
assert(
String(article.source?.documentText ?? "").includes("## 第一阶段:打牢基础") &&
String(article.source?.documentText ?? "").includes("## 第二阶段:用项目建立反馈") &&
String(article.source?.documentText ?? "").includes("人才选聘不能只看简历关键词") &&
String(article.source?.documentText ?? "").includes("## 用任务验证真实能力") &&
!String(article.source?.documentText ?? "").includes("## 待执行检索") &&
!String(article.source?.documentText ?? "").includes("## 编排步骤") &&
!String(article.source?.documentText ?? "").includes("从基础语法到工程实战") &&
!String(article.source?.documentText ?? "").includes("不要只生成一段话"),
"articleDraft documentText must include final article content without process sections"
"articleDraft documentText must come from host generation without process or template sections"
);
for (const locale of ["zh-CN", "zh-TW", "en-US", "ja-JP", "ko-KR"]) {
@@ -21,7 +21,7 @@ export const DEFAULT_WRITING_ORCHESTRATION = [
subagent: "article-writer",
skillRefs: ["article-writing"],
status: "completed",
summary: "生成文章草稿,并写入文章产物框与右侧文章编辑器。"
summary: "生成文章草稿,并写入 articleDraft 产物。"
},
{
id: "review",
@@ -53,27 +53,14 @@ function normalizeReferenceList(context) {
}
function buildTopicProfile(context) {
const rawTopic = `${context.topic} ${context.goal}`.toLowerCase();
const isGoLearning =
/golang|go语言|go 语言|学习 go|go 学习|learn go/.test(rawTopic) ||
/\bgo\b/.test(rawTopic);
if (isGoLearning) {
return {
subject: "Golang",
foundation: "语法、类型、函数、接口、错误处理、包管理和标准库",
practice: "命令行工具、HTTP 服务、数据库访问、测试用例和小型后台任务",
advanced: "goroutine、channel、context、性能分析、部署和工程规范",
pitfall: "只背语法和并发八股,却没有写过可运行、可测试、可维护的小项目",
exampleProject: "写一个带单元测试的 REST API,或者写一个可取消的任务队列 worker"
};
}
return {
subject: context.topic,
foundation: "核心概念、基本术语、常见场景和必要工具",
practice: "一个能独立完成的小项目、一组可复用模板和一次真实复盘",
advanced: "工程化方法、质量检查、性能边界和长期维护节奏",
pitfall: "只收集资料却不做输出,或者只追求大而全却没有形成反馈闭环",
exampleProject: "选择一个最小场景做出可交付版本,再根据反馈迭代"
readerQuestion: `为什么“${context.topic}”值得现在写清楚`,
coreFrame: "问题背景、关键判断、依据展开、行动建议和发布检查",
evidenceNeed: "用户材料、业务场景、公开资料和可验证案例",
deliveryFocus: "形成一篇结构清晰、可审稿、可继续迭代的正文",
pitfall: "直接堆观点或套模板,却没有说明读者对象、使用场景和判断依据",
nextAction: "先把目标读者、核心问题和可验证依据列成清单,再开始写正文"
};
}
@@ -191,10 +178,10 @@ export function buildCitations(context, researchRounds) {
export function buildKeyTakeaways(context) {
const profile = buildTopicProfile(context);
return [
`${profile.subject} 的学习要先建立清晰主线:知道为什么学、先学什么、用什么项目验证`,
`第一阶段先掌握${profile.foundation}不要一开始就钻进零散细节`,
`第二阶段用${profile.practice}建立反馈,把知识变成能运行的产物`,
`第三阶段补齐${profile.advanced},让能力从“会写示例”升级到“能做工程”`
`这篇文章要先回答:${profile.readerQuestion}`,
`正文结构应覆盖${profile.coreFrame}避免只堆素材`,
`每个关键判断都需要回到${profile.evidenceNeed}`,
`最终交付目标是${profile.deliveryFocus}`
];
}
@@ -203,20 +190,20 @@ export function buildTitleCandidates(context) {
return [
{
id: "title-1",
title: `${profile.subject} 学习路线:从基础语法到工程实战`,
angle: "学习路径",
title: `${profile.subject}:先把问题说清楚`,
angle: "问题定义",
score: 0.92
},
{
id: "title-2",
title: `学习${profile.subject},不要只停留在看懂示例`,
angle: "常见误区",
title: `${context.channel}文章,别急着套模板`,
angle: "写作方法",
score: 0.88
},
{
id: "title-3",
title: `${profile.subject} 入门到进阶:一条更稳的实践路径`,
angle: "行动建议",
title: `${profile.subject}的文章要先有判断,再有表达`,
angle: "内容判断",
score: 0.84
}
];
@@ -227,37 +214,37 @@ export function buildOutline(context, keyTakeaways) {
return [
{
id: "section-opening",
title: "开场:先把学习目标从看懂改成能交付",
purpose: `解释为什么学习${profile.subject}不能只停留在阅读教程`,
title: "开场:交代场景和读者问题",
purpose: `解释${profile.readerQuestion}`,
points: [keyTakeaways[0], `最大的误区是${profile.pitfall}`],
evidenceIds: ["citation-1"]
},
{
id: "section-research",
title: "基础:用最小知识集跑通第一批代码",
purpose: `${profile.foundation}收敛成第一阶段目标`,
points: [keyTakeaways[1], "每学一个概念,都要写出一段能运行的代码。"],
title: "判断:明确文章要成立的核心依据",
purpose: `${profile.evidenceNeed}收敛成可审稿依据`,
points: [keyTakeaways[1], "每个判断都要能回到用户材料、事实或可验证案例。"],
evidenceIds: ["citation-1", "citation-2"]
},
{
id: "section-strategy",
title: "实践:用项目把知识连成闭环",
purpose: `通过${profile.practice}建立真实反馈。`,
points: [keyTakeaways[2], `建议从“${profile.exampleProject}”开始。`],
title: "展开:把观点拆成可验证的段落",
purpose: "把核心观点拆成读者能顺着读下去的段落。",
points: [keyTakeaways[2], `建议从“${profile.nextAction}”开始。`],
evidenceIds: ["citation-2"]
},
{
id: "section-draft",
title: "进阶:补齐工程化能力",
purpose: `${profile.advanced}纳入长期学习。`,
points: [keyTakeaways[3], "进阶阶段要关注可观测、可测试和可维护。"],
title: "行动:给出读者下一步",
purpose: "把文章落到可执行建议,而不是只停留在观点。",
points: [keyTakeaways[3], "行动建议要具体到读者下一次可以做什么。"],
evidenceIds: ["citation-3"]
},
{
id: "section-delivery",
title: "复盘:把学习节奏长期化",
purpose: "用固定复盘保持持续进步,而不是靠一次冲刺。",
points: ["每周复盘一次代码、问题和下一步计划。", "把输出结果沉淀成可复用笔记和项目模板。"],
title: "检查:发布前核对事实和表达",
purpose: "确认正文结构、事实依据和语气都符合发布要求。",
points: ["核对标题是否夸大承诺。", "核对引用、案例和配图是否服务正文。"],
evidenceIds: ["citation-3"]
}
];
@@ -268,26 +255,26 @@ export function buildImageSlots(context, outline) {
return [
{
id: "image-slot-cover",
title: "学习路线封面图",
title: "主题封面图",
sectionId: "section-opening",
purpose: `展示${profile.subject}从入门到工程实战的主线`,
prompt: `${profile.subject} 学习路线图,包含基础、项目、工程化、复盘四段,中文标签清楚,适合${context.channel}封面`,
purpose: `展示${profile.subject}”的核心场景和读者问题`,
prompt: `${profile.subject} 主题封面图,包含场景、问题、判断、行动四个中文标签,适合${context.channel}封面`,
status: "planned"
},
{
id: "image-slot-research",
title: "项目实践图",
title: "观点结构图",
sectionId: "section-research",
purpose: "展示用项目把知识转成真实反馈。",
prompt: `${profile.subject} 项目实践示意图,代码、测试、运行日志和复盘清单从左到右排列,中文标签清楚,面向${context.audience}`,
purpose: "展示观点、依据和段落之间的关系。",
prompt: `${profile.subject} 观点结构图,问题、依据、段落、结论从左到右排列,中文标签清楚,面向${context.audience}`,
status: "planned"
},
{
id: "image-slot-outline",
title: "复盘清单图",
title: "发布检查图",
sectionId: outline[2]?.id ?? "section-strategy",
purpose: "展示每周如何检查学习进展。",
prompt: `${profile.subject} 学习复盘清单,包含已学概念、项目进展、问题记录、下一步计划,风格${context.tone}`,
purpose: "展示发布前如何检查事实、结构和表达。",
prompt: `${profile.subject} 发布检查清单,包含标题、事实、结构、配图、下一步五项,风格${context.tone}`,
status: "planned"
}
];
@@ -408,51 +395,43 @@ export function buildArticleMarkdown(context, parts) {
return [
`# ${chosenTitle}`,
"",
`很多人开始学${profile.subject}时,会先收集课程、语法清单和示例代码。但真正决定学习效果的,往往不是资料数量,而是你能不能把知识变成一个可运行、可测试、可复盘的成果`,
`很多文章看起来信息很满,但读者读完仍然不知道它到底解决什么问题。真正有效的${context.channel}文章,首先要把读者、场景和核心判断说清楚`,
"",
`如果目标读者是${context.audience},这篇文章最想解决的问题是:怎样用一条稳定路径学习${profile.subject},避免在教程、概念和零散代码之间来回打转`,
`如果目标读者是${context.audience},这篇文章最想解决的问题是:${profile.readerQuestion}`,
"",
"## 先把目标定清楚",
"## 先交代读者为什么要关心",
"",
`${keyTakeaways[0]}学习目标不要写成“我要学会全部语法”,而要写成“我能独立完成一个小功能,并解释它为什么这样设计”。目标越具体,学习路径越不容易变形`,
`${keyTakeaways[0]}开场不要急着给结论,先写清楚谁正在遇到什么问题,以及这个问题为什么现在需要被处理`,
"",
`${profile.subject}来说,一个更稳的目标是:先写出能运行的代码,再补语言细节;先完成小项目,再追求架构完整;先用测试保护改动,再讨论性能和工程规范`,
`${profile.subject}来说,一个更稳的写法是先说明场景,再说明判断,最后给出读者能带走的行动`,
"",
"## 第一阶段:打牢基础",
"## 把核心判断写成一句话",
"",
`第一阶段不用追求大而全,重点是掌握${profile.foundation}。这一阶段最有效的方式,是每学一个概念就写一段小代码:定义结构体、处理错误、组织包、写一个简单测试,再从运行结果里确认自己真的理解了`,
`${keyTakeaways[1]}核心判断越清楚,后面的段落越不容易散。它可以是一句明确观点,也可以是一条判断标准`,
"",
"如果遇到看不懂的语法,不要只停在搜索答案。更好的做法是把它拆成三个问题:它解决什么问题、它和我已经学过的概念有什么关系、我能不能写一个更小的例子复现它。",
"如果一句话说不清楚,就先不要扩写正文。先把想表达的判断拆成对象、问题、依据和建议,确认它能被读者理解。",
"",
"## 第二阶段:用项目建立反馈",
"## 用依据支撑每个段落",
"",
`第二阶段要进入${profile.practice}。这时不要再把学习成果停留在笔记里,而要做一个能被别人运行的东西。${profile.exampleProject},就是一个合适的起点`,
`${keyTakeaways[2]}依据不一定都来自外部检索,也可以来自用户材料、业务案例、历史讨论或可复盘的经验。关键是每个观点都要能被追问`,
"",
"项目不需要复杂,但必须完整。它至少要有清晰入口、错误处理、测试用例和一份能说明如何运行的 README。这样你会自然遇到依赖管理、目录组织、接口边界和调试方法,这些才是学习真正开始变扎实的地方。",
"段落之间也要有递进关系:先讲问题,再讲判断,再讲依据,最后给行动。这样读者不会被素材淹没。",
"",
"## 第三阶段:补齐工程化能力",
"## 给出下一步行动",
"",
`当基础和项目都跑通后,再进入${profile.advanced}。这个阶段不要把并发、性能或架构当成孤立知识点,它们都应该回到具体问题里:请求为什么变慢、任务为什么不能取消、错误为什么难定位、部署后怎么观察状态`,
`${keyTakeaways[3]}文章的最后不要只做情绪收束,也要告诉读者下一步可以怎么做`,
"",
`${profile.subject} 的进阶学习尤其需要克制。并发不是越多越好,抽象不是越早越好,性能优化也不是越复杂越好。先写出简单直接的实现,再用测试、日志和性能分析确认瓶颈,最后再决定是否引入更复杂的设计`,
`一个可执行的建议是:${profile.nextAction}。这能帮助正文从观点变成可落地的行动`,
"",
"## 最容易踩的坑",
"## 发布前再做一次核对",
"",
`最常见的坑是${profile.pitfall}它会让学习看起来很努力,但每隔一段时间又回到原点:教程看过很多,真正动手时还是不知道从哪里开始`,
`最常见的坑是${profile.pitfall}发布前要检查标题、事实、案例、语气和配图是否都服务同一个核心判断`,
"",
"解决这个问题的办法很朴素:每周只保留一到两个学习重点,每个重点必须对应一个输出。输出可以是一段代码、一个测试、一次性能对比、一篇复盘,或者一个小项目的新功能。没有输出,就说明这个知识点还没有真正进入你的能力范围。",
"",
"## 30 天行动建议",
"",
"前 7 天,集中补基础。每天写一个小例子,重点练习语法、类型、错误处理、包组织和测试。",
"",
"第 8 到 20 天,完成一个小项目。项目可以很小,但必须能运行、能测试、能说明设计取舍。",
"",
"第 21 到 30 天,做工程化补强。给项目加日志、配置、并发控制、性能观察和部署说明,同时复盘哪些地方写得别扭,哪些地方需要重构。",
"如果某一段删掉后并不影响读者理解,说明它可能只是填充内容;如果某个配图不能帮助读者理解结构,也应该重新规划。",
"",
"## 结尾",
"",
`学习${profile.subject}不是把所有知识一次性装进脑子里,而是持续把知识变成可交付成果。先打牢基础,再用项目获得反馈,最后补齐工程化能力。只要这条主线不丢,学习就会从“看过很多”变成“真的能做”`
`写好“${profile.subject}不是套一个固定模板,而是把读者问题、判断依据和行动建议连成一条清楚的线。只要这条线成立,正文就能继续审稿、编辑和发布`
]
.filter((line) => line !== null && line !== undefined)
.join("\n")
@@ -203,6 +203,23 @@ function generatedDocumentTextFromHost(hostManagedGeneration) {
return normalizeText(output?.content, "");
}
function markdownTitleFromDocumentText(documentText) {
const heading = normalizeText(documentText, "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => /^#\s+/.test(line));
return heading ? heading.replace(/^#\s+/, "").trim() : "";
}
function markdownExcerptFromDocumentText(documentText) {
return (
normalizeText(documentText, "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line && !line.startsWith("#")) ?? ""
);
}
function slugify(value) {
return normalizeText(value, "content")
.toLowerCase()
@@ -317,42 +334,49 @@ function normalizeHostWorkerRequest(input) {
) {
return { error: "WORKER_RUNTIME_CONTRACT_UNSUPPORTED" };
}
const normalizedRequest = {
taskKind,
sessionId: normalizeText(request.sessionId, DEFAULT_SESSION_ID),
workspaceId: normalizeText(request.workspaceId, ""),
turnId: normalizeText(request.turnId, DEFAULT_TURN_ID),
taskId: normalizeText(request.taskId, DEFAULT_TASK_ID),
topic: prompt,
goal: "生成一套可审核的首版内容资产",
references: normalizeList(request.sourceArtifactIds, [
"Claw 中间保持对话和审批",
"右侧文章编辑器展示产物",
"所有修改回流到 current turn"
]),
sourceObjectRef: asRecord(request.sourceObjectRef),
requestedAt: normalizeText(request.requestedAt, ""),
workflowKey: normalizeText(
request.workflowKey ?? request.pluginActivation?.workflow_key ?? request.pluginActivation?.workflowKey,
DEFAULT_ARTICLE_WORKFLOW_KEY
),
cliRefs: normalizeList(request.cliRefs, DEFAULT_CLI_REFS),
connectorRefs: normalizeList(request.connectorRefs, DEFAULT_CONNECTOR_REFS),
hookPolicy: normalizeHookPolicy(request.hookPolicy, DEFAULT_HOOK_POLICY),
subagents: normalizeList(
request.subagents,
readStringListFromRecords(request.pluginActivation?.subagents, "id")
),
skillRefs: normalizeList(
request.skillRefs,
readStringListFromRecords(request.pluginActivation?.skill_refs ?? request.pluginActivation?.skillRefs, "id")
),
orchestration: normalizeOrchestration(
request.orchestration ?? request.pluginActivation?.orchestration
),
hostManagedGeneration: hostManagedGenerationFromRequest(request)
};
if (
taskKind === "content.article.generate" &&
!generatedDocumentTextFromHost(normalizedRequest.hostManagedGeneration)
) {
return { error: "HOST_MANAGED_GENERATION_REQUIRED" };
}
return {
request: {
taskKind,
sessionId: normalizeText(request.sessionId, DEFAULT_SESSION_ID),
workspaceId: normalizeText(request.workspaceId, ""),
turnId: normalizeText(request.turnId, DEFAULT_TURN_ID),
taskId: normalizeText(request.taskId, DEFAULT_TASK_ID),
topic: prompt,
goal: "生成一套可审核的首版内容资产",
references: normalizeList(request.sourceArtifactIds, [
"Claw 中间保持对话和审批",
"右侧文章编辑器展示产物",
"所有修改回流到 current turn"
]),
sourceObjectRef: asRecord(request.sourceObjectRef),
requestedAt: normalizeText(request.requestedAt, ""),
workflowKey: normalizeText(
request.workflowKey ?? request.pluginActivation?.workflow_key ?? request.pluginActivation?.workflowKey,
DEFAULT_ARTICLE_WORKFLOW_KEY
),
cliRefs: normalizeList(request.cliRefs, DEFAULT_CLI_REFS),
connectorRefs: normalizeList(request.connectorRefs, DEFAULT_CONNECTOR_REFS),
hookPolicy: normalizeHookPolicy(request.hookPolicy, DEFAULT_HOOK_POLICY),
subagents: normalizeList(
request.subagents,
readStringListFromRecords(request.pluginActivation?.subagents, "id")
),
skillRefs: normalizeList(
request.skillRefs,
readStringListFromRecords(request.pluginActivation?.skill_refs ?? request.pluginActivation?.skillRefs, "id")
),
orchestration: normalizeOrchestration(
request.orchestration ?? request.pluginActivation?.orchestration
),
hostManagedGeneration: hostManagedGenerationFromRequest(request)
}
request: normalizedRequest
};
}
@@ -597,11 +621,16 @@ function buildArticleObject(context) {
const id = `article-${slugify(context.topic)}`;
const artifactIds = [`artifact-${id}`];
const planning = buildArticlePlanning(context);
const articleTitle = planning.titleCandidates[0]?.title ?? `${context.channel}文章草稿`;
const generatedDocumentText = generatedDocumentTextFromHost(
context.hostManagedGeneration
);
const articleTitle =
markdownTitleFromDocumentText(generatedDocumentText) ||
planning.titleCandidates[0]?.title ||
`${context.channel}文章草稿`;
const documentText = generatedDocumentText || planning.documentText;
const excerpt =
markdownExcerptFromDocumentText(documentText) || planning.keyTakeaways[0];
const generationMetadata = context.hostManagedGeneration
? {
status: context.hostManagedGeneration.status,
@@ -624,7 +653,7 @@ function buildArticleObject(context) {
}),
title: articleTitle,
status: "needs_review",
summary: `围绕“${planning.titleCandidates[0]?.title ?? context.topic}”生成首版${context.channel}正文,可继续编辑、审稿和发布。`,
summary: `围绕“${articleTitle || context.topic}”生成首版${context.channel}正文,可继续编辑、审稿和发布。`,
previewArtifactId: artifactIds[0],
source: {
...sourceBase({
@@ -636,7 +665,7 @@ function buildArticleObject(context) {
processMarkdown: planning.processMarkdown,
documentText,
finalMarkdown: documentText,
excerpt: planning.keyTakeaways[0],
excerpt,
researchRounds: planning.researchRounds,
titleCandidates: planning.titleCandidates,
outline: planning.outline,
@@ -7,6 +7,39 @@ import {
runContentFactoryTask
} from "../src/runtime/content-factory-worker.mjs";
const HOST_GENERATED_MARKDOWN = [
"# 宿主生成标题",
"",
"导语先说明为什么内容生产链路必须收敛。",
"",
"## 第一节",
"",
"这里是宿主托管生成的正文。"
].join("\n");
function withHostManagedGeneration(request) {
return {
...request,
hostManagedGeneration: {
schemaVersion: "lime.agent_app.host_managed_generation.v1",
source: "app_server_runtime_backend",
status: "completed",
provider: "test-provider",
model: "test-model",
outputs: [
{
id: "article-draft-document",
kind: "markdown_document",
targetObjectKind: "articleDraft",
outputField: "documentText",
contentType: "text/markdown",
content: HOST_GENERATED_MARKDOWN
}
]
}
};
}
test("content.factory.generate outputs a full Article Workspace patch", () => {
const result = runContentFactoryTask({
taskKind: "content.factory.generate",
@@ -57,7 +90,7 @@ test("content.factory.generate outputs a full Article Workspace patch", () => {
});
test("host worker request returns artifact snapshot response", () => {
const response = handleContentFactoryWorkerRequest({
const response = handleContentFactoryWorkerRequest(withHostManagedGeneration({
schemaVersion: "content-factory.worker-request.v1",
appId: "content-factory-app",
sessionId: "session-host-001",
@@ -75,7 +108,7 @@ test("host worker request returns artifact snapshot response", () => {
directFilesystemAccess: false
},
requestedAt: "2026-06-28T00:00:00.000Z"
});
}));
assert.equal(response.schemaVersion, "content-factory.worker-response.v1");
assert.equal(response.status, "completed");
@@ -95,14 +128,9 @@ test("host worker request returns artifact snapshot response", () => {
const article = patch.objects.find((object) => object.ref.kind === "articleDraft");
assert.ok(article);
assert.equal(Object.hasOwn(article.source, "markdown"), false);
assert.match(
article.source.documentText ?? "",
/Golang 学习路线/
);
assert.match(
article.source.finalMarkdown ?? "",
/Golang 学习路线/
);
assert.equal(article.title, "宿主生成标题");
assert.equal(article.source.documentText, HOST_GENERATED_MARKDOWN);
assert.equal(article.source.finalMarkdown, HOST_GENERATED_MARKDOWN);
assert.match(
article.source.processMarkdown ?? "",
/待执行检索/
@@ -119,9 +147,10 @@ test("host worker request returns artifact snapshot response", () => {
assert.ok(article?.source.writingPlan.length >= 5);
assert.match(article?.source.processMarkdown ?? "", /## 检索轮次/);
assert.match(article?.source.processMarkdown ?? "", /## 编排步骤/);
assert.match(article?.source.documentText ?? "", /## 第一阶段:打牢基础/);
assert.match(article?.source.documentText ?? "", /## 第阶段:用项目建立反馈/);
assert.match(article?.source.documentText ?? "", /goroutine/);
assert.match(article?.source.documentText ?? "", /这里是宿主托管生成的正文/);
assert.doesNotMatch(article?.source.documentText ?? "", /## 第阶段:打牢基础/);
assert.doesNotMatch(article?.source.documentText ?? "", /## 第二阶段:用项目建立反馈/);
assert.doesNotMatch(article?.source.documentText ?? "", /goroutine/);
assert.doesNotMatch(article?.source.documentText ?? "", /## 待执行检索/);
assert.doesNotMatch(article?.source.documentText ?? "", /## 编排步骤/);
assert.doesNotMatch(article?.source.documentText ?? "", /不要只生成一段话/);
@@ -134,7 +163,7 @@ test("host worker request returns artifact snapshot response", () => {
});
test("host worker request emits paragraph-level artifact progress events", () => {
const request = {
const request = withHostManagedGeneration({
schemaVersion: "content-factory.worker-request.v1",
appId: "content-factory-app",
sessionId: "session-host-001",
@@ -152,7 +181,7 @@ test("host worker request emits paragraph-level artifact progress events", () =>
directFilesystemAccess: false
},
requestedAt: "2026-06-28T00:00:00.000Z"
};
});
const progressEvents = buildContentFactoryWorkerProgressEvents(request);
const response = handleContentFactoryWorkerRequest(request);
@@ -204,7 +233,7 @@ test("host worker request emits paragraph-level artifact progress events", () =>
artifactProgressEvents[0].payload.artifact.metadata.contentFactoryWorkspacePatch.objects.find(
(object) => object.ref.kind === "articleDraft"
).source.documentText,
/Golang 学习路线/
/# 宿主生成标题/
);
});
@@ -239,7 +268,7 @@ test("host managed generation output overrides deterministic article draft text"
targetObjectKind: "articleDraft",
outputField: "documentText",
contentType: "text/markdown",
content: "# 宿主生成标题\n\n导语先说明为什么内容生产链路必须收敛。\n\n## 第一节\n\n这里是宿主托管生成的正文。"
content: HOST_GENERATED_MARKDOWN
}
]
},
@@ -251,8 +280,9 @@ test("host managed generation output overrides deterministic article draft text"
assert.equal(
article?.source.documentText,
"# 宿主生成标题\n\n导语先说明为什么内容生产链路必须收敛。\n\n## 第一节\n\n这里是宿主托管生成的正文。"
HOST_GENERATED_MARKDOWN
);
assert.equal(article?.title, "宿主生成标题");
assert.equal(article?.source.finalMarkdown, article?.source.documentText);
assert.equal(article?.source.hostManagedGeneration?.status, "completed");
assert.equal(article?.source.hostManagedGeneration?.provider, "test-provider");
@@ -262,8 +292,8 @@ test("host managed generation output overrides deterministic article draft text"
]);
});
test("missing host managed generation result falls back to unavailable status", () => {
const response = handleContentFactoryWorkerRequest({
test("missing host managed generation result fails closed without template article", () => {
const request = {
schemaVersion: "content-factory.worker-request.v1",
appId: "content-factory-app",
sessionId: "session-host-managed-missing-001",
@@ -291,18 +321,16 @@ test("missing host managed generation result falls back to unavailable status",
}
},
requestedAt: "2026-06-28T00:00:00.000Z"
};
const progressEvents = buildContentFactoryWorkerProgressEvents(request);
const response = handleContentFactoryWorkerRequest({
...request
});
const patch = response.artifacts[0].metadata.contentFactoryWorkspacePatch;
const article = patch.objects.find((object) => object.ref.kind === "articleDraft");
assert.equal(article?.source.hostManagedGeneration?.status, "unavailable");
assert.equal(
article?.source.hostManagedGeneration?.reasonCode,
"host_generation_unavailable"
);
assert.deepEqual(article?.source.hostManagedGeneration?.outputIds, []);
assert.match(article?.source.documentText ?? "", /内容工厂插件化写文章/);
assert.deepEqual(progressEvents, []);
assert.equal(response.status, "failed");
assert.equal(response.error?.code, "HOST_MANAGED_GENERATION_REQUIRED");
assert.deepEqual(response.artifacts, []);
});
test("content.image.generate selects image grid as the primary surface", () => {
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, verify, constants } from "node:crypto";
import { test } from "node:test";
import {
SIGNATURE_PROOF_SCHEMA,
buildAppSignatureYaml,
buildReleaseSignature,
buildReleaseSignaturePayload,
sha256Digest
} from "../scripts/sign-release.mjs";
function buildPrivateKeyPem() {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" }
});
return privateKey;
}
const release = {
appId: "content-factory-app",
version: "2.2.2",
releaseId: "agent-app-release-test",
tenantId: "tenant-0001",
tenantEnablementRef: "tenant-agent-app-test",
channel: "stable",
packageUrl: "https://updates.limeai.run/agent-apps/content-factory-app.lapp",
packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
signatureRef: "sigstore:content-factory-app@2.2.2"
};
test("sign-release builds a host-verifiable canonical proof", () => {
const signature = buildReleaseSignature({
release,
privateKeyPem: buildPrivateKeyPem(),
publicKeyId: "agent-app-root-2026",
signedAt: "2026-07-03T00:00:00.000Z"
});
const expectedPayload = buildReleaseSignaturePayload(release, {
schemaVersion: SIGNATURE_PROOF_SCHEMA,
publicKeyId: "agent-app-root-2026",
algorithm: "RSASSA-PKCS1-v1_5-SHA256",
signature: "",
signedAt: "2026-07-03T00:00:00.000Z"
});
assert.equal(signature.payload, expectedPayload);
assert.equal(signature.proof.payloadHash, sha256Digest(expectedPayload));
assert.equal(signature.trustRoot.publicKeyId, "agent-app-root-2026");
assert.deepEqual(signature.trustRoot.appIds, ["content-factory-app"]);
assert.equal(
verify(
"sha256",
Buffer.from(expectedPayload),
{
key: signature.trustRoot.publicKey,
padding: constants.RSA_PKCS1_PADDING
},
Buffer.from(signature.proof.signature, "base64")
),
true
);
});
test("sign-release writes app.signature.yaml fields LimeCore can infer", () => {
const signature = buildReleaseSignature({
release,
privateKeyPem: buildPrivateKeyPem(),
publicKeyId: "agent-app-root-2026",
signedAt: "2026-07-03T00:00:00.000Z"
});
const yaml = buildAppSignatureYaml(signature);
assert.match(yaml, /^signature:\n package:\n/);
assert.match(yaml, /signatureRef: "sigstore:content-factory-app@2\.2\.2"/);
assert.match(yaml, /publicKeyId: "agent-app-root-2026"/);
assert.match(yaml, /algorithm: "RSASSA-PKCS1-v1_5-SHA256"/);
assert.match(yaml, /payloadHash: "sha256:[a-f0-9]{64}"/);
});
@@ -5,16 +5,16 @@
"apps": [
{
"appId": "content-factory-app",
"version": "2.0.0",
"version": "2.2.2",
"manifestPath": "./content-factory-app.json",
"packageRoot": "./package-root",
"packageUrl": "https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
"packageSourceUri": "seeded:content-factory-app@2.0.0",
"releaseId": "seeded-content-factory-app-2.0.0",
"packageUrl": "https://seeded.local/agent-apps/content-factory-app/2.2.2.lapp",
"packageSourceUri": "seeded:content-factory-app@2.2.2",
"releaseId": "seeded-content-factory-app-2.2.2",
"channel": "seeded",
"runtimeTargets": ["local"],
"packageHash": "package-fnv1a-a4d0e86f",
"manifestHash": "manifest-fnv1a-b840e5c9",
"packageHash": "sha256:2b842c1401459db69e399002eeb2b7ead451e1fe3b8bab192c6a1ca7aa20d354",
"manifestHash": "sha256:6df5c193ac89ff4400006a45d8340029446529d9b5953c7c5d03d4349f9dbfc3",
"workerEntrypoint": "./src/runtime/content-factory-worker.mjs"
}
]
@@ -21,7 +21,7 @@ export const DEFAULT_WRITING_ORCHESTRATION = [
subagent: "article-writer",
skillRefs: ["article-writing"],
status: "completed",
summary: "生成文章草稿,并写入文章产物框与右侧文章编辑器。"
summary: "生成文章草稿,并写入 articleDraft 产物。"
},
{
id: "review",
@@ -53,27 +53,14 @@ function normalizeReferenceList(context) {
}
function buildTopicProfile(context) {
const rawTopic = `${context.topic} ${context.goal}`.toLowerCase();
const isGoLearning =
/golang|go语言|go 语言|学习 go|go 学习|learn go/.test(rawTopic) ||
/\bgo\b/.test(rawTopic);
if (isGoLearning) {
return {
subject: "Golang",
foundation: "语法、类型、函数、接口、错误处理、包管理和标准库",
practice: "命令行工具、HTTP 服务、数据库访问、测试用例和小型后台任务",
advanced: "goroutine、channel、context、性能分析、部署和工程规范",
pitfall: "只背语法和并发八股,却没有写过可运行、可测试、可维护的小项目",
exampleProject: "写一个带单元测试的 REST API,或者写一个可取消的任务队列 worker"
};
}
return {
subject: context.topic,
foundation: "核心概念、基本术语、常见场景和必要工具",
practice: "一个能独立完成的小项目、一组可复用模板和一次真实复盘",
advanced: "工程化方法、质量检查、性能边界和长期维护节奏",
pitfall: "只收集资料却不做输出,或者只追求大而全却没有形成反馈闭环",
exampleProject: "选择一个最小场景做出可交付版本,再根据反馈迭代"
readerQuestion: `为什么“${context.topic}”值得现在写清楚`,
coreFrame: "问题背景、关键判断、依据展开、行动建议和发布检查",
evidenceNeed: "用户材料、业务场景、公开资料和可验证案例",
deliveryFocus: "形成一篇结构清晰、可审稿、可继续迭代的正文",
pitfall: "直接堆观点或套模板,却没有说明读者对象、使用场景和判断依据",
nextAction: "先把目标读者、核心问题和可验证依据列成清单,再开始写正文"
};
}
@@ -191,10 +178,10 @@ export function buildCitations(context, researchRounds) {
export function buildKeyTakeaways(context) {
const profile = buildTopicProfile(context);
return [
`${profile.subject} 的学习要先建立清晰主线:知道为什么学、先学什么、用什么项目验证`,
`第一阶段先掌握${profile.foundation}不要一开始就钻进零散细节`,
`第二阶段用${profile.practice}建立反馈,把知识变成能运行的产物`,
`第三阶段补齐${profile.advanced},让能力从“会写示例”升级到“能做工程”`
`这篇文章要先回答:${profile.readerQuestion}`,
`正文结构应覆盖${profile.coreFrame}避免只堆素材`,
`每个关键判断都需要回到${profile.evidenceNeed}`,
`最终交付目标是${profile.deliveryFocus}`
];
}
@@ -203,20 +190,20 @@ export function buildTitleCandidates(context) {
return [
{
id: "title-1",
title: `${profile.subject} 学习路线:从基础语法到工程实战`,
angle: "学习路径",
title: `${profile.subject}:先把问题说清楚`,
angle: "问题定义",
score: 0.92
},
{
id: "title-2",
title: `学习${profile.subject},不要只停留在看懂示例`,
angle: "常见误区",
title: `${context.channel}文章,别急着套模板`,
angle: "写作方法",
score: 0.88
},
{
id: "title-3",
title: `${profile.subject} 入门到进阶:一条更稳的实践路径`,
angle: "行动建议",
title: `${profile.subject}的文章要先有判断,再有表达`,
angle: "内容判断",
score: 0.84
}
];
@@ -227,37 +214,37 @@ export function buildOutline(context, keyTakeaways) {
return [
{
id: "section-opening",
title: "开场:先把学习目标从看懂改成能交付",
purpose: `解释为什么学习${profile.subject}不能只停留在阅读教程`,
title: "开场:交代场景和读者问题",
purpose: `解释${profile.readerQuestion}`,
points: [keyTakeaways[0], `最大的误区是${profile.pitfall}`],
evidenceIds: ["citation-1"]
},
{
id: "section-research",
title: "基础:用最小知识集跑通第一批代码",
purpose: `${profile.foundation}收敛成第一阶段目标`,
points: [keyTakeaways[1], "每学一个概念,都要写出一段能运行的代码。"],
title: "判断:明确文章要成立的核心依据",
purpose: `${profile.evidenceNeed}收敛成可审稿依据`,
points: [keyTakeaways[1], "每个判断都要能回到用户材料、事实或可验证案例。"],
evidenceIds: ["citation-1", "citation-2"]
},
{
id: "section-strategy",
title: "实践:用项目把知识连成闭环",
purpose: `通过${profile.practice}建立真实反馈。`,
points: [keyTakeaways[2], `建议从“${profile.exampleProject}”开始。`],
title: "展开:把观点拆成可验证的段落",
purpose: "把核心观点拆成读者能顺着读下去的段落。",
points: [keyTakeaways[2], `建议从“${profile.nextAction}”开始。`],
evidenceIds: ["citation-2"]
},
{
id: "section-draft",
title: "进阶:补齐工程化能力",
purpose: `${profile.advanced}纳入长期学习。`,
points: [keyTakeaways[3], "进阶阶段要关注可观测、可测试和可维护。"],
title: "行动:给出读者下一步",
purpose: "把文章落到可执行建议,而不是只停留在观点。",
points: [keyTakeaways[3], "行动建议要具体到读者下一次可以做什么。"],
evidenceIds: ["citation-3"]
},
{
id: "section-delivery",
title: "复盘:把学习节奏长期化",
purpose: "用固定复盘保持持续进步,而不是靠一次冲刺。",
points: ["每周复盘一次代码、问题和下一步计划。", "把输出结果沉淀成可复用笔记和项目模板。"],
title: "检查:发布前核对事实和表达",
purpose: "确认正文结构、事实依据和语气都符合发布要求。",
points: ["核对标题是否夸大承诺。", "核对引用、案例和配图是否服务正文。"],
evidenceIds: ["citation-3"]
}
];
@@ -268,26 +255,26 @@ export function buildImageSlots(context, outline) {
return [
{
id: "image-slot-cover",
title: "学习路线封面图",
title: "主题封面图",
sectionId: "section-opening",
purpose: `展示${profile.subject}从入门到工程实战的主线`,
prompt: `${profile.subject} 学习路线图,包含基础、项目、工程化、复盘四段,中文标签清楚,适合${context.channel}封面`,
purpose: `展示${profile.subject}”的核心场景和读者问题`,
prompt: `${profile.subject} 主题封面图,包含场景、问题、判断、行动四个中文标签,适合${context.channel}封面`,
status: "planned"
},
{
id: "image-slot-research",
title: "项目实践图",
title: "观点结构图",
sectionId: "section-research",
purpose: "展示用项目把知识转成真实反馈。",
prompt: `${profile.subject} 项目实践示意图,代码、测试、运行日志和复盘清单从左到右排列,中文标签清楚,面向${context.audience}`,
purpose: "展示观点、依据和段落之间的关系。",
prompt: `${profile.subject} 观点结构图,问题、依据、段落、结论从左到右排列,中文标签清楚,面向${context.audience}`,
status: "planned"
},
{
id: "image-slot-outline",
title: "复盘清单图",
title: "发布检查图",
sectionId: outline[2]?.id ?? "section-strategy",
purpose: "展示每周如何检查学习进展。",
prompt: `${profile.subject} 学习复盘清单,包含已学概念、项目进展、问题记录、下一步计划,风格${context.tone}`,
purpose: "展示发布前如何检查事实、结构和表达。",
prompt: `${profile.subject} 发布检查清单,包含标题、事实、结构、配图、下一步五项,风格${context.tone}`,
status: "planned"
}
];
@@ -408,51 +395,43 @@ export function buildArticleMarkdown(context, parts) {
return [
`# ${chosenTitle}`,
"",
`很多人开始学${profile.subject}时,会先收集课程、语法清单和示例代码。但真正决定学习效果的,往往不是资料数量,而是你能不能把知识变成一个可运行、可测试、可复盘的成果`,
`很多文章看起来信息很满,但读者读完仍然不知道它到底解决什么问题。真正有效的${context.channel}文章,首先要把读者、场景和核心判断说清楚`,
"",
`如果目标读者是${context.audience},这篇文章最想解决的问题是:怎样用一条稳定路径学习${profile.subject},避免在教程、概念和零散代码之间来回打转`,
`如果目标读者是${context.audience},这篇文章最想解决的问题是:${profile.readerQuestion}`,
"",
"## 先把目标定清楚",
"## 先交代读者为什么要关心",
"",
`${keyTakeaways[0]}学习目标不要写成“我要学会全部语法”,而要写成“我能独立完成一个小功能,并解释它为什么这样设计”。目标越具体,学习路径越不容易变形`,
`${keyTakeaways[0]}开场不要急着给结论,先写清楚谁正在遇到什么问题,以及这个问题为什么现在需要被处理`,
"",
`${profile.subject}来说,一个更稳的目标是:先写出能运行的代码,再补语言细节;先完成小项目,再追求架构完整;先用测试保护改动,再讨论性能和工程规范`,
`${profile.subject}来说,一个更稳的写法是先说明场景,再说明判断,最后给出读者能带走的行动`,
"",
"## 第一阶段:打牢基础",
"## 把核心判断写成一句话",
"",
`第一阶段不用追求大而全,重点是掌握${profile.foundation}。这一阶段最有效的方式,是每学一个概念就写一段小代码:定义结构体、处理错误、组织包、写一个简单测试,再从运行结果里确认自己真的理解了`,
`${keyTakeaways[1]}核心判断越清楚,后面的段落越不容易散。它可以是一句明确观点,也可以是一条判断标准`,
"",
"如果遇到看不懂的语法,不要只停在搜索答案。更好的做法是把它拆成三个问题:它解决什么问题、它和我已经学过的概念有什么关系、我能不能写一个更小的例子复现它。",
"如果一句话说不清楚,就先不要扩写正文。先把想表达的判断拆成对象、问题、依据和建议,确认它能被读者理解。",
"",
"## 第二阶段:用项目建立反馈",
"## 用依据支撑每个段落",
"",
`第二阶段要进入${profile.practice}。这时不要再把学习成果停留在笔记里,而要做一个能被别人运行的东西。${profile.exampleProject},就是一个合适的起点`,
`${keyTakeaways[2]}依据不一定都来自外部检索,也可以来自用户材料、业务案例、历史讨论或可复盘的经验。关键是每个观点都要能被追问`,
"",
"项目不需要复杂,但必须完整。它至少要有清晰入口、错误处理、测试用例和一份能说明如何运行的 README。这样你会自然遇到依赖管理、目录组织、接口边界和调试方法,这些才是学习真正开始变扎实的地方。",
"段落之间也要有递进关系:先讲问题,再讲判断,再讲依据,最后给行动。这样读者不会被素材淹没。",
"",
"## 第三阶段:补齐工程化能力",
"## 给出下一步行动",
"",
`当基础和项目都跑通后,再进入${profile.advanced}。这个阶段不要把并发、性能或架构当成孤立知识点,它们都应该回到具体问题里:请求为什么变慢、任务为什么不能取消、错误为什么难定位、部署后怎么观察状态`,
`${keyTakeaways[3]}文章的最后不要只做情绪收束,也要告诉读者下一步可以怎么做`,
"",
`${profile.subject} 的进阶学习尤其需要克制。并发不是越多越好,抽象不是越早越好,性能优化也不是越复杂越好。先写出简单直接的实现,再用测试、日志和性能分析确认瓶颈,最后再决定是否引入更复杂的设计`,
`一个可执行的建议是:${profile.nextAction}。这能帮助正文从观点变成可落地的行动`,
"",
"## 最容易踩的坑",
"## 发布前再做一次核对",
"",
`最常见的坑是${profile.pitfall}它会让学习看起来很努力,但每隔一段时间又回到原点:教程看过很多,真正动手时还是不知道从哪里开始`,
`最常见的坑是${profile.pitfall}发布前要检查标题、事实、案例、语气和配图是否都服务同一个核心判断`,
"",
"解决这个问题的办法很朴素:每周只保留一到两个学习重点,每个重点必须对应一个输出。输出可以是一段代码、一个测试、一次性能对比、一篇复盘,或者一个小项目的新功能。没有输出,就说明这个知识点还没有真正进入你的能力范围。",
"",
"## 30 天行动建议",
"",
"前 7 天,集中补基础。每天写一个小例子,重点练习语法、类型、错误处理、包组织和测试。",
"",
"第 8 到 20 天,完成一个小项目。项目可以很小,但必须能运行、能测试、能说明设计取舍。",
"",
"第 21 到 30 天,做工程化补强。给项目加日志、配置、并发控制、性能观察和部署说明,同时复盘哪些地方写得别扭,哪些地方需要重构。",
"如果某一段删掉后并不影响读者理解,说明它可能只是填充内容;如果某个配图不能帮助读者理解结构,也应该重新规划。",
"",
"## 结尾",
"",
`学习${profile.subject}不是把所有知识一次性装进脑子里,而是持续把知识变成可交付成果。先打牢基础,再用项目获得反馈,最后补齐工程化能力。只要这条主线不丢,学习就会从“看过很多”变成“真的能做”`
`写好“${profile.subject}不是套一个固定模板,而是把读者问题、判断依据和行动建议连成一条清楚的线。只要这条线成立,正文就能继续审稿、编辑和发布`
]
.filter((line) => line !== null && line !== undefined)
.join("\n")
@@ -1,4 +1,4 @@
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -19,7 +19,19 @@ import {
} from "./index";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const fixtureRoot = resolve(repoRoot, "src/features/agent-app/testing/fixtures");
const fixtureRoot = resolve(
repoRoot,
"src/features/agent-app/testing/fixtures",
);
const HOST_GENERATED_MARKDOWN = [
"# 宿主生成标题",
"",
"导语先说明为什么文章正文必须来自宿主托管生成。",
"",
"## 第一节",
"",
"这里是宿主托管生成的正文。",
].join("\n");
function buildFixtureWorkerRuntimeContract() {
return buildContentFactoryWorkerRuntimeContract({
@@ -53,6 +65,31 @@ function parseWorkerStdout(stdout: string): {
};
}
function withHostManagedGeneration<T extends Record<string, unknown>>(
request: T,
): T & { hostManagedGeneration: Record<string, unknown> } {
return {
...request,
hostManagedGeneration: {
schemaVersion: "lime.agent_app.host_managed_generation.v1",
source: "test-host-generation",
status: "completed",
provider: "test-provider",
model: "test-model",
outputs: [
{
id: "article-draft-document",
kind: "markdown_document",
targetObjectKind: "articleDraft",
outputField: "documentText",
contentType: "text/markdown",
content: HOST_GENERATED_MARKDOWN,
},
],
},
};
}
describe("contentFactoryWorkerContract", () => {
it("应从内容工厂 manifest 投影 worker runtime 契约", () => {
const contract = buildFixtureWorkerRuntimeContract();
@@ -112,11 +149,7 @@ describe("contentFactoryWorkerContract", () => {
"article-image-plan",
],
cliRefs: ["content-factory"],
connectorRefs: [
"lime-knowledge",
"web-research",
"media-generation",
],
connectorRefs: ["lime-knowledge", "web-research", "media-generation"],
hookPolicy: {
prompt: ["prompt-submit"],
task: ["task-complete"],
@@ -191,7 +224,8 @@ describe("contentFactoryWorkerContract", () => {
agentRuntime: {
worker: {
entrypoint: contentFactoryFixture.runtimePackage.worker.entrypoint,
sampleRequest: contentFactoryFixture.runtimePackage.worker.sampleRequest,
sampleRequest:
contentFactoryFixture.runtimePackage.worker.sampleRequest,
directProviderAccess: false,
directFilesystemAccess: false,
outputArtifactKind: CONTENT_FACTORY_WORKSPACE_PATCH_KIND,
@@ -217,7 +251,7 @@ describe("contentFactoryWorkerContract", () => {
turnId: "turn-demo-runtime-001",
taskId: "task-demo-runtime-001",
taskKind: "content.article.generate",
prompt: expect.stringContaining("Golang 学习路线"),
prompt: expect.stringContaining("人才选聘"),
expectedOutput: {
artifactKind: CONTENT_FACTORY_WORKSPACE_PATCH_KIND,
},
@@ -291,12 +325,12 @@ describe("contentFactoryWorkerContract", () => {
(object) => object.ref.kind === "articleDraft",
);
expect(article).toMatchObject({
title: expect.stringContaining("学习路线"),
title: "人才选聘不能只看简历关键词",
summary: expect.not.stringContaining("轮资料检索"),
source: {
processMarkdown: expect.stringContaining("## 检索轮次"),
documentText: expect.stringContaining("## 第一阶段:打牢基础"),
finalMarkdown: expect.stringContaining("## 第一阶段:打牢基础"),
documentText: expect.stringContaining("## 先定义岗位要解决的问题"),
finalMarkdown: expect.stringContaining("## 先定义岗位要解决的问题"),
researchRounds: expect.arrayContaining([
expect.objectContaining({
title: "主题和用户目标检索",
@@ -304,17 +338,17 @@ describe("contentFactoryWorkerContract", () => {
]),
titleCandidates: expect.arrayContaining([
expect.objectContaining({
title: expect.stringContaining("学习路线"),
title: expect.stringContaining("先把问题说清楚"),
}),
]),
outline: expect.arrayContaining([
expect.objectContaining({
title: "实践:用项目把知识连成闭环",
title: "展开:把观点拆成可验证的段落",
}),
]),
imageSlots: expect.arrayContaining([
expect.objectContaining({
prompt: expect.stringContaining("学习路线图"),
prompt: expect.stringContaining("主题封面图"),
}),
]),
searchRequests: expect.arrayContaining([
@@ -348,6 +382,11 @@ describe("contentFactoryWorkerContract", () => {
skillRef: "article-writing",
}),
]),
hostManagedGeneration: expect.objectContaining({
status: "completed",
provider: "sample-provider",
outputIds: ["article-draft-document"],
}),
reviewNotes: expect.arrayContaining([
expect.stringContaining("确认标题是否符合真实表达"),
]),
@@ -360,10 +399,22 @@ describe("contentFactoryWorkerContract", () => {
"## 待执行检索",
);
expect(String(article?.source?.documentText ?? "")).toContain(
"## 第一阶段:打牢基础",
"## 用任务验证真实能力",
);
expect(String(article?.source?.documentText ?? "")).toContain(
"## 第二阶段:用项目建立反馈",
"## 保留复盘证据",
);
expect(String(article?.source?.documentText ?? "")).not.toContain(
"学习路线:从基础语法到工程实战",
);
expect(String(article?.source?.documentText ?? "")).not.toContain(
"## 第一阶段:打牢基础",
);
expect(String(article?.source?.processMarkdown ?? "")).not.toContain(
"学习路线:从基础语法到工程实战",
);
expect(String(article?.source?.processMarkdown ?? "")).not.toContain(
"## 第一阶段:打牢基础",
);
expect(String(article?.source?.documentText ?? "")).not.toContain(
"不要只生成一段话",
@@ -379,7 +430,7 @@ describe("contentFactoryWorkerContract", () => {
);
});
it("写文章 worker 请求应输出完整文章结构", () => {
it("写文章 worker 请求必须使用宿主托管生成结果", () => {
const contract = buildFixtureWorkerRuntimeContract();
const workerEntrypointPath = resolveFixturePath(contract.workerEntrypoint);
const request = buildContentFactoryWorkerRequest({
@@ -388,13 +439,16 @@ describe("contentFactoryWorkerContract", () => {
turnId: "turn-article-1",
taskId: "task-article-1",
taskKind: "content.article.generate",
prompt: "写一篇关于 golang 学习的公众号文章",
prompt: "写一篇关于人才选聘的公众号文章",
requestedAt: "2026-06-28T00:00:00.000Z",
runtimeContract: contract,
});
expect(request).not.toBeNull();
const { events, response } = parseWorkerStdout(
execFileSync("node", [workerEntrypointPath], {
input: JSON.stringify(request),
input: JSON.stringify(
withHostManagedGeneration(request as Record<string, unknown>),
),
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
}),
@@ -479,17 +533,17 @@ describe("contentFactoryWorkerContract", () => {
expect.stringContaining("# "),
);
expect(article?.source?.documentText).toEqual(
expect.stringContaining("Golang 学习路线"),
expect.stringContaining("宿主生成标题"),
);
expect(article?.source?.documentText).toEqual(
expect.stringContaining("这里是宿主托管生成的正文"),
);
expect(article?.source?.documentText).not.toEqual(
expect.stringContaining("学习路线:从基础语法到工程实战"),
);
expect(article?.source?.documentText).not.toEqual(
expect.stringContaining("## 第一阶段:打牢基础"),
);
expect(article?.source?.documentText).toEqual(
expect.stringContaining("## 第二阶段:用项目建立反馈"),
);
expect(article?.source?.documentText).toEqual(
expect.stringContaining("## 第三阶段:补齐工程化能力"),
);
expect(article?.source?.documentText).not.toEqual(
expect.stringContaining("不要只生成一段话"),
);
@@ -511,21 +565,21 @@ describe("contentFactoryWorkerContract", () => {
]),
titleCandidates: expect.arrayContaining([
expect.objectContaining({
title: expect.stringContaining("学习路线"),
title: expect.stringContaining("先把问题说清楚"),
}),
]),
outline: expect.arrayContaining([
expect.objectContaining({
title: "实践:用项目把知识连成闭环",
title: "展开:把观点拆成可验证的段落",
}),
]),
keyTakeaways: expect.arrayContaining([
expect.stringContaining("学习要先建立清晰主线"),
expect.stringContaining("这篇文章要先回答"),
]),
imageSlots: expect.arrayContaining([
expect.objectContaining({
title: "学习路线封面图",
prompt: expect.stringContaining("学习路线图"),
title: "主题封面图",
prompt: expect.stringContaining("主题封面图"),
}),
]),
searchRequests: expect.arrayContaining([
@@ -559,12 +613,52 @@ describe("contentFactoryWorkerContract", () => {
done: true,
}),
]),
hostManagedGeneration: expect.objectContaining({
status: "completed",
provider: "test-provider",
outputIds: ["article-draft-document"],
}),
reviewNotes: expect.arrayContaining([
expect.stringContaining("确认标题是否符合真实表达"),
]),
});
});
it("写文章 worker 请求缺少宿主托管生成结果时应 fail closed", () => {
const contract = buildFixtureWorkerRuntimeContract();
const workerEntrypointPath = resolveFixturePath(contract.workerEntrypoint);
const request = buildContentFactoryWorkerRequest({
sessionId: "session-content-factory-article",
workspaceId: "workspace-main",
turnId: "turn-article-1",
taskId: "task-article-1",
taskKind: "content.article.generate",
prompt: "写一篇关于人才选聘的公众号文章",
requestedAt: "2026-06-28T00:00:00.000Z",
runtimeContract: contract,
});
expect(request).not.toBeNull();
const result = spawnSync("node", [workerEntrypointPath], {
input: JSON.stringify(request),
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
expect(result.status).toBe(1);
const { events, response } = parseWorkerStdout(result.stdout);
expect(events).toEqual([]);
expect(response).toMatchObject({
schemaVersion: "content-factory.worker-response.v1",
appId: CONTENT_FACTORY_PLUGIN_ID,
status: "failed",
error: {
code: "HOST_MANAGED_GENERATION_REQUIRED",
},
artifacts: [],
});
});
it("缺少必填字段、未知任务或 runtime blocker 时应 fail closed", () => {
const contract = buildFixtureWorkerRuntimeContract();
expect(
@@ -330,6 +330,54 @@ describe("PluginMarketplacePage", () => {
).toBeNull();
});
it("空插件目录仍应提供本地安装入口并刷新列表", async () => {
const emptySnapshot = snapshot();
emptySnapshot.marketplace.items = [];
emptySnapshot.registry = [];
emptySnapshot.projectionInputs = [];
const loader = vi
.fn()
.mockResolvedValueOnce(emptySnapshot)
.mockResolvedValueOnce(snapshot());
const installLocalPackage: NonNullable<
PluginMarketplaceActionDeps["installLocalPackage"]
> = vi.fn(async () => installedState("local-kit"));
const selectLocalDirectory = vi.fn(async () => "/tmp/local-plugin");
const dispatchChanged = vi.fn();
const container = await renderPage({
loader,
actionDeps: {
selectLocalDirectory,
installLocalPackage,
dispatchChanged,
},
});
const localInstall = container.querySelector<HTMLButtonElement>(
'[data-testid="plugin-marketplace-local-install"]',
);
expect(localInstall?.disabled).toBe(false);
expect(container.textContent).toContain("plugin.marketplace.empty.title");
await act(async () => {
localInstall?.click();
await Promise.resolve();
});
await flushEffects(6);
expect(selectLocalDirectory).toHaveBeenCalledWith({
title: "plugin.marketplace.localInstall.dialogTitle",
});
expect(installLocalPackage).toHaveBeenCalledWith(
expect.objectContaining({
appDir: "/tmp/local-plugin",
profile: expectActionProfile(),
}),
);
expect(dispatchChanged).toHaveBeenCalledTimes(1);
expect(loader).toHaveBeenCalledTimes(2);
});
it("本地安装取消选择目录时不应显示错误", async () => {
const localSnapshot = snapshot();
localSnapshot.marketplace.items[0] = marketplaceItem(
+62 -13
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Boxes, ChevronRight, Info, RefreshCw, Search } from "lucide-react";
import {
Boxes,
ChevronRight,
FolderOpen,
Info,
RefreshCw,
Search,
} from "lucide-react";
import {
resolveOemCloudRuntimeContext,
type OemCloudRuntimeContext,
@@ -14,6 +21,7 @@ import {
type PluginMarketplaceRegistryLoader,
} from "./marketplace/usePluginMarketplaceRegistry";
import {
performPluginMarketplaceLocalInstall,
performPluginMarketplaceAction,
resolvePluginMarketplaceItemLabel,
submitPluginMarketplaceRegistrationCode,
@@ -105,6 +113,7 @@ export function PluginMarketplacePage({
);
const [category, setCategory] = useState(pageParams?.category ?? "");
const [pendingActionId, setPendingActionId] = useState<string | null>(null);
const [localInstallPending, setLocalInstallPending] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [suppressedRegistryError, setSuppressedRegistryError] = useState<
string | null
@@ -282,6 +291,31 @@ export function PluginMarketplacePage({
}
}
async function handleInstallLocalPackage() {
setLocalInstallPending(true);
setActionError(null);
setSuppressedRegistryError(null);
try {
const result = await performPluginMarketplaceLocalInstall(
{
title: t("plugin.marketplace.localInstall.dialogTitle"),
},
resolvedActionDeps,
);
if (result.status !== "performed") {
return;
}
registry.refresh().catch((error) => {
setSuppressedRegistryError(actionRefreshErrorMessage(error));
console.warn("[plugin-marketplace] local install refresh failed", error);
});
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error));
} finally {
setLocalInstallPending(false);
}
}
function handleOpenSkill(
item: PluginMarketplaceViewItem,
skill: PluginSkillDeclaration,
@@ -420,18 +454,33 @@ export function PluginMarketplacePage({
{t("plugin.marketplace.description")}
</p>
</div>
<button
type="button"
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-[color:var(--lime-surface-border,#e2e8f0)] bg-[color:var(--lime-surface,#fff)] px-4 text-sm font-semibold text-[color:var(--lime-text-strong,#0f172a)] transition hover:bg-[color:var(--lime-surface-hover,#f8fafc)] disabled:cursor-not-allowed disabled:opacity-60"
data-testid="plugin-marketplace-refresh"
disabled={registry.loading}
onClick={() => void registry.refresh().catch(() => undefined)}
>
<RefreshCw className="size-4" aria-hidden="true" />
{registry.loading
? t("plugin.marketplace.loading")
: t("plugin.marketplace.refresh")}
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-slate-950 bg-slate-950 px-4 text-sm font-semibold text-white shadow-sm shadow-slate-950/10 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
data-testid="plugin-marketplace-local-install"
disabled={localInstallPending}
title={t("plugin.marketplace.localInstall.actionTitle")}
onClick={() => void handleInstallLocalPackage()}
>
<FolderOpen className="size-4" aria-hidden="true" />
{localInstallPending
? t("plugin.marketplace.localInstall.pending")
: t("plugin.marketplace.localInstall.action")}
</button>
<button
type="button"
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-[color:var(--lime-surface-border,#e2e8f0)] bg-[color:var(--lime-surface,#fff)] px-4 text-sm font-semibold text-[color:var(--lime-text-strong,#0f172a)] transition hover:bg-[color:var(--lime-surface-hover,#f8fafc)] disabled:cursor-not-allowed disabled:opacity-60"
data-testid="plugin-marketplace-refresh"
disabled={registry.loading}
onClick={() => void registry.refresh().catch(() => undefined)}
>
<RefreshCw className="size-4" aria-hidden="true" />
{registry.loading
? t("plugin.marketplace.loading")
: t("plugin.marketplace.refresh")}
</button>
</div>
</header>
<section className="grid gap-3 md:grid-cols-5">
@@ -1,19 +1,15 @@
import {
getAgentAppCloudCatalog,
listInstalledAgentApps,
reviewLocalAgentAppPackage,
saveInstalledAgentAppState,
type AgentAppCloudCatalogResult,
} from "@/lib/api/agentApps";
import {
getClientPluginMarketplace,
OemCloudControlPlaneError,
} from "@/lib/api/oemCloudControlPlane";
import type {
CloudBootstrapApp,
HostCapabilityProfile,
InstalledAgentAppState,
RuntimeTarget,
} from "@/features/agent-app/types";
import type { InstalledAgentAppStateListResult } from "@/features/agent-app/install/installedAppState";
import { repairStaleInstalledAgentAppReadinessList } from "@/features/agent-app/install/staleReadinessRepair";
@@ -52,7 +48,6 @@ export interface PluginMarketplaceRegistryLoaderDeps {
query?: PluginMarketplaceQuery,
) => Promise<PluginMarketplaceListResponse>;
listInstalled?: () => Promise<InstalledAgentAppStateListResult>;
getAgentAppCatalog?: () => Promise<AgentAppCloudCatalogResult>;
reviewLocalPackage?: typeof reviewLocalAgentAppPackage;
saveInstalledState?: typeof saveInstalledAgentAppState;
profile?: HostCapabilityProfile;
@@ -258,161 +253,6 @@ function enrichMarketplaceItemWithInstalledStateSummary(params: {
};
}
function hasCloudReleaseEvidence(
state: InstalledAgentAppState | undefined,
): boolean {
const setup = readRecord(state?.setup);
return Boolean(
state?.identity.sourceKind !== "cloud_release" ||
!state.identity.sourceUri.startsWith("https://seeded.local/") ||
setup?.cloudReleaseEvidence,
);
}
function agentAppCatalogMarketplaceName(
source: AgentAppCloudCatalogResult["source"],
): string {
return source === "remote"
? "agent-app-cloud"
: source === "bootstrap"
? "agent-app-bootstrap"
: "agent-app-seeded";
}
function marketplaceItemFromCloudAgentApp(
app: CloudBootstrapApp,
source: AgentAppCloudCatalogResult["source"],
installedState?: InstalledAgentAppState,
): PluginMarketplaceItem {
const marketplaceName = agentAppCatalogMarketplaceName(source);
const installed = Boolean(installedState);
const packageUrl =
readOptionalText(app.packageUrl) ??
readOptionalText(installedState?.identity.sourceUri);
const packageHash =
readOptionalText(app.packageHash) ??
readOptionalText(installedState?.identity.packageHash);
const manifestHash =
readOptionalText(app.manifestHash) ??
readOptionalText(installedState?.identity.manifestHash);
const registrationBlocked =
!installed &&
app.registrationRequired === true &&
app.registrationState !== "active";
const packageReady = Boolean(packageUrl && packageHash && manifestHash);
const installedEvidenceMissing =
installed && !hasCloudReleaseEvidence(installedState);
const enabled = installed ? installedState?.disabled !== true : app.enabled;
const installable =
(installed && !installedEvidenceMissing) ||
(enabled && packageReady && !registrationBlocked);
const blockedReason = installed
? installedEvidenceMissing
? "cloud release evidence missing"
: undefined
: (readOptionalText(app.disabledReason) ??
(registrationBlocked
? "registration required"
: packageReady
? undefined
: "package unavailable"));
const category = readOptionalText(app.presentation?.category);
const displayName =
readOptionalText(app.displayName) ??
readOptionalText(app.presentation?.title) ??
app.appId;
return {
pluginKey: app.appId,
pluginName: app.appId,
marketplaceName,
marketplaceDisplayName: "Agent Apps",
displayName,
description:
readOptionalText(app.presentation?.summary) ?? blockedReason ?? "",
version: app.version,
category,
categories: category ? [category] : [],
keywords: app.defaultEntries,
capabilities: Object.keys(app.capabilityRequirements).sort(),
sourceKind: "agent_app_release",
sourceRef: readOptionalText(app.releaseId) ?? app.appId,
appId: app.appId,
install: (app.runtimeTargets ?? ([] as RuntimeTarget[])).includes("local")
? {
local: true,
cloud: source === "seeded" ? false : true,
authentication: app.registrationRequired ? "on_install" : "on_use",
}
: {
local: false,
cloud: true,
authentication: app.registrationRequired ? "on_install" : "on_use",
},
enabled,
installState: installable ? "available" : "blocked",
activationState: enabled && installable ? "activatable" : "blocked",
...(blockedReason ? { blockedReason } : {}),
policy: {
installation:
installed && !installedEvidenceMissing
? "INSTALLED_BY_DEFAULT"
: enabled
? "AVAILABLE"
: "NOT_AVAILABLE",
authentication: installed
? "ON_USE"
: app.registrationRequired
? "ON_INSTALL"
: "ON_USE",
},
package:
packageUrl || packageHash || manifestHash || app.releaseId
? {
releaseId: readOptionalText(app.releaseId),
packageUrl,
packageHash,
manifestHash,
signatureRef: readOptionalText(app.signatureRef),
}
: undefined,
manifestSummary: {
install: {
local: (app.runtimeTargets ?? ([] as RuntimeTarget[])).includes(
"local",
),
cloud: true,
authentication: app.registrationRequired ? "on_install" : "on_use",
},
},
};
}
function mergeLocalMarketplaceItems(params: {
installedItems: PluginMarketplaceItem[];
catalogItems: PluginMarketplaceItem[];
}): PluginMarketplaceItem[] {
const itemsByPluginKey = new Map<string, PluginMarketplaceItem>();
for (const item of params.installedItems) {
itemsByPluginKey.set(item.pluginKey, item);
}
for (const item of params.catalogItems) {
const installedItem = itemsByPluginKey.get(item.pluginKey);
itemsByPluginKey.set(
item.pluginKey,
installedItem
? enrichMarketplaceItemWithInstalledManifest({
marketplaceItem: item,
installedItem,
})
: item,
);
}
return Array.from(itemsByPluginKey.values()).sort((left, right) =>
left.displayName.localeCompare(right.displayName, "zh-Hans-CN"),
);
}
function enrichMarketplaceItemWithInstalledManifest(params: {
marketplaceItem: PluginMarketplaceItem;
installedItem: PluginMarketplaceItem;
@@ -541,7 +381,6 @@ function mergeInstalledManifestFieldsIntoMarketplace(params: {
function buildLocalMarketplaceRegistrySnapshot(
installed: InstalledAgentAppStateListResult,
agentAppCatalog: AgentAppCloudCatalogResult | null,
): PluginMarketplaceRegistrySnapshot {
const installedAgentApps: readonly InstalledAgentAppState[] =
installed.states;
@@ -557,18 +396,9 @@ function buildLocalMarketplaceRegistrySnapshot(
state: installedByAppId.get(item.appId ?? ""),
});
});
const catalogItems =
agentAppCatalog?.payload.apps.map((app) =>
marketplaceItemFromCloudAgentApp(
app,
agentAppCatalog.source,
installedByAppId.get(app.appId),
),
) ?? [];
const items = mergeLocalMarketplaceItems({
installedItems,
catalogItems,
});
const items = installedItems.sort((left, right) =>
left.displayName.localeCompare(right.displayName, "zh-Hans-CN"),
);
const marketplace: PluginMarketplaceListResponse = {
schemaVersion: "plugin-marketplace/v1",
tenantId: "local",
@@ -604,7 +434,6 @@ export async function loadPluginMarketplaceRegistry(
): Promise<PluginMarketplaceRegistrySnapshot> {
const getMarketplace = deps.getMarketplace ?? getClientPluginMarketplace;
const listInstalled = deps.listInstalled ?? listInstalledAgentApps;
const getAgentAppCatalog = deps.getAgentAppCatalog ?? getAgentAppCloudCatalog;
const normalizedTenantId = tenantId.trim();
const loadedInstalled = await listInstalled();
let installed = loadedInstalled;
@@ -644,13 +473,7 @@ export async function loadPluginMarketplaceRegistry(
}
if (!marketplace) {
let agentAppCatalog: AgentAppCloudCatalogResult | null = null;
try {
agentAppCatalog = await getAgentAppCatalog();
} catch {
agentAppCatalog = null;
}
return buildLocalMarketplaceRegistrySnapshot(installed, agentAppCatalog);
return buildLocalMarketplaceRegistrySnapshot(installed);
}
const installedAgentApps: readonly InstalledAgentAppState[] =
@@ -7,11 +7,7 @@ import { buildInstalledAppPreview } from "@/features/agent-app/install/installed
import { buildAgentAppLabResolvedSetupState } from "@/features/agent-app/install/labInstallFlow";
import { buildLocalAgentAppSourceState } from "@/features/agent-app/install/installReview";
import { buildPackageIdentity } from "@/features/agent-app/install/packageIdentity";
import type { AgentAppCloudCatalogResult } from "@/lib/api/agentApps";
import type {
AppManifest,
CloudBootstrapApp,
} from "@/features/agent-app/types";
import type { AppManifest } from "@/features/agent-app/types";
import type { InstalledAgentAppState } from "@/features/agent-app/types";
import { OemCloudControlPlaneError } from "@/lib/api/oemCloudControlPlane";
import type { PluginMarketplaceListResponse } from "./types";
@@ -184,51 +180,6 @@ function staleProfileBlockedInstalledState(): InstalledAgentAppState {
};
}
function cloudApp(
overrides: Partial<CloudBootstrapApp> = {},
): CloudBootstrapApp {
return {
appId: "content-studio",
displayName: "Content Studio",
version: "0.4.0",
presentation: {
category: "content",
summary: "Content production app",
},
releaseId: "content-studio-0.4.0",
channel: "seeded",
registrationRequired: false,
registrationState: "not_required",
enabled: true,
packageUrl:
"https://packages.limecloud.example/agent-apps/content-studio-0.4.0.lpkg",
packageHash:
"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
manifestHash:
"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
capabilityRequirements: {},
defaultEntries: ["content-studio"],
policyDefaults: {},
toolAvailability: [],
...overrides,
};
}
function agentAppCatalog(
apps: CloudBootstrapApp[],
source: AgentAppCloudCatalogResult["source"] = "seeded",
): AgentAppCloudCatalogResult {
return {
source,
payload: {
schemaVersion: "agent-app-cloud-bootstrap/v1",
tenantId: "seeded",
generatedAt: "2026-06-25T00:00:00.000Z",
apps,
},
};
}
describe("plugin marketplace registry loader", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -290,7 +241,7 @@ describe("plugin marketplace registry loader", () => {
expect(snapshot.projectionInputs).toHaveLength(2);
});
it("没有云端租户时应合并本地已安装插件 Agent App 目录", async () => {
it("没有云端租户时只使用本地已安装插件,不再读取 Agent App 云端目录", async () => {
const getMarketplace = vi.fn(async () => marketplace());
const localState = readyInstalledState();
const listInstalled = vi.fn(
@@ -299,49 +250,25 @@ describe("plugin marketplace registry loader", () => {
issues: [],
}),
);
const getAgentAppCatalog = vi.fn(async () => agentAppCatalog([cloudApp()]));
const snapshot = await loadPluginMarketplaceRegistry(
"",
{},
{ getMarketplace, listInstalled, getAgentAppCatalog },
{ getMarketplace, listInstalled },
);
expect(getMarketplace).not.toHaveBeenCalled();
expect(listInstalled).toHaveBeenCalledTimes(1);
expect(getAgentAppCatalog).toHaveBeenCalledTimes(1);
expect(snapshot.marketplace.marketplaceName).toBe("local");
expect(snapshot.marketplace.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginKey: localState.appId,
displayName: localState.manifest.displayName,
policy: {
installation: "INSTALLED_BY_DEFAULT",
authentication: "ON_USE",
},
}),
expect.objectContaining({
pluginKey: "content-studio",
displayName: "Content Studio",
appId: "content-studio",
policy: {
installation: "AVAILABLE",
authentication: "ON_USE",
},
}),
]),
);
expect(snapshot.registry).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "content-studio",
installed: false,
enabled: false,
capabilityStates: expect.arrayContaining(["installable"]),
}),
]),
);
expect(snapshot.marketplace.items).toHaveLength(1);
expect(snapshot.marketplace.items[0]).toMatchObject({
pluginKey: localState.appId,
displayName: localState.manifest.displayName,
policy: {
installation: "INSTALLED_BY_DEFAULT",
authentication: "ON_USE",
},
});
expect(
snapshot.registry.find((item) => item.pluginId === localState.appId),
).toMatchObject({
@@ -398,7 +325,6 @@ describe("plugin marketplace registry loader", () => {
const snapshot = await loadPluginMarketplaceRegistry("", {}, {
listInstalled,
getAgentAppCatalog: vi.fn(async () => agentAppCatalog([])),
reviewLocalPackage,
saveInstalledState,
});
@@ -530,29 +456,14 @@ describe("plugin marketplace registry loader", () => {
});
});
it("Agent App 目录缺包哈希但本地已安装同 appId 时不应误报包不匹配", async () => {
const localState = installedState();
it("本地已安装插件不再与旧 Agent App 目录做包匹配", async () => {
const localState = readyInstalledState();
const listInstalled = vi.fn(
async (): Promise<InstalledAgentAppStateListResult> => ({
states: [localState],
issues: [],
}),
);
const getAgentAppCatalog = vi.fn(async () =>
agentAppCatalog([
cloudApp({
appId: localState.appId,
displayName: "Research Kit",
packageUrl: "",
packageHash: "",
manifestHash: "",
registrationRequired: true,
registrationState: "required",
enabled: false,
disabledReason: "registration required",
}),
]),
);
const snapshot = await loadPluginMarketplaceRegistry(
"",
@@ -560,7 +471,6 @@ describe("plugin marketplace registry loader", () => {
{
getMarketplace: vi.fn(async () => marketplace()),
listInstalled,
getAgentAppCatalog,
},
);
@@ -577,70 +487,6 @@ describe("plugin marketplace registry loader", () => {
]);
});
it("本地 seeded 目录应保留可安装包信息以刷新缺 evidence 的旧安装态", async () => {
const localState = installedState({
appId: "content-factory-app",
packageHash: "package-fnv1a-a4d0e86f",
manifestHash: "manifest-fnv1a-b840e5c9",
sourceUri: "https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
});
const listInstalled = vi.fn(
async (): Promise<InstalledAgentAppStateListResult> => ({
states: [localState],
issues: [],
}),
);
const getAgentAppCatalog = vi.fn(async () =>
agentAppCatalog([
cloudApp({
appId: "content-factory-app",
displayName: "内容工厂",
version: "2.0.0",
releaseId: "seeded-content-factory-app-2.0.0",
runtimeTargets: ["local"],
packageUrl:
"https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
packageHash: "package-fnv1a-a4d0e86f",
manifestHash: "manifest-fnv1a-b840e5c9",
defaultEntries: ["content_factory"],
}),
]),
);
const snapshot = await loadPluginMarketplaceRegistry(
"",
{},
{
getMarketplace: vi.fn(async () => marketplace()),
listInstalled,
getAgentAppCatalog,
},
);
expect(snapshot.marketplace.items[0]).toMatchObject({
pluginKey: "content-factory-app",
package: expect.objectContaining({
packageUrl:
"https://seeded.local/agent-apps/content-factory-app/2.0.0.lapp",
}),
});
expect(snapshot.registry[0]).toMatchObject({
pluginId: "content-factory-app",
installed: false,
capabilityStates: expect.arrayContaining(["installable"]),
blockerCodes: expect.arrayContaining([
"PLUGIN_CLOUD_RELEASE_EVIDENCE_MISSING",
]),
});
expect(snapshot.marketplace.items[0]).toMatchObject({
pluginKey: "content-factory-app",
install: {
local: true,
cloud: false,
},
});
});
it("云端认证失败时应回退到本地已安装插件目录", async () => {
const getMarketplace = vi.fn(async () => {
throw new OemCloudControlPlaneError("invalid auth token", {
@@ -654,18 +500,16 @@ describe("plugin marketplace registry loader", () => {
issues: [],
}),
);
const getAgentAppCatalog = vi.fn(async () => agentAppCatalog([]));
const snapshot = await loadPluginMarketplaceRegistry(
"tenant-0001",
{ sort: "name" },
{ getMarketplace, listInstalled, getAgentAppCatalog },
{ getMarketplace, listInstalled },
);
expect(getMarketplace).toHaveBeenCalledWith("tenant-0001", {
sort: "name",
});
expect(getAgentAppCatalog).toHaveBeenCalledTimes(1);
expect(snapshot.marketplace.marketplaceName).toBe("local");
expect(snapshot.registry[0]).toMatchObject({
pluginId: localState.appId,
@@ -83,6 +83,15 @@ export type PluginMarketplaceActionResult =
blockerCodes: PluginMarketplaceActionBlockerCode[];
};
export type PluginMarketplaceLocalInstallResult =
| {
status: "performed";
installedState: InstalledAgentAppState;
}
| {
status: "noop";
};
export interface PluginMarketplaceActionDeps {
installCloudRelease?: typeof installCloudAgentAppRelease;
installLocalPackage?: typeof installLocalAgentAppPackage;
@@ -99,6 +108,29 @@ export interface PluginMarketplaceActionDeps {
dispatchChanged?: () => void;
}
export async function performPluginMarketplaceLocalInstall(
options: { title: string },
deps: PluginMarketplaceActionDeps = {},
): Promise<PluginMarketplaceLocalInstallResult> {
const selectLocalDirectory =
deps.selectLocalDirectory ?? selectLocalAgentAppDirectory;
const appDir = await selectLocalDirectory({ title: options.title });
if (!appDir) {
return { status: "noop" };
}
const installedState = await (
deps.installLocalPackage ?? installLocalAgentAppPackage
)({
appDir,
profile: deps.profile,
});
(deps.dispatchChanged ?? defaultDispatchChanged)();
return {
status: "performed",
installedState,
};
}
export async function submitPluginMarketplaceRegistrationCode(
item: PluginMarketplaceViewItem,
code: string,
@@ -206,11 +238,11 @@ function buildMarketplaceCloudApp(item: PluginMarketplaceViewItem): {
}
return {
app: {
appId,
displayName: resolvePluginMarketplaceItemLabel(item),
version: item.version,
releaseId: item.releaseId ?? item.package?.releaseId,
app: {
appId,
displayName: resolvePluginMarketplaceItemLabel(item),
version: item.version,
releaseId: item.releaseId ?? item.package?.releaseId,
signatureRef: item.package?.signatureRef,
registrationRequired: false,
registrationState: "not_required",
+5 -1
View File
@@ -2,7 +2,11 @@ import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding";
import { useTranslation } from "react-i18next";
export function StartupLoadingScreen() {
const { t } = useTranslation("common");
const { t: translate } = useTranslation("common");
const t = translate as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
return (
<div
+5 -1
View File
@@ -2014,6 +2014,10 @@
"plugin.marketplace.description": "Read the cloud marketplace, merge local install state, and show installable, activatable, renderable, and read-only history states.",
"plugin.marketplace.refresh": "Refresh",
"plugin.marketplace.loading": "Loading",
"plugin.marketplace.localInstall.action": "Install local plugin",
"plugin.marketplace.localInstall.pending": "Installing",
"plugin.marketplace.localInstall.actionTitle": "Choose a local plugin directory and install it",
"plugin.marketplace.localInstall.dialogTitle": "Choose local plugin directory",
"plugin.marketplace.count.all": "All plugins",
"plugin.marketplace.count.installed": "Installed",
"plugin.marketplace.count.installable": "Installable",
@@ -2030,7 +2034,7 @@
"plugin.marketplace.filter.attention": "Needs action",
"plugin.marketplace.error.title": "Plugin marketplace failed to load",
"plugin.marketplace.empty.title": "No matching plugins",
"plugin.marketplace.empty.description": "Clear filters or wait for the cloud marketplace to publish available plugins.",
"plugin.marketplace.empty.description": "Clear filters, or install a local plugin directory.",
"plugin.marketplace.descriptionFallback": "No plugin description yet.",
"plugin.marketplace.status.activatable": "Activatable",
"plugin.marketplace.status.attention": "Needs action",
+5 -1
View File
@@ -2014,6 +2014,10 @@
"plugin.marketplace.description": "クラウドマーケットを読み込み、ローカルのインストール状態を統合して、インストール可能、有効化可能、描画可能、読み取り専用履歴の状態を表示します。",
"plugin.marketplace.refresh": "更新",
"plugin.marketplace.loading": "読み込み中",
"plugin.marketplace.localInstall.action": "ローカルプラグインをインストール",
"plugin.marketplace.localInstall.pending": "インストール中",
"plugin.marketplace.localInstall.actionTitle": "ローカルプラグインのディレクトリを選択してインストール",
"plugin.marketplace.localInstall.dialogTitle": "ローカルプラグインディレクトリを選択",
"plugin.marketplace.count.all": "すべてのプラグイン",
"plugin.marketplace.count.installed": "インストール済み",
"plugin.marketplace.count.installable": "インストール可能",
@@ -2030,7 +2034,7 @@
"plugin.marketplace.filter.attention": "対応が必要",
"plugin.marketplace.error.title": "プラグインマーケットの読み込みに失敗しました",
"plugin.marketplace.empty.title": "一致するプラグインはありません",
"plugin.marketplace.empty.description": "フィルターを解除するか、クラウドマーケットから利用可能なプラグインが配信されるのを待ってください。",
"plugin.marketplace.empty.description": "フィルターを解除するか、ローカルプラグインのディレクトリをインストールしてください。",
"plugin.marketplace.descriptionFallback": "プラグイン説明はまだありません。",
"plugin.marketplace.status.activatable": "有効化可能",
"plugin.marketplace.status.attention": "対応が必要",
+5 -1
View File
@@ -2014,6 +2014,10 @@
"plugin.marketplace.description": "클라우드 마켓플레이스를 읽고 로컬 설치 상태를 합쳐 설치 가능, 활성화 가능, 렌더링 가능, 읽기 전용 기록 상태를 보여줍니다.",
"plugin.marketplace.refresh": "새로고침",
"plugin.marketplace.loading": "불러오는 중",
"plugin.marketplace.localInstall.action": "로컬 플러그인 설치",
"plugin.marketplace.localInstall.pending": "설치 중",
"plugin.marketplace.localInstall.actionTitle": "로컬 플러그인 디렉터리를 선택해 설치",
"plugin.marketplace.localInstall.dialogTitle": "로컬 플러그인 디렉터리 선택",
"plugin.marketplace.count.all": "전체 플러그인",
"plugin.marketplace.count.installed": "설치됨",
"plugin.marketplace.count.installable": "설치 가능",
@@ -2030,7 +2034,7 @@
"plugin.marketplace.filter.attention": "처리 필요",
"plugin.marketplace.error.title": "플러그인 마켓플레이스 로드 실패",
"plugin.marketplace.empty.title": "일치하는 플러그인이 없습니다",
"plugin.marketplace.empty.description": "필터를 지우거나 클라우드 마켓플레이스에서 사용 가능한 플러그인이 배포될 때까지 기다리세요.",
"plugin.marketplace.empty.description": "필터를 지우거나 로컬 플러그인 디렉터리를 설치하세요.",
"plugin.marketplace.descriptionFallback": "아직 플러그인 설명이 없습니다.",
"plugin.marketplace.status.activatable": "활성화 가능",
"plugin.marketplace.status.attention": "처리 필요",
+5 -1
View File
@@ -2014,6 +2014,10 @@
"plugin.marketplace.description": "从云端市场读取插件目录,合并本地安装态后展示可安装、可激活、可渲染和只读历史状态。",
"plugin.marketplace.refresh": "刷新",
"plugin.marketplace.loading": "正在加载",
"plugin.marketplace.localInstall.action": "安装本地插件",
"plugin.marketplace.localInstall.pending": "正在安装",
"plugin.marketplace.localInstall.actionTitle": "选择本地插件目录并安装",
"plugin.marketplace.localInstall.dialogTitle": "选择本地插件目录",
"plugin.marketplace.count.all": "全部插件",
"plugin.marketplace.count.installed": "已安装",
"plugin.marketplace.count.installable": "可安装",
@@ -2030,7 +2034,7 @@
"plugin.marketplace.filter.attention": "需要处理",
"plugin.marketplace.error.title": "插件市场加载失败",
"plugin.marketplace.empty.title": "没有符合条件的插件",
"plugin.marketplace.empty.description": "可以清空筛选条件,或等待云端市场下发可用插件。",
"plugin.marketplace.empty.description": "可以清空筛选条件,或直接安装本地插件目录。",
"plugin.marketplace.descriptionFallback": "暂无插件说明。",
"plugin.marketplace.status.activatable": "可激活",
"plugin.marketplace.status.attention": "需要处理",
+5 -1
View File
@@ -2014,6 +2014,10 @@
"plugin.marketplace.description": "從雲端市場讀取插件目錄,合併本機安裝狀態後展示可安裝、可啟用、可渲染和只讀歷史狀態。",
"plugin.marketplace.refresh": "重新整理",
"plugin.marketplace.loading": "正在載入",
"plugin.marketplace.localInstall.action": "安裝本機插件",
"plugin.marketplace.localInstall.pending": "正在安裝",
"plugin.marketplace.localInstall.actionTitle": "選擇本機插件目錄並安裝",
"plugin.marketplace.localInstall.dialogTitle": "選擇本機插件目錄",
"plugin.marketplace.count.all": "全部插件",
"plugin.marketplace.count.installed": "已安裝",
"plugin.marketplace.count.installable": "可安裝",
@@ -2030,7 +2034,7 @@
"plugin.marketplace.filter.attention": "需要處理",
"plugin.marketplace.error.title": "插件市場載入失敗",
"plugin.marketplace.empty.title": "沒有符合條件的插件",
"plugin.marketplace.empty.description": "可以清空篩選條件,或等待雲端市場下發可用插件。",
"plugin.marketplace.empty.description": "可以清空篩選條件,或直接安裝本機插件目錄。",
"plugin.marketplace.descriptionFallback": "暫無插件說明。",
"plugin.marketplace.status.activatable": "可啟用",
"plugin.marketplace.status.attention": "需要處理",