diff --git a/CHANGELOG.md b/CHANGELOG.md index d86698b..953e891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ContentCloud 的重要变更记录在此文件中。 +## [0.4.0] - 2026-07-27 + +### Added + +- 打通 SubmissionRevision 内审、客户 OTP 审批、ApprovedSnapshot、三格式 DeliveryPackage、投放结果、人工评级与 Lineage 的 V2 完整业务链。 +- 增加 revision 级客户 ReviewGrant 的创建、查看、撤销、过期与新 revision 自动失效机制,公开审批页继续兼容 V1 历史链接。 +- 增加基于 ApprovedSnapshot 的 JSON、Markdown、XLSX 导出与不可变交付包,并支持 Web 和 CLI 下载。 +- 增加 `00014`/`00015` 数据库迁移,将交付和结果切换到 ApprovedSnapshot,并以可审计、幂等方式回填 V1 影子快照。 +- 增加真实 PostgreSQL、应用层和 HTTP Golden Path 测试,覆盖两阶段审批、交付、结果回流、RLS、不可变约束及脏 hash 跳过。 + +### Changed + +- 内部批准只推进到 `internally_approved`,客户批准才原子生成 ApprovedSnapshot,避免未获客户确认的内容进入正式交付。 +- Brief 强制绑定已拉取的 Strategy ApprovedSnapshot;PerformanceObservation、RatingDecision 与 Lineage 统一使用批准快照作为事实源。 +- 更新 CLI `review`、`artifact`、`result` 命令和 OpenAPI 契约,使 revision、快照、交付包与结果字段保持一致。 +- 重构租户控制台路由与 Editorial Studio 视觉体系,保留旧 `/workspace/*` 地址的兼容跳转,并完善审批、交付和结果操作界面。 +- 本地开发改为 `make dev` 同时运行 Go API 与 Vite,默认使用隔离 Memory Store;构建后单体预览使用 `make preview`。 + +### Fixed + +- 修复 V1 影子回填遇到非 SHA-256 历史 hash 时导致迁移失败的问题;无效记录仅计入报告,不伪造或重算历史 hash。 +- 修复 CLI `review status` 仍读取 V1 ScriptVersion 的问题,改为返回 Submission、Revision 与客户授权状态。 + ## [0.3.0] - 2026-07-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a46ec0a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +## Design System + +Always read `DESIGN.md` before making visual or UI decisions. Font choices, colors, spacing, and aesthetic direction are defined there. Do not deviate without explicit approval. During UI review, flag code that does not match `DESIGN.md`. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..17b8ce9 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,100 @@ +# ContentCloud Design System + +## Product Context + +- **产品**:面向 AI 内容营销团队的本地优先创作与云端治理系统。 +- **用户**:内容策略、编辑、审核、项目管理与平台运营人员。 +- **界面类型**:高频工作台、内容生产管线、审核界面与独立系统后台。 +- **设计目标**:像一张可信、克制但有创作能量的编辑制作台,而不是通用云平台控制台。 + +## Aesthetic Direction + +- **方向**:Editorial Studio(编辑制作台)。 +- **装饰强度**:克制且有意图。中性工作面承担大部分内容,颜色只标记品牌、当前动作、内容分类和状态。 +- **布局**:严格网格。生产工具优先扫描效率、对齐和稳定尺寸,不采用营销页式卡片堆叠。 +- **基调**:石墨、稿纸灰与荧光标记构成主界面;资料、知识、策略、制作和审核拥有稳定分类色。 + +### Research References + +- [Adobe Spectrum color system](https://spectrum.adobe.com/page/color-system/):用颜色关系与语义角色建立可扩展系统。 +- [Sanity UI theme](https://www.sanity.io/ui/docs/theme):中性表面配合多组 hue token,而非单色覆盖全部功能。 +- [Contentful Forma 36 colors](https://f36.contentful.com/tokens/colors/):内容后台以清晰中性层级和独立交互色为基础。 +- [Frame.io](https://frame.io/):深色制作台、媒体内容优先、颜色稀疏且用于状态与选择。 +- [Descript](https://www.descript.com/):高对比编辑气质与鲜明的制作信号色。 +- [Craft](https://www.craft.do/) 与 [Airtable](https://www.airtable.com/):中性工作面承载多种内容分类色。 + +## Color + +### Brand And Commands + +- **Brand marker** `#d5f34a`:荧光稿纸标记,只用于品牌、当前导航和生产管线基线。 +- **Brand ink** `#202810`:品牌亮色上的文字与图形。 +- **Command** `#1c1f1b`:主要按钮和高权重命令。 +- **Command hover** `#30342e`。 +- **Link** `#4c6500`:白色和稿纸灰表面上的可访问文本链接。 + +### Surfaces + +- **Page** `#f2f3ef` +- **Panel** `#ffffff` +- **Raised** `#eef0ea` +- **Neutral** `#e7eae3` +- **Ink** `#1b1d1a` +- **Muted** `#5b6258` +- **Border** `#d9dcd3` + +### Content Categories + +- **Sources** `#3567d8` / `#e9f0ff` +- **Knowledge** `#117a61` / `#e5f5ef` +- **Strategy** `#a46200` / `#fbf0db` +- **Production** `#c74632` / `#fbeae6` +- **Review** `#b33c62` / `#f8e9ef` + +分类色表达对象类型和生产阶段,不表达成功或失败。 + +### Semantic States + +- **Info** `#3567d8` +- **Success** `#117a61` +- **Warning** `#a46200` +- **Danger** `#b8273d` + +语义色只表达系统状态,不承担品牌装饰。 + +## Typography + +- **UI / Body**:平台原生 humanist sans 栈,优先 Avenir Next、Segoe UI Variable、苹方与微软雅黑。 +- **Data / Code**:系统等宽字体栈;数字列启用 tabular nums。 +- **策略**:不加载远程字体,保证本地优先环境、离线开发和隐私边界。 +- **正文尺度**:`11 / 11.5 / 12 / 13 / 14px`,页面标题按容器使用 `21-27px`。 +- **字距**:统一为 `0`。 + +## Spacing And Layout + +- **基础单位**:4px。 +- **密度**:紧凑但可扫描;表格和管线保持固定节奏,表单保留 8-16px 组间距。 +- **内容宽度**:工作台最大 1440px,页面使用响应式网格。 +- **圆角**:小元素 6px、控件与卡片 8px、弹层 12px、状态 pill 使用完全圆角。 +- **阴影**:卡片仅使用 1px 级轻阴影,弹层使用独立高程;边框负责主要分层。 + +## Motion + +- **方向**:minimal-functional。 +- **时长**:微交互 150-200ms。 +- **属性**:仅过渡颜色、透明度和 transform,不使用 `transition: all`。 +- **无障碍**:必须尊重 `prefers-reduced-motion`,所有键盘交互保留 `focus-visible`。 + +## Safe Choices And Risks + +- **安全选择**:中性表面、严格网格、可预测导航和高对比命令符合内容后台的高频操作预期。 +- **设计风险**:荧光品牌色比传统 SaaS 蓝更醒目,但限定在小面积标记中,避免娱乐化。 +- **设计风险**:生产阶段采用分类色,提升定位速度;代价是必须严格区分分类色与语义状态色。 + +## Decisions Log + +| Date | Decision | Rationale | +| --- | --- | --- | +| 2026-07-26 | 从 Rubik 蓝平台主题改为 Editorial Studio | 单一蓝色更像基础设施控制台,无法表达内容生产阶段。 | +| 2026-07-26 | 品牌、命令、链接、语义和分类 token 分离 | 防止一个 accent 同时承担多个互相冲突的职责。 | +| 2026-07-26 | 使用本地系统字体栈 | 保持本地优先、离线可用和零第三方字体请求。 | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 6ae7069..a496702 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -19,16 +19,19 @@ - 新 revision 出现后旧 grant 自动失效 - 客户批准生成 ApprovedSnapshot,hash 等于 revision content_hash **Tests**: internal→client 正常路径、跳过 internal 被拒、grant 失效、OTP 错误、重复决定。 -**Status**: Not Started +**Status**: Complete +**Notes**: `ApprovalDecision.decision_stage` 区分 internal/client/legacy;script 内审仅进入 `internally_approved`,客户 OTP 批准后原子创建当前 ApprovedSnapshot;新 revision 自动撤销旧未决 grant;公开审批同时兼容 V1 历史链接和 V2 revision 链接。Web 与 CLI 的新授权入口只接受 SubmissionRevision。 ## Stage 3: 导出改由 ApprovedSnapshot 驱动 **Goal**: JSON/Markdown/XLSX 从批准快照的 canonical 内容生成,不再要求 V1 ScriptVersion。 **Success Criteria**: 三种格式由同一快照生成,manifest 记录 snapshot ID 与 revision hash。 **Tests**: 三格式导出内容一致性、未批准快照拒绝导出。 -**Status**: Not Started +**Status**: Complete +**Notes**: 服务端和本地 CLI 共用 `localworkspace.RenderScriptPackageV2`;`artifact export/package` 与 Web 均从 `origin=current` 的 script ApprovedSnapshot 生成 JSON/Markdown/XLSX,并在 Artifact metadata 中保存 snapshot ID、revision hash 和 script hash。 ## Stage 4: 交付、结果与影子快照回填 **Goal**: DeliveryPackage 引用 ApprovedSnapshot;PerformanceObservation 绑定快照;V1 已批准 ScriptVersion 回填 `origin=v1_import` 只读影子快照。 **Success Criteria**: 回填后历史导出内容与 hash 不变;dry-run 报告数量与不可映射项。 **Tests**: 回填幂等、hash 不变、跨租户负测。 -**Status**: Not Started +**Status**: Complete +**Notes**: DeliveryPackage/Artifact/PerformanceObservation/Rating/Lineage 已接到 ApprovedSnapshot。`00014_v2_single_track_delivery.sql` 完成结构迁移,`00015_v1_shadow_backfill_report.sql` 提供幂等回填与 inserted/skipped_invalid_hash 报告;真实 PostgreSQL 测试覆盖 hash 保持、重复回填为 0、RLS 和不可变约束。 diff --git a/Makefile b/Makefile index f38bef4..3fa6c98 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -.PHONY: dev server web worker cli build test check install-cli migrate-up +.PHONY: dev preview server web worker cli build test check install-cli migrate-up CONTENTCLOUD_ADDR ?= :8080 CONTENTCLOUD_WEB_DIST ?= web/dist -dev: build +dev: + ./scripts/dev.sh + +preview: build CONTENTCLOUD_ADDR=$(CONTENTCLOUD_ADDR) CONTENTCLOUD_WEB_DIST=$(CONTENTCLOUD_WEB_DIST) ./bin/contentcloud-server server: diff --git a/README.md b/README.md index 77100c0..7729fb3 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,14 @@ docs/roadmap/v2/ V2 产品、架构、流程、安全、计划与实现 ```bash pnpm install -make build -CONTENTCLOUD_DEV_MODE=1 ./bin/contentcloud-server +make dev ``` -打开 `http://localhost:8080` 使用租户工作台,或打开 `http://localhost:8080/admin/dashboard` 使用独立系统后台。开发模式使用 Memory Store、本地 Blob,演示账号默认具备平台管理员权限,并自动创建金陵古法线香演示项目;来源由内置确定性 Worker 处理。 +开发脚本会同时启动 Go API(`http://localhost:8080`)和 Vite(`http://localhost:5173`),自动加载根目录 `.env`,并在退出时清理自己启动的子进程。打开 `http://localhost:5173` 使用租户工作台,或打开 `http://localhost:5173/admin/dashboard` 使用独立系统后台。 + +默认使用隔离的 Memory Store 和 `var/dev-data` 本地 Blob,不会连接 `.env` 中的数据库或 S3。运行 `./scripts/dev.sh --help` 可查看端口覆盖、真实数据库联调和显式清理占用端口等选项。需要验证构建后的前后端单体服务时使用 `make preview`。 + +开发模式下,演示账号默认具备平台管理员权限,并自动创建金陵古法线香演示项目;来源由内置确定性 Worker 处理。 CLI 示例: @@ -97,7 +100,7 @@ CONTENTCLOUD_REQUIRE_MALWARE_SCAN=1 ./bin/contentcloud-worker 2. 项目总览生成 10 分钟有效、单次使用的 `cck_`,并拼成不含登录态的 Agent Prompt: ```text -Fetch https://content.example.com/api/bootstrap and initialize this ContentCloud project. +Fetch https://content.example.com/api/bootstrap and help me initialize this ContentCloud project on this machine. server-url: https://content.example.com connect-key: cck_xxx diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md new file mode 100644 index 0000000..448a921 --- /dev/null +++ b/REDESIGN_PLAN.md @@ -0,0 +1,74 @@ +# 页面视觉重构(Editorial Studio 内容生产体系) + +> 独立于 `IMPLEMENTATION_PLAN.md`(后端 V2 审批收敛,进行中),本文件只覆盖 `web/src/styles.css` 的视觉层。 + +基础密度参考:`/Users/coso/Documents/dev/js/loopany-platform/packages/server/src/styles/app.css` + +第二轮色彩研究参考 Adobe Spectrum、Sanity UI、Contentful Forma 36、Frame.io、Descript、Craft +与 Airtable。完整设计决策见 `DESIGN.md`。 + +## 问题诊断 + +在 `web/src/styles.css`(83KB 压缩单体)实测: + +- **无色彩体系**:130+ 种硬编码 hex,几乎全是暖绿调灰(`#6f7774` / `#dfe3e1` / `#f6f8f7`) +- **红色语义错位**:`--accent:#d84b3e` 砖红同时承担品牌色、交互色和装饰性 eyebrow, + 而错误态另用一套红(`#a93d35` / `#fdebea`)——「红」因此不传达任何信息 +- **字号失控**:21 个不同字号,其中 9px×79、8px×40、7px×4,正文级文字落在 8-10px +- **圆角失控**:4/5/6/7/8/9/14/16px 共 8 种规格混用 + +## 目标体系 + +| 维度 | 现状 | 目标(Editorial Studio) | +| --- | --- | --- | +| 页面底色 | `#f7f8f8` 暖灰 | `#f2f3ef` 稿纸灰 | +| 品牌标记 | 品牌色兼作按钮与状态 | `#d5f34a` 荧光稿纸标记 | +| 高权重命令 | 与品牌色共用 | `#1c1f1b` 石墨黑 | +| 内容分类 | 无稳定分类色 | 资料蓝 / 知识青绿 / 策略琥珀 / 制作珊瑚 / 审核玫红 | +| 错误态 | 散落 5 种红 | `#b8273d` 危险红 | +| 成功态 | `#17855f` | `#117a61` 成功青绿 | +| 警告态 | `#a6650b` | `#a46200` 警告琥珀 | +| 字号 | 7~13px 无级 | 11 / 11.5 / 12 / 13 / 14 五档 | +| 圆角 | 8 种 | 6 / 8 / 12 三档角色层级 | + +## Stage 1: 色彩体系 +**Goal**: 将品牌、命令、链接、语义状态和内容分类拆分为独立 token,建立编辑制作台配色 +**Success Criteria**: 无单一 accent 跨职责复用;分类色与成功/错误等语义色严格分离 +**Tests**: `pnpm test` + `pnpm typecheck` + 逐页截图回归 +**Status**: Completed (2026-07-26) + +## Stage 2: 字号体系 +**Goal**: 21 档字号收敛到 5 档 token,最小字号从 7px 提到 11px +**Success Criteria**: 无 <11px 文字;固定宽度容器无溢出/截断 +**Tests**: 逐页截图检查溢出,重点查 grid 固定列宽处 +**Status**: Completed (2026-07-26) + +## Stage 3: 圆角与描边 +**Goal**: 圆角收敛到 3 档角色层级;卡片使用 1px 级轻阴影,弹层使用独立高程 +**Success Criteria**: 卡片边界统一,视觉层次清晰 +**Tests**: 截图回归 +**Status**: Completed (2026-07-26) + +## Stage 4: 关键组件精修 +**Goal**: sidebar / topbar / eyebrow / button / stat 卡片对齐参考项目质感 +**Success Criteria**: 全页面截图通过人工审阅 +**Tests**: dashboard、项目总览、团队、登录、admin 全覆盖截图 +**Status**: Completed (2026-07-26) + +## 完成记录 + +- 色彩已集中到根 token:稿纸灰页面、石墨命令、荧光品牌标记,以及资料、知识、策略、制作、 + 审核五组内容分类色;品牌色不再兼任按钮、链接和状态色。 +- 正文字号已收敛为 `11 / 11.5 / 12 / 13 / 14px` 五档,样式中无小于 `11px` 的文字。 +- 组件圆角按编辑工具密度调整为 `6 / 8 / 12px` 角色层级,卡片使用 1px 级轻阴影, + 弹层使用独立的浮层阴影 token。 +- sidebar、生产管线、eyebrow、按钮、统计卡片和登录页已统一到 Editorial Studio;导航与生产阶段 + 使用分类色定位,清除了登录页废弃装饰规则,并保留统一 `focus-visible` 与减弱动效支持。 +- 移动端精修覆盖后台紧凑租户表和团队成员表,关键字段与操作在 `390px` 视口内完整显示。 + +## 验收结果 + +- 人工截图:工作台、项目总览、团队、登录、系统后台,均覆盖 `1440px` 桌面与 `390px` 移动端。 +- 浏览器检查:上述关键路由在 `390px` 下均无页面级横向溢出,无 console error 或 page error。 +- 自动验证:`pnpm --dir web test`(16 tests)、`pnpm --dir web typecheck`、 + `pnpm --dir web build`、`go test ./...`、`git diff --check` 全部通过。 diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 8bd189c..e35c27b 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -421,22 +421,50 @@ paths: /bff/comments/{id}/resolve: parameters: [{$ref: '#/components/parameters/ID'}] post: *jsonWrite - /bff/scripts/{id}/review-grants: + /bff/projects/{project_id}/submissions: + parameters: [{$ref: '#/components/parameters/ProjectID'}] + get: *jsonRead + /bff/submissions/{id}: parameters: [{$ref: '#/components/parameters/ID'}] - get: + get: *jsonRead + /bff/submission-revisions/{id}/approve: + parameters: [{$ref: '#/components/parameters/ID'}] + post: tags: [Web BFF] - operationId: listScriptReviewGrants + operationId: approveSubmissionRevision security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/ApproveSubmissionInput'}} responses: - '200': - description: Review grant history without token or OTP hashes. - content: - application/json: {schema: {$ref: '#/components/schemas/ReviewGrantListEnvelope'}} + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/Error'} '401': {$ref: '#/components/responses/Error'} - '404': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + /bff/submission-revisions/{id}/request-changes: + parameters: [{$ref: '#/components/parameters/ID'}] post: tags: [Web BFF] - operationId: createScriptReviewGrant + operationId: requestSubmissionRevisionChanges + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/RequestSubmissionChangesInput'}} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + /bff/submission-revisions/{id}/review-grants: + parameters: [{$ref: '#/components/parameters/ID'}] + get: *jsonRead + post: + tags: [Web BFF] + operationId: createSubmissionReviewGrant security: [{cookieAuth: []}] requestBody: required: true @@ -444,13 +472,26 @@ paths: application/json: {schema: {$ref: '#/components/schemas/CreateReviewGrantInput'}} responses: '200': - description: Created grant. The plaintext token and OTP are returned once. + description: Revision-bound grant. Plaintext token and OTP are returned exactly once. content: application/json: {schema: {$ref: '#/components/schemas/ReviewGrantCreatedEnvelope'}} '400': {$ref: '#/components/responses/Error'} '401': {$ref: '#/components/responses/Error'} '403': {$ref: '#/components/responses/Error'} '409': {$ref: '#/components/responses/Error'} + /bff/scripts/{id}/review-grants: + parameters: [{$ref: '#/components/parameters/ID'}] + get: + tags: [Web BFF] + operationId: listScriptReviewGrants + security: [{cookieAuth: []}] + responses: + '200': + description: Legacy ScriptVersion review grant history without token or OTP hashes. New grants use SubmissionRevision. + content: + application/json: {schema: {$ref: '#/components/schemas/ReviewGrantListEnvelope'}} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} /bff/review-grants/{id}/revoke: parameters: [{$ref: '#/components/parameters/ID'}] post: @@ -485,6 +526,52 @@ paths: /bff/scripts/{id}/exports: parameters: [{$ref: '#/components/parameters/ID'}] post: *jsonWrite + /bff/projects/{project_id}/approved-snapshots: + parameters: + - {$ref: '#/components/parameters/ProjectID'} + - {name: type, in: query, schema: {type: string, enum: [knowledge, research, strategy, brief, script, delivery, performance]}} + get: *jsonRead + /bff/approved-snapshots/{id}/artifacts: + parameters: [{$ref: '#/components/parameters/ID'}] + get: *jsonRead + /bff/approved-snapshots/{id}/exports: + parameters: [{$ref: '#/components/parameters/ID'}] + post: + tags: [Web BFF] + operationId: exportApprovedSnapshot + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/ExportApprovedSnapshotInput'}} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + /bff/approved-snapshots/{id}/delivery-packages: + parameters: [{$ref: '#/components/parameters/ID'}] + post: + tags: [Web BFF] + operationId: createDeliveryPackage + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/CreateDeliveryPackageInput'}} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + /bff/projects/{project_id}/delivery-packages: + parameters: [{$ref: '#/components/parameters/ProjectID'}] + get: *jsonRead + /bff/delivery-packages/{id}: + parameters: [{$ref: '#/components/parameters/ID'}] + get: *jsonRead /bff/artifacts/{id}/presentation: parameters: [{$ref: '#/components/parameters/ID'}] get: @@ -627,7 +714,10 @@ paths: tags: [Review BFF] operationId: getReviewProjection responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: Token-bound legacy ScriptVersion or current SubmissionRevision projection. + content: + application/json: {schema: {$ref: '#/components/schemas/ReviewProjectionEnvelope'}} '401': {$ref: '#/components/responses/Error'} /review/{token}/verify: parameters: [{$ref: '#/components/parameters/ReviewToken'}] @@ -833,6 +923,136 @@ components: properties: {decision: {const: return}} then: required: [assignee_user_id] + Submission: + type: object + additionalProperties: false + required: [id, tenant_id, project_id, workspace_id, submission_type, status, current_revision_id, created_by, created_at, updated_at] + properties: + id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + project_id: {type: string, format: uuid} + workspace_id: {type: string, format: uuid} + submission_type: {type: string, enum: [knowledge, research, strategy, brief, script, delivery, performance]} + status: {type: string, enum: [submitted, in_review, internally_approved, client_review, approved, changes_requested, superseded]} + current_revision_id: {type: string, format: uuid} + created_by: {type: string} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + SubmissionRevision: + type: object + additionalProperties: false + required: [id, tenant_id, project_id, workspace_id, submission_id, revision_no, schema_version, content_hash, local_run_summary, objects, artifacts, idempotency_key, evidence_limited, created_by, created_at, source_disclosures] + properties: + id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + project_id: {type: string, format: uuid} + workspace_id: {type: string, format: uuid} + submission_id: {type: string, format: uuid} + revision_no: {type: integer, minimum: 1} + schema_version: {type: string} + content_hash: {type: string} + base_approved_snapshot_id: {type: string, format: uuid} + local_run_summary: {type: object} + objects: {type: array, items: {type: object}} + artifacts: {type: array, items: {type: object}} + message: {type: string} + idempotency_key: {type: string} + evidence_limited: {type: boolean} + created_by: {type: string} + created_at: {type: string, format: date-time} + source_disclosures: {type: array, items: {type: object}} + SubmissionDetails: + type: object + additionalProperties: false + required: [submission, revisions, comments] + properties: + submission: {$ref: '#/components/schemas/Submission'} + revisions: {type: array, items: {$ref: '#/components/schemas/SubmissionRevision'}} + comments: {type: array, items: {type: object}} + ApprovalDecision: + type: object + additionalProperties: false + required: [id, tenant_id, project_id, subject_type, subject_id, subject_hash, decision_stage, actor_id, decision, previous_state, resulting_state, created_at] + properties: + id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + project_id: {type: string, format: uuid} + subject_type: {type: string, enum: [submission_revision, script_version]} + subject_id: {type: string, format: uuid} + subject_hash: {type: string} + decision_stage: {type: string, enum: [internal, client, legacy]} + actor_id: {type: string} + decision: {type: string, enum: [approve, return, request_changes]} + reason: {type: string} + previous_state: {type: string} + resulting_state: {type: string} + created_at: {type: string, format: date-time} + ApprovedSnapshot: + type: object + additionalProperties: false + required: [id, tenant_id, project_id, workspace_id, submission_id, submission_revision_id, submission_type, schema_version, content_hash, subject_hash, canonical_content, eligible_ids, artifacts, decision_id, created_by, created_at, origin] + properties: + id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + project_id: {type: string, format: uuid} + workspace_id: {type: string, format: uuid} + submission_id: {type: string, format: uuid} + submission_revision_id: {type: string, format: uuid} + submission_type: {type: string, enum: [knowledge, research, strategy, brief, script, delivery, performance]} + schema_version: {type: string} + content_hash: {type: string} + subject_hash: {type: string} + canonical_content: {} + eligible_ids: {type: array, uniqueItems: true, items: {type: string}} + artifacts: {type: array, items: {type: object}} + decision_id: {type: string, format: uuid} + created_by: {type: string} + created_at: {type: string, format: date-time} + origin: {type: string, enum: [current, v1_import]} + external_ref: {type: string} + ApproveSubmissionInput: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 1} + RequestSubmissionChangesInput: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 1} + json_pointer: {type: string, pattern: '^(/([^~/]|~0|~1)*)*$'} + SubmissionReviewSubject: + type: object + additionalProperties: false + required: [submission_id, submission_revision_id, subject_hash, schema_version, objects] + properties: + submission_id: {type: string, format: uuid} + submission_revision_id: {type: string, format: uuid} + subject_hash: {type: string} + schema_version: {type: string} + objects: {type: array, items: {type: object}} + ReviewProjection: + type: object + additionalProperties: false + required: [project, comments, verified] + properties: + project: {type: object} + script: {type: object, description: Legacy ScriptVersion projection.} + submission: {$ref: '#/components/schemas/SubmissionReviewSubject'} + comments: {type: array, items: {type: object}} + verified: {type: boolean} + ReviewProjectionEnvelope: + type: object + additionalProperties: false + required: [ok, command, request_id, data, meta] + properties: + ok: {const: true} + command: {const: review.projection} + request_id: {type: string} + data: {$ref: '#/components/schemas/ReviewProjection'} + meta: {type: object} ReviewCycle: type: object additionalProperties: false @@ -841,7 +1061,7 @@ components: id: {type: string, format: uuid} tenant_id: {type: string, format: uuid} project_id: {type: string, format: uuid} - subject_type: {const: script_version} + subject_type: {type: string, enum: [submission_revision, script_version]} subject_id: {type: string, format: uuid} cycle_number: {type: integer, minimum: 1} status: {type: string, enum: [open, approved, changes_requested, superseded]} @@ -859,7 +1079,7 @@ components: id: {type: string, format: uuid} tenant_id: {type: string, format: uuid} project_id: {type: string, format: uuid} - subject_type: {const: script_version} + subject_type: {type: string, enum: [submission_revision, script_version]} subject_id: {type: string, format: uuid} subject_hash: {type: string} reviewer_email: {type: string, format: email} @@ -932,13 +1152,14 @@ components: Artifact: type: object additionalProperties: false - required: [id, tenant_id, project_id, script_version_id, kind, capability_id, capability_version, capability_digest, schema_id, media_type, file_name, sha256, byte_size, visibility, retention_class, purpose, validation_status, presentation_tier, metadata, created_at] + required: [id, tenant_id, project_id, kind, capability_id, capability_version, capability_digest, schema_id, media_type, file_name, sha256, byte_size, visibility, retention_class, purpose, validation_status, presentation_tier, metadata, created_at] properties: id: {type: string, format: uuid} tenant_id: {type: string, format: uuid} project_id: {type: string, format: uuid} + approved_snapshot_id: {type: string, format: uuid} script_version_id: {type: string, format: uuid} - kind: {type: string, enum: [core, export, extension, rendition, review_projection]} + kind: {type: string, enum: [core, export, extension, rendition, review_projection, delivery]} capability_id: {type: string} capability_version: {type: string} capability_digest: {type: string} @@ -958,6 +1179,32 @@ components: presentation_tier: {type: string, enum: [cloud_native, hosted_preview, safe_rendition, local_open, metadata_only]} metadata: {type: object} created_at: {type: string, format: date-time} + ExportApprovedSnapshotInput: + type: object + additionalProperties: false + required: [format] + properties: + script_id: {type: string, description: Required only when a snapshot contains multiple script objects.} + format: {type: string, enum: [json, markdown, xlsx]} + CreateDeliveryPackageInput: + type: object + additionalProperties: false + properties: + script_id: {type: string, description: Required only when a snapshot contains multiple script objects.} + DeliveryPackage: + type: object + additionalProperties: false + required: [id, tenant_id, project_id, approved_snapshot_ids, script_id, status, manifest, created_by, created_at] + properties: + id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + project_id: {type: string, format: uuid} + approved_snapshot_ids: {type: array, minItems: 1, uniqueItems: true, items: {type: string, format: uuid}} + script_id: {type: string} + status: {const: ready} + manifest: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Artifact'}} + created_by: {type: string} + created_at: {type: string, format: date-time} ArtifactPresentation: type: object additionalProperties: false @@ -1020,11 +1267,17 @@ components: PerformanceObservationInput: type: object additionalProperties: false - required: [script_version_id, platform, account_alias, published_at, window_hours, sample_status, metrics] + required: [platform, account_alias, published_at, window_hours, sample_status, metrics] + oneOf: + - required: [approved_snapshot_id] + not: {required: [script_version_id]} + - required: [script_version_id] + not: {required: [approved_snapshot_id]} properties: row_number: {type: integer, minimum: 1} project_id: {type: string, format: uuid} - script_version_id: {type: string, format: uuid} + approved_snapshot_id: {type: string, format: uuid} + script_version_id: {type: string, format: uuid, deprecated: true, description: Read/import compatibility for V1 records only; mutually exclusive with approved_snapshot_id.} platform: {type: string, minLength: 1} account_alias: {type: string, minLength: 1} published_at: {type: string, format: date-time} @@ -1071,14 +1324,15 @@ components: PerformanceObservation: type: object additionalProperties: false - required: [id, tenant_id, project_id, import_batch_id, row_number, script_version_id, platform, account_alias, published_at, window_hours, sample_status, metrics, spend, gmv, dedup_key, issue_category, notes, created_at] + required: [id, tenant_id, project_id, import_batch_id, row_number, platform, account_alias, published_at, window_hours, sample_status, metrics, spend, gmv, dedup_key, issue_category, notes, created_at] properties: id: {type: string, description: UUID when persisted; empty only in a dry-run result.} tenant_id: {type: string, format: uuid} project_id: {type: string, format: uuid} import_batch_id: {type: string} row_number: {type: integer, minimum: 1} - script_version_id: {type: string, format: uuid} + approved_snapshot_id: {type: string, format: uuid} + script_version_id: {type: string, format: uuid, deprecated: true} platform: {type: string} account_alias: {type: string} published_at: {type: string, format: date-time} @@ -1114,7 +1368,7 @@ components: required: [subject_type, subject_id, observation_ids, rating, reason, next_action] properties: project_id: {type: string, format: uuid, description: CLI transport only; Web BFF derives it from the route.} - subject_type: {type: string, enum: [script_version, content_framework, shot_pattern]} + subject_type: {type: string, enum: [approved_snapshot, script_version, content_framework, shot_pattern]} subject_id: {type: string, format: uuid} observation_ids: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, format: uuid}} rating: {type: string, enum: [insufficient_sample, seed_candidate, repairable, discarded]} @@ -1129,7 +1383,7 @@ components: id: {type: string, description: UUID when persisted; empty only in a dry-run result.} tenant_id: {type: string, format: uuid} project_id: {type: string, format: uuid} - subject_type: {type: string, enum: [script_version, content_framework, shot_pattern]} + subject_type: {type: string, enum: [approved_snapshot, script_version, content_framework, shot_pattern]} subject_id: {type: string, format: uuid} observation_ids: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, format: uuid}} rating: {type: string, enum: [insufficient_sample, seed_candidate, repairable, discarded]} diff --git a/docs/roadmap/plugin/README.md b/docs/roadmap/plugin/README.md new file mode 100644 index 0000000..6146510 --- /dev/null +++ b/docs/roadmap/plugin/README.md @@ -0,0 +1,1638 @@ +# ContentCloud 创作环境与插件控制面方案 + +状态:`Proposal`,等待确认后实施。 + +更新时间:2026-07-26。 + +首个目标宿主:Codex。 + +后续目标宿主:Claude Code、OpenClaw、WorkBuddy。后续宿主的插件契约需要分别验证,本方案不假设它们与 Codex 完全兼容。 + +## 1. 这次要解决的真正问题 + +ContentCloud 不是简单地“给 Codex 加一个插件”。 + +真正目标是:ContentCloud 根据具体业务场景,为用户准备一套精选、版本化、可升级、可重置的 AI 创作环境;Codex、Claude Code、OpenClaw、WorkBuddy 只是承载这套环境的可替换 Agent Harness。 + +当前 AI 视频场景需要在本地 Agent 中完成一条连续生产链: + +```text +原始文档与数据 + -> 本地解析与证据 + -> 客户知识库 + -> 策略与 Brief + -> 营销视频剧本 + -> 图片、音频和其他素材 + -> 视频生成与组装 + -> ContentCloud 提交审核 + -> 投放结果与下一轮改进 +``` + +用户不应该从一个包含几十或上百插件的市场中自行选型。ContentCloud 应该把经过验证的 Skills、MCP 和确定性工具组合成面向场景的产品能力,并在用户点击“初始化本地工作区”后,通过一次 Prompt 把正确环境交付给本地 Agent。 + +因此,本方案的产品对象不是“插件列表”,而是“创作环境”。插件系统只是创作环境在不同 Agent Harness 上的交付机制。 + +你的原始想法与本方案的对应关系: + +| 原始想法 | 架构表达 | +| --- | --- | +| “只用我开发的插件” | ContentCloud 精选 Marketplace,只发布自有或经过审核并产品化的场景插件、Skill Pack 和 Provider Pack | +| “排除乱七八糟的插件” | 用户不选插件,项目只绑定一个 `CreativeEnvironmentProfile` | +| “服务端控制安装什么” | 服务端签发 `CreativeEnvironmentManifest`,声明受控插件集合、版本、Workspace Template 和 capability | +| “用户就复制 Prompt 到 Codex” | Web ConnectSession 生成带 plugin mention 的一次性连接 Prompt | +| “无感知安装需要的周边” | Codex 在同一任务中完成安装和 Continue;用户只确认业务能力与权限,不手工拼装组件 | +| “文档 -> 知识库 -> 剧本 -> 素材 -> 视频” | 必装场景插件编排核心流程,并按任务解析少量精选 Skill Pack/Provider Pack | +| “创作时把需要的 Skills 随剧本下发” | 剧本业务对象保持纯净;服务端并行签发 `CreativeExecutionBundle`,锁定所需 capability 和 Skill Pack 引用 | +| “一个 Codex 文件夹下有多个对话” | 文件夹是共享 Workspace;每个对话绑定独立本地 session/Run,通过 `HandoffRecord` 和 digest 交接,不共享模型上下文 | +| “创作环境可以升级、重置” | `environment.lock` 加 plan/apply/doctor/upgrade/reset 生命周期 | +| “Loop 调度 AI Agent” | Automation Plan 调度 capability,不调度具体 Skill、Prompt 或模型 | +| “AI Agent 和业务必须解耦” | Harness Adapter 负责交付,业务层只依赖 capability 与 Schema | +| “借鸡生蛋” | 复用 Codex/Claude 等成熟 Agent 运行时,不自建通用 Agent 基础设施 | + +## 2. 核心结论 + +### 2.1 产品结论 + +1. 项目总览中的“初始化本地工作区”弹窗是唯一主入口。 +2. 用户只需要复制一次项目连接 Prompt 到 Codex;不需要理解 Marketplace、Skill、MCP、CLI 和版本组合。 +3. 初始化完成后,一个 ContentCloud Workspace 作为 Codex 项目文件夹;用户可以按资料、剧本、素材、审核等任务创建多个对话。 +4. 对话历史不是共享业务状态。跨对话交接必须落到版本化业务文件、`LocalRunContext` 和 `HandoffRecord`,同一 Run 只允许一个写入者。 +5. ContentCloud 服务端为项目选择一个经过审核的 `CreativeEnvironmentProfile`,并从自有精选市场解析出受控插件组合,不让初级用户自由拼装插件。 +6. 用户界面展示“资料整理、知识库、剧本、素材、视频”等业务能力,以及本次会安装或升级什么;市场存在于供给和治理侧,不成为初始化时的插件选择题。 +7. 当前 AI 视频场景优先交付一个必装场景插件;只有确有独立价值、权限边界或更新周期的能力才做成少量 Skill Pack/Provider Pack 插件。 +8. 创作环境可以升级和重置,但升级必须可见、可预览、可审计,不能在活动任务中静默热替换。 +9. 普通交互式创作和 Automation Loop 复用同一套业务 capability 与环境版本,但执行模型不同。 + +### 2.2 工程结论 + +1. ContentCloud 自己维护精选 Marketplace,不依赖 `wshobson/agents` 的完整市场作为运行时。 +2. 借鉴 `wshobson/agents` 的单一事实源、Harness Adapter、渐进披露和生成物校验,不引入其 94 个通用插件。 +3. Codex 首版使用原生 `.codex-plugin/plugin.json`、`.agents/plugins/marketplace.json`、Skills 和 bundled MCP;Codex Marketplace 的安装单元统一是 plugin,精选 Skills 以 Skill Pack Plugin 发布。 +4. `contentcloud` CLI 继续是所有本地确定性执行、凭据、云端通信、环境锁和升级计划的唯一程序化边界。 +5. 服务端声明期望环境,客户端负责在用户授权下协调本机 Harness。服务端不直接执行 `codex plugin add`,也不能绕过 Codex 权限。 +6. 业务层继续依赖稳定 capability ID 和 JSON Schema,不依赖 Codex、Claude、模型、Prompt 或具体 Skill 路径。 +7. Harness 差异放入 Adapter,业务对象和 Automation Plan 不按宿主分叉。 +8. 多对话并发由本地 RunClaim、context revision 和原子版本写入解决;服务端不保存或同步 Codex transcript。 + +### 2.3 必须承认的宿主边界 + +在用户自己的通用 Codex 环境中,ContentCloud 不能: + +- 绕过 Codex 的安装确认静默执行插件代码。 +- 删除或隐藏 OpenAI 和用户已有的其他 Marketplace。 +- 在没有企业管理员策略的情况下强制禁止用户使用其他插件。 +- 假装 Web 页面能够读取本机 Codex 的插件安装状态。 +- 保证新安装插件在任意旧会话中无需 Continue、新会话或刷新即可生效。 + +ContentCloud 可以做到: + +- 提供一个只包含 ContentCloud 精选场景插件、Skill Pack 和 Provider Pack 的 Marketplace。 +- 由服务端决定每个项目和服务端来源任务允许使用的插件与版本;普通本地对话由本地 Resolver 在该 allowlist 内选择组合。 +- 在 Web 生成带 plugin mention 的 Prompt,让 Codex 在同一任务中处理“需要安装 -> 安装 -> Continue -> 初始化”。 +- 用项目级 AGENTS、Environment Lock、CLI/MCP 契约和 capability allowlist 约束 ContentCloud 项目内的工作流。 +- 在 ChatGPT Enterprise/Business 管理能力允许时,由管理员进一步限制插件可用性和本地运行策略。 + +这是一种“低摩擦、有感知、可审计”的安装,不是绕过宿主安全机制的隐形安装。 + +## 3. 产品模型:创作环境而不是插件市场 + +### 3.1 Creative Environment + +`CreativeEnvironmentProfile` 表示一个面向业务场景的完整本地创作环境,例如: + +```text +contentcloud.video-production +``` + +它对用户表达的是: + +- 能处理哪些输入。 +- 能完成哪些业务阶段。 +- 输出什么受治理的结果。 +- 当前版本和更新状态。 +- 哪些能力需要本地文件、网络、第三方账号或额外确认。 + +它内部才映射到: + +- 一个必装场景插件。 +- 零个或少量精选 Skill Pack Plugin。 +- 零个或少量 Provider/MCP Pack Plugin。 +- 一个或多个 MCP Server。 +- ContentCloud CLI 与确定性 Schema。 +- Workspace Template。 +- Automation Capability Manifest。 + +### 3.2 当前推荐的插件组合 + +Codex MVP 的环境基座是一个面向 AI 视频生产的必装场景插件: + +```text +contentcloud-video-production +``` + +该插件内部按单一职责组织核心 Skills,而不是把每个 Skill 变成一个需要用户安装的插件: + +```text +contentcloud-video-production + skills + contentcloud-workspace + contentcloud-source-intake + contentcloud-knowledge-extraction + contentcloud-strategy-brief + contentcloud-marketing-video-script + contentcloud-asset-production + contentcloud-video-production + contentcloud-review-and-revision + mcp + contentcloud-local +``` + +当前仓库已实现的能力先进入同一个场景插件: + +- `contentcloud-knowledge-extraction` +- `contentcloud-marketing-video-script` +- `contentcloud-local` MCP + +资料到知识、策略 Brief、素材、视频等能力按真实实现逐步加入。未实现的能力不能只靠 Skill 文案伪装成已交付功能。 + +在此基座上,ContentCloud Marketplace 可以精选少量配套插件: + +| 类型 | 示例 | 何时拆出 | 默认策略 | +| --- | --- | --- | --- | +| Scene Plugin | `contentcloud-video-production` | 承载场景入口、核心契约和基础编排 | 项目必装 | +| Skill Pack Plugin | `contentcloud-visual-storytelling`、`contentcloud-deep-research` | 方法论可独立复用,且有单独评测、版本或上下文成本 | Resolver 按任务选择 | +| Provider/MCP Pack Plugin | `contentcloud-media-provider-x` | 引入独立账号、网络权限、费用或供应商生命周期 | 用户授权后按需安装 | + +一个 Skill Pack Plugin 可以包含一个或多个高度内聚的 Skills、references、assets,以及只服务于这些 Skills 的 `scripts/`。它不是单个 Prompt 的别名,也不是从外部市场临时复制进来的未审核 Skill。 + +首批候选 Pack 应保持克制,例如研究、视觉叙事、素材生成、视频组装。只有在真实创作任务中证明需要后才进入市场,避免提前建设空目录。 + +### 3.3 为什么仍然不是多个细碎插件 + +- 用户只关心“AI 视频创作环境”,不关心内部组件数量。 +- Codex Marketplace 的安装单元是插件,但场景插件不应假设宿主能自动递归安装插件依赖;插件组合由 ContentCloud Resolver 显式计算和锁定。 +- 大量可选项会把产品责任推给缺乏技术背景的用户。 +- 每个 Skill 都独立打包会增加安装、版本和新会话协调成本。 +- 当前只有一个明确业务场景,把所有潜在能力提前拆成通用市场违反 YAGNI。 + +未来出现真正独立的业务场景,例如超级售前时,再增加新的 Scene Plugin;跨场景复用且能独立治理的方法论才提升为 Skill Pack,而不是把一个场景的每个步骤都暴露为安装项。 + +## 4. ContentCloud 精选 Marketplace + +### 4.1 定位 + +ContentCloud Marketplace 不是复制 OpenAI 公共市场,也不是把 `wshobson/agents` 全部转售给用户。 + +它是 ContentCloud 自己审核、签名和发布的创作能力目录。Codex 侧仍使用原生 Marketplace 格式作为安装索引,ContentCloud 服务端另有一份治理注册表记录产品元数据和准入结论: + +```text +.agents/plugins/marketplace.json +plugins/ + contentcloud-video-production/ + contentcloud-visual-storytelling/ + contentcloud-media-provider-x/ + +ContentCloud Marketplace Registry + scene_plugins + curated_skill_packs + provider_mcp_packs +``` + +三层市场分别承担: + +- **Scene Plugins**:提供完整场景入口、核心工作流和业务契约,一个项目至少绑定一个。 +- **Curated Skill Packs**:提供可跨任务复用的精选创作方法论;服务端为 Profile/云端任务签名允许范围,本地 Resolver 为普通对话按 intent 选择。 +- **Provider/MCP Packs**:连接外部生成、搜索或媒体服务,单独声明账号、网络、费用和数据出境边界。 + +每个市场条目至少记录:稳定 ID、类型、展示能力、兼容的 Profile、插件版本、源码来源、许可证、digest、签名、权限、数据流、输出 Schema、评测结果、发布状态和撤回状态。Codex 的 `marketplace.json` 是可发现性与安装元数据,不替代这份治理事实源。 + +规则: + +- 只收录与 ContentCloud 业务场景直接相关的插件。 +- 每个插件必须对应清晰业务能力和验收用例。 +- 第三方 Skill/MCP 必须经过许可证、来源、安全、权限和输出契约审核。 +- 借鉴第三方实现时,转化为 ContentCloud 的事实源、Schema 和治理边界,不在运行时拉取一个庞大外部 Marketplace。 +- 普通用户不在初始化流程中浏览和拼装 Marketplace 条目;服务端返回推荐组合,用户只确认能力、权限和费用变化。 +- 管理员或高级用户后续可以查看“创作能力中心”,但只能在项目 Profile 允许范围内启停受审核 Pack,不能导入任意远程代码。 +- 市场条目被撤回后禁止新安装;已有环境按风险等级提示升级、停用或阻止新的 Automation Run,不静默破坏历史产物。 + +### 4.2 市场的三个产品表面 + +| 表面 | 使用者 | 职责 | +| --- | --- | --- | +| Marketplace Publishing Console | ContentCloud 产品、安全和研发 | 提交、审核、评测、签名、发布、灰度、废弃和撤回条目 | +| 创作能力中心 | 租户管理员和高级用户 | 查看精选能力、适用场景、权限、费用、版本、更新状态和项目启用策略 | +| Harness Distribution | Codex/其他 Adapter | 把已解析组合渲染为 `marketplace.json`、plugin mention 和宿主原生安装/验证动作 | + +首版必须先有 Registry、发布校验和 Codex 分发,不要求立即完成一个完整商店 UI。发布生命周期统一为: + +```text +draft -> security_review -> evaluated -> published -> deprecated -> revoked +``` + +普通创作者仍从具体项目或剧本任务进入,不先逛市场。市场负责保证“可选的东西是少而正确的”,Resolver 负责保证“这次只下发真正需要的”。 + +### 4.3 `wshobson/agents` 的作用 + +可借鉴: + +- `plugins//` 作为内容事实源。 +- 不同 Harness 使用适配器生成原生产物。 +- Skill 使用渐进披露,主 `SKILL.md` 保持聚焦,细节进入 `references/`。 +- manifest、生成物和 Harness 行为进入自动校验。 +- 插件按单一业务目的组织。 + +不采用: + +- 不把其全部插件暴露给 ContentCloud 用户。 +- 不让 ContentCloud 依赖它的仓库可用性和发布节奏。 +- 不直接复制未经产品和安全验证的 Agent Prompt。 +- Codex MVP 不先建设五种 Harness 的完整生成框架。 + +### 4.4 与 YouMind 等内容产品的关系 + +可以研究其内容相关 Skill/MCP 的场景拆分、输入输出和交互质量,但应执行以下流程: + +1. 提取可验证的用户任务和工作流模式。 +2. 检查许可证与可复用范围。 +3. 重新绑定 ContentCloud 的 Evidence、Knowledge、Brief、ScriptPackage、Submission 和 ApprovedSnapshot 契约。 +4. 通过 ContentCloud 自己的安全与质量评测。 +5. 作为 ContentCloud 场景能力发布,而不是直接把第三方插件市场交给用户。 + +## 5. 控制面的事实与控制链 + +```mermaid +flowchart TB + K[Curated Marketplace Registry
受审核能力供给] --> V[Environment Resolver
解析版本与组合] + P[CreativeEnvironmentProfile
业务期望环境] --> V + V --> M[CreativeEnvironmentManifest
项目基线交付清单] + T[Server Task / Governed Snapshot
服务端冻结输入] --> B[CreativeExecutionBundle
服务端执行绑定] + K --> B + Q[Codex Intent + LocalRunContext
普通本地创作] --> E[LocalExecutionPlan
本地临时执行计划] + M --> E + K --> E + M --> A[Harness Adapter
Codex first] + B --> A + E --> A + A --> I[Installed Plugin + Skills + MCP] + I --> L[environment.lock
本机实际状态] + I --> C[Capability Manifest
可执行业务能力] + B --> R[Local Run / Automation Run] + C --> R +``` + +### 5.1 Profile:业务选择 + +服务端项目只选择受支持的业务 Profile,例如 `contentcloud.video-production`。普通用户不编辑插件列表。 + +Profile 决定: + +- 业务阶段和能力范围。 +- 推荐 Workspace Template。 +- 允许的发布与审核策略。 +- 对应的环境版本。 +- 允许使用的 Scene/Skill Pack/Provider Pack 范围和选择策略。 + +### 5.2 Environment Manifest:项目基线交付 + +服务端签名的 `CreativeEnvironmentManifest` 描述如何把 Profile 的项目基线交付到某个 Harness: + +```json +{ + "schema_version": "1.0", + "profile_id": "contentcloud.video-production", + "profile_version": "1.0.0", + "environment_version": "2026.07.1", + "harness": "codex", + "distribution": { + "marketplace": "contentcloud", + "plugins": [ + { + "id": "contentcloud-video-production", + "kind": "scene", + "version": "0.4.0", + "source_ref": "v0.4.0", + "digest": "sha256:...", + "required": true, + "scope": "environment" + }, + { + "id": "contentcloud-visual-storytelling", + "kind": "skill_pack", + "version": "1.2.0", + "source_ref": "v1.2.0", + "digest": "sha256:...", + "required": false, + "scope": "task" + } + ] + }, + "workspace_template": { + "id": "workspace_marketing_video", + "version": "2.1.0", + "digest": "sha256:..." + }, + "capabilities": [ + "contentcloud.source.ingest", + "contentcloud.knowledge.extract", + "contentcloud.script.generate" + ], + "policies": { + "publish_requires_confirmation": true, + "automation_enabled": false, + "background_upgrade": false + }, + "issued_at": "...", + "signature": "..." +} +``` + +Manifest 禁止包含: + +- connect key 和长期凭据。 +- 模型密钥。 +- 客户原始内容。 +- 本地绝对路径。 +- 完整 Prompt 或 Agent transcript。 + +其中 `environment` scope 表示项目基线必须安装并持续验证;`task` scope 表示该 Pack 已进入项目 allowlist,是否在某次任务中启用由 `CreativeExecutionBundle` 决定。这里的 scope 是 ContentCloud 的治理语义,不假设 Codex 能把一个已安装插件对其他会话完全隐藏。 + +### 5.3 Execution Bundle:任务需要什么创作能力 + +`CreativeExecutionBundle` 是服务端针对服务端来源任务或受治理快照签发的执行绑定。它可以与剧本、Brief、ApprovedSnapshot 或 Automation Task 一起返回,但不嵌入这些业务对象: + +```json +{ + "schema_version": "1.0", + "bundle_id": "ceb_...", + "profile_id": "contentcloud.video-production", + "environment_version": "2026.07.1", + "subject": { + "type": "script_package", + "id": "aps_...", + "digest": "sha256:..." + }, + "required_capabilities": [ + { + "id": "contentcloud.asset.generate", + "schema_version": "1.0", + "digest": "sha256:..." + } + ], + "skill_packs": [ + { + "id": "contentcloud-visual-storytelling", + "plugin_version": "1.2.0", + "digest": "sha256:...", + "scope": "task", + "required": true + } + ], + "issued_at": "...", + "expires_at": "...", + "signature": "..." +} +``` + +约束: + +- `ScriptPackage`、Brief 等继续只表达 provider-neutral 的业务事实和来源,不承载 Skill 正文、Prompt、安装命令或任意代码。 +- Bundle 只引用 Marketplace 中已审核、签名和版本锁定的 Pack;客户文档、剧本正文和模型输出不能动态生成可执行 Skill。 +- 如果“随脚本下发”指 Skill 自带的辅助程序,它必须位于插件自己的 `scripts/` 中,随插件版本发布和审核,不能经业务 ScriptPackage 下发。 +- Bundle 必须绑定冻结业务对象 digest 和环境版本,避免同一个剧本在未知工具组合下被重放。 +- Provider Pack 的选择还必须满足租户策略、用户授权、地区、费用和数据流限制。 + +普通用户在 Codex 中基于本地资料发起创作时,不为了取得 Bundle 而强制联网,也不创建云端 TaskRun;这条路径使用下一节的 `LocalExecutionPlan`。 + +### 5.4 Local Execution Plan:普通对话如何选能力 + +`LocalExecutionPlan` 是 Scene Plugin 根据用户 intent、`LocalRunContext`、本地签名 Environment Manifest 和已缓存市场兼容矩阵生成的临时计划: + +```json +{ + "plan_id": "lep_...", + "run_id": "local-run-...", + "intent": "script_generate", + "required_capabilities": ["contentcloud.script.generate"], + "skill_packs": ["contentcloud-visual-storytelling@1.2.0"], + "input_refs": ["approved-snapshot:aps_..."], + "environment_digest": "sha256:...", + "requires_server": false +} +``` + +它只在本机生效,不是服务端授权,也不能突破 Profile allowlist。若所需 Pack 已安装且 digest 匹配,直接进入本地 Run;若缺失,计划转为显式环境准备,用户确认后才访问市场或服务端。发布时将计划摘要和环境 digest 作为 provenance 提交,服务端仍独立验证业务 Schema、权限和基线。 + +### 5.5 Environment Lock:本机实际状态 + +本地 `.contentcloud/environment.lock` 记录: + +- Profile、Environment、Workspace Template 版本。 +- Harness 类型和 Adapter 版本。 +- 已应用插件 ID、版本和 digest。 +- 环境级 Pack、已缓存任务级 Pack,以及最近验证结果;任务激活记录进入 Run 审计,不把临时状态伪装成项目基线。 +- Skill/MCP/Schema 的内容 hash。 +- 安装时间、最近 doctor、更新可用状态。 +- 本机实际 capability digest。 + +它不记录其他非 ContentCloud 插件,不把用户整个 Codex 环境上传云端。 + +### 5.6 Capability Manifest:业务执行能力 + +现有 Automation Capability Manifest 继续表达业务能力,而不是插件实现: + +```text +contentcloud.knowledge.extract +contentcloud.script.generate +contentcloud.asset.generate +contentcloud.video.compose +``` + +服务端 Automation Plan 依赖 capability ID、版本、Schema 和 digest。它不依赖 `codex`、Skill 文件名或具体模型。 + +这保持了“AI Agent 与业务解耦”:Environment Manifest 负责项目基线交付,Local Execution Plan 负责普通本地对话,Execution Bundle 负责服务端来源任务的受控能力绑定,Capability Manifest 负责证明本机能执行什么,业务计划仍只依赖 capability 和 Schema。 + +## 6. 与现有 Web 初始化流程结合 + +### 6.1 唯一入口 + +继续使用项目总览中的“初始化本地工作区”弹窗和现有 ConnectSession: + +```text +waiting_for_computer -> verifying -> connected +``` + +不创建独立插件安装页面,不创建第二套连接状态机。 + +弹窗中的用户流程改为: + +```text +初始化本地工作区 + +本项目将配置“AI 视频创作环境” +包含:资料整理、知识库、Brief、剧本 +精选能力:视觉叙事(创作需要时启用) +后续版本:素材与视频生产 + +1. 在 Codex 中进行 +2. 复制连接 Prompt +3. 粘贴并发送 + +[复制 Prompt] [打开 Codex] + +等待 Codex +连接码于 23:12:25 失效 + +使用 Claude Code / 手动 CLI +``` + +用户看到业务能力、配套 Pack、权限和安装进度,但不需要理解或手工选择插件 ID。 + +### 6.2 Web 生成的 Codex Prompt + +生产环境中,Prompt mention 服务端选择的必装场景插件。若项目基线还要求其他 Pack,Codex Adapter 为每个必装插件渲染一个 mention;下面只展示最小场景: + +```text +[@ContentCloud Video Production](plugin://contentcloud-video-production@contentcloud) +Initialize the ContentCloud creative environment for this project. After setup, +continue in a Codex chat whose workspace path is the verified project root. + +bootstrap-url: https://content.example.com/api/bootstrap +server-url: https://content.example.com +connect-key: cck_xxx +project: "品牌 / 单品" +environment-profile: contentcloud.video-production +``` + +行为: + +- 所需插件均已安装:Codex 直接调用场景插件的 workspace Skill。 +- 任一必装插件对用户可见但未安装:Codex 展示安装,用户确认后点击 Continue,继续同一任务。 +- 任一必装插件不可发现:Codex 无法自动完成,Web 保持 `waiting_for_computer` 并显示具体插件的分发错误。 + +连接码仍只用于项目绑定,不用于插件市场认证。 + +### 6.3 Deep Link 与秘密处理 + +可以提供“复制并打开 Codex”按钮,但必须分开处理: + +1. 把完整连接 Prompt 写入剪贴板。 +2. 用不含 query 的 `codex://threads/new` 打开 Codex。 +3. 用户粘贴并发送 Prompt。 + +不能把 connect key 放入 `codex://new?prompt=...`,否则秘密可能进入浏览器历史、系统协议记录和遥测。 + +网页无法可靠读取本机 Codex 插件状态,因此不能显示伪造的“插件已安装”。Web 只显示自己能验证的 ConnectSession 和 WorkspaceRegistration 状态。 + +### 6.4 服务端状态闭环 + +```mermaid +sequenceDiagram + actor U as 用户 + participant W as ContentCloud Web + participant S as ConnectSession + participant C as Codex + participant P as Scene Plugin + Packs + participant CLI as contentcloud CLI + participant API as CLI Gateway + + U->>W: 点击初始化本地工作区 + W->>API: 创建ConnectSession并解析项目环境Profile + API-->>W: connect key + Environment摘要 + plugin mention + W-->>U: 复制Prompt并打开Codex + U->>C: 粘贴并发送 + alt 所需插件未安装但可发现 + C->>U: 展示受控插件组合和权限 + U->>C: 确认并Continue + end + C->>P: 恢复项目初始化任务 + P->>CLI: environment plan + init dry-run + CLI-->>U: 展示目录、环境版本、Scene/Skill/Provider Pack和权限变化 + U->>P: 确认应用 + P->>CLI: init --target codex-plugin + CLI->>API: 消费connect key + API->>S: waiting_for_computer -> verifying + CLI->>CLI: 写workspace与environment.lock + CLI->>API: workspace.register + capability manifest digest + API->>S: verifying -> connected + CLI-->>C: verified workspace root + non-secret resume context + C-->>U: 在该文件夹继续;必要时打开绑定path的新对话 + W->>S: 每2秒轮询 + W-->>U: 创作环境已就绪 +``` + +### 6.5 从引导对话切换到项目文件夹 + +Web 打开的第一条 Codex 对话是 bootstrap conversation,不应默认成为长期项目对话,因为它可能没有绑定最终工作目录,也可能尚未重新加载刚安装的项目配置。 + +初始化完成后分两种情况: + +- 当前 Codex 对话已经以最终 Workspace Root 为 active path,且新插件/项目配置在 Continue 后可用:原对话可以继续,但仍要重新运行 workspace probe。 +- CLI 在当前目录下创建了新的项目子目录,或宿主要求新会话加载配置:由本地 Scene Plugin 提供“在项目文件夹中新建对话”,使用已验证的绝对 `path` 和不含秘密的恢复 Prompt。 + +恢复 Prompt 只包含 `workspace_id` 或本地 bootstrap handoff ID,例如: + +```text +[@ContentCloud Video Production](plugin://contentcloud-video-production@contentcloud) +Open the initialized ContentCloud workspace and verify its local registration. +workspace-id: ws_... +bootstrap-handoff: hnd_bootstrap_... +``` + +它不再包含 connect key。Web 只知道 ConnectSession 已连接,不保存或展示本机绝对路径;打开项目文件夹的动作由本地 Agent/CLI 完成。完成这一步后,该目录才是截图中 Codex 侧边栏的项目入口,后续多个对话都绑定同一个 path。 + +### 6.6 为什么需要 `codex-plugin` target + +现有 target 语义需要扩展: + +| Target | 含义 | +| --- | --- | +| `codex` | 旧路径,CLI 向项目写 `.agents/skills` 和 `.codex/config.toml` | +| `claude` | 项目级 Claude Code Skill/MCP | +| `codex-plugin` | Skills/MCP 由受控插件组合提供,项目只写 Workspace、AGENTS 和环境锁 | +| `none` | 不配置任何 Agent Harness | + +`codex-plugin` 不能复用 `none`,因为服务端、doctor、升级和 Automation 需要知道该工作区由哪种交付机制提供能力。 + +## 7. Codex 文件夹、多对话与业务交接协议 + +### 7.1 一个文件夹是共享工作区,不是一个大对话 + +在单台设备上,一个 `WorkspaceBinding` 对应一个 ContentCloud 项目文件夹。用户在 Codex 中打开该文件夹后,可以创建多个独立对话: + +```text +ContentCloud BrandProject + -> Device WorkspaceBinding + -> Codex project folder: marketing/ + -> 对话:项目总控 + -> 对话:资料与知识整理 + -> 对话:剧本批次 A + -> 对话:剧本 S001 的素材生产 + -> 对话:审核反馈修订 +``` + +这些对话名称只是用户界面标签,不是业务主键;真正的关联键是 `workspace_id`、`run_id`、`handoff_id` 和业务对象 digest。即使 Codex 的 Memory 或建议功能可用,也不能把它们当成受治理的交接机制。 + +Codex 的插件安装可发生在用户/宿主层,而 ContentCloud 的业务授权发生在文件夹层。即使另一个 Codex 文件夹也能发现同一个插件,它仍必须读取自己的 Environment Manifest、WorkspaceBinding 和 allowlist,不能访问或复用 `marketing/` 中的客户资源。 + +同一个云端 BrandProject 可以授权多台设备或多个本地 Workspace,但它们分别注册、分别持有环境锁和本地来源,不能把某台设备的文件路径当成全局事实。 + +Codex 多个对话之间: + +- **共享**:同一个文件夹、项目级 `AGENTS.md`/配置、Environment Lock、已安装插件、已批准快照、本地知识、版本化剧本与素材。 +- **不共享**:模型上下文、对话历史、未落盘推理、临时附件含义、某个对话“记得什么”。 +- **有条件共享**:正在编辑的 Run、草稿和环境变更,必须经过 claim、版本和 handoff 协议,不能靠两个对话同时猜测当前状态。 + +因此,对话只是交互入口,文件夹中的结构化状态才是跨对话事实源。任何需要另一个对话继续的结论,都必须先落入业务文件、`LocalRunContext` 或 `HandoffRecord`。 + +### 7.2 六个参与者的职责 + +| 参与者 | 负责 | 不负责 | +| --- | --- | --- | +| Codex Conversation | 理解用户意图、展示计划与结果、请求确认 | 保存跨对话业务状态、直接代表云端批准 | +| Scene Plugin | 对话入口、工作区探测、intent 路由、Run/交接协调 | 保存客户事实、绕过 Resolver 任意选插件 | +| Skill Pack | 执行聚焦方法论,生成符合 Schema 的候选内容 | 安装其他插件、直接修改云端业务状态 | +| Provider/MCP Pack | 调用经授权的外部能力并返回受约束结果 | 直接把 Provider 输出标记为已批准内容 | +| ContentCloud CLI/MCP | 本地确定性读写、lint、凭据、唯一云端 Gateway | 自主决定创作策略、上传完整对话历史 | +| ContentCloud Server | Profile/市场治理、签名 Manifest/Bundle、Submission、审批、Automation | 读取本机任意目录、执行本地 Agent 或持续同步草稿 | + +插件和业务的流转固定为: + +```text +用户消息 + -> Scene Plugin 识别 intent + -> Conversation Coordinator 选择新建/恢复 LocalRun + -> Environment Resolver 生成 LocalExecutionPlan 或验证 CreativeExecutionBundle + -> 选择已准入 Skill Pack/Provider Pack + -> MCP/CLI 读取冻结输入并写入版本化业务输出 + -> 确定性 Schema/lint + -> HandoffRecord、完成或 publish preflight + -> 用户确认后才通过 CLI Gateway 与服务端交换治理检查点 +``` + +Skill Pack 不拥有 ScriptPackage、Knowledge 或 Brief;它只是生成或转换这些业务对象的方法。业务对象始终由 Workspace Schema、hash、lineage 和审核状态治理。 + +### 7.3 多对话共享资源的并发规则 + +| 资源类型 | 示例 | 多对话规则 | +| --- | --- | --- | +| 不可变共享 | ApprovedSnapshot、已注册原始来源 hash、已发布 SubmissionRevision、市场条目 digest | 任意对话可读,不原地修改 | +| 版本化共享 | Knowledge Pack、Brief、ScriptPackage、Storyboard、素材清单 | 新建版本并记录 lineage,不覆盖其他 Run 的版本 | +| Run 独占写 | `LocalRunContext`、Run 草稿、当前 output path | 多个对话可读,同一时刻只有一个本地 session 可 claim 写入 | +| Workspace 独占写 | Environment apply/upgrade/reset、模板迁移、`sync-state` 更新 | 使用 Workspace 级锁;活动写 Run 中默认不执行环境变更 | +| 云端治理 | publish、approve、Automation lease | 通过服务端权限、幂等键、base snapshot 和 revision 校验 | + +当前 `work/current-run.json` 只能表达一个“当前 Run”,不适合作为多对话的权威状态。目标模型调整为: + +```text +project-root/ + .contentcloud/ + project.yaml + environment.lock + sync-state.json + cache/approved/ + inbox/ + locks/ + workspace-environment.claim + runs/.claim + work/ + runs/.json + handoffs/.json + outputs/ + scripts//.json + storyboards//.json + delivery// +``` + +`work/current-run.json` 在迁移期只作为单 Run 工作区的兼容指针;`contentcloud local run list` 必须通过扫描和校验 `work/runs/` 返回活动 Run。新对话恢复任务时必须显式使用 `run_id` 或 `handoff_id`,不能读取一个全局 current 指针后直接写入。 + +“文件在同一个目录里看得见”不等于“可以作为下一阶段输入”。其他对话只能消费已经完成 checkpoint、计算 digest,并被 LocalRunContext/HandoffRecord 标记为 eligible 的资源;不得扫描另一个活动 Run 的临时草稿后自行接续。 + +### 7.4 Run Claim:防止两个对话同时改同一任务 + +每个 Codex 对话第一次准备写某个 Run 时,由 Scene Plugin 通过 CLI 获取本地 `RunClaim`: + +```json +{ + "run_id": "local-run-...", + "session_id": "local-session-...", + "expected_context_revision": 7, + "mode": "write", + "claimed_at": "...", + "expires_at": "..." +} +``` + +规则: + +- `session_id` 由 ContentCloud 本地生成;Codex thread ID 仅在宿主可靠提供时作为可选审计字段,不能成为跨 Harness 主键。 +- 同一个 Run 可以被其他对话只读查看,但只能有一个未过期 write claim。 +- 每次更新 `LocalRunContext` 都使用 `context_revision` 做 compare-and-swap;revision 冲突立即停止写入并重新读取,不自动覆盖。 +- claim 获取必须由 CLI 使用操作系统文件锁或原子 exclusive-create 实现,不能采用“先检查 JSON 不存在,再写文件”的竞态实现;后续写入使用临时文件加原子 rename。 +- 活动对话在每次受管写入时续期 claim,不依赖模型自行运行后台心跳。 +- 对话正常结束或创建 handoff 时释放 claim。异常退出后只能在 claim 过期或用户检查现场并确认接管后重新 claim。 +- 不同 Run 可以并行,但不得写同一个版本化输出路径;输出使用唯一 ID、临时文件加原子 rename。 +- Environment install/upgrade/reset 使用 Workspace 级 claim,不能与活动写 Run 并发。 + +Run Claim 是本机并发保护,不访问服务端,也不把 Codex 对话内容上传云端。 + +### 7.5 HandoffRecord:对话之间交接什么 + +`HandoffRecord` 是跨对话交接的最小结构化对象: + +```json +{ + "schema_version": "1.0", + "handoff_id": "hnd_...", + "run_id": "local-run-...", + "context_revision": 8, + "intent": "storyboard_generate", + "stage": "inputs_ready", + "status": "ready", + "input_refs": [ + {"type": "script_package", "id": "script_v3", "digest": "sha256:..."} + ], + "output_refs": [ + {"type": "shot_plan", "path": "outputs/storyboards/...", "digest": "sha256:..."} + ], + "execution_ref": { + "type": "local_execution_plan", + "id": "lep_...", + "environment_digest": "sha256:..." + }, + "completed_checks": ["script-lint"], + "blockers": [], + "pending_decisions": ["确认视觉风格 A 或 B"], + "next_action": "选择风格后生成逐镜头画面提示", + "created_at": "..." +} +``` + +Handoff 禁止包含完整 transcript、隐藏思维过程、token、任意安装 URL 或未版本化的大段业务正文。它通过 ID 和 digest 引用已落盘资源,只保存接手者必须知道的状态、校验、阻断和下一动作。 + +Handoff 生命周期: + +```text +draft -> ready -> claimed -> completed + \-> superseded +``` + +创建 handoff 固定执行:保存当前业务输出 -> 运行阶段 lint -> 更新 LocalRunContext revision -> 写 HandoffRecord -> 释放旧 session claim。任何一步失败都不能向用户显示“可在新对话继续”。 + +### 7.6 新对话如何接管 + +每个绑定该文件夹的新对话首先处于只读 bootstrap 状态。项目级 `AGENTS.md` 与 `contentcloud-workspace` 入口 Skill 约定:首个业务请求先调用本地 `workspace conversation-context`,不立即 claim Run,也不访问服务端。 + +`WorkspaceConversationContext` 至少返回: + +```json +{ + "workspace_id": "ws_...", + "profile_id": "contentcloud.video-production", + "environment_health": "ready", + "active_runs": [], + "ready_handoffs": [], + "pending_local_decisions": [], + "cached_approved_inputs": [], + "review_inbox_count": 0, + "last_cloud_pull_at": "...", + "suggested_intents": [ + "导入资料", + "构建知识", + "生成剧本", + "继续已有任务" + ] +} +``` + +因此,截图中的“我们应该在 marketing 中做些什么?”应由本地项目状态回答:当前做到了哪里、有哪些可继续任务、缺什么输入、哪些结果待审核。`last_cloud_pull_at` 只提示本地缓存新鲜度;除非用户选择“检查云端更新”,不能为了回答这个问题自动联网。 + +当用户明确选择某个 ready handoff 时,可以在同一个 Codex 项目文件夹的新对话中粘贴: + +```text +[@ContentCloud Video Production](plugin://contentcloud-video-production@contentcloud) +继续 ContentCloud handoff: hnd_... +先验证工作区、输入 digest 和 Run claim,再继续下一阶段。 +``` + +本地插件也可以生成不含秘密的 `codex://new?path=...&prompt=...` 链接,在同一文件夹创建新对话。`path` 是本地敏感元数据,默认仍优先显示“复制交接 Prompt”;只有用户允许时才放入 Deep Link,connect key 和其他凭据永远不能进入 URL。 + +新对话的接管顺序固定为: + +```text +workspace probe(本地) + -> environment.lock / doctor --offline(本地) + -> handoff show + Schema/digest 校验(本地) + -> 检查 Handoff 是否已被其他 session claim(本地) + -> 获取 RunClaim(本地) + -> 重读 LocalRunContext 和全部 input refs(本地) + -> Resolver 验证 Pack/capability(通常本地) + -> 向用户摘要“已完成 / 待决定 / 下一步” + -> 用户确认继续后写入 +``` + +新对话不需要读取旧对话历史。若 handoff 引用的输入 digest 已变化、Run revision 不匹配或输出被另一个 Run 占用,必须停止接管并生成冲突报告。 + +### 7.7 一次 Codex 对话的状态机 + +```mermaid +stateDiagram-v2 + [*] --> workspace_probe + workspace_probe --> init_required: 未绑定 + workspace_probe --> task_select: 工作区健康 + init_required --> task_select: init + doctor 完成 + task_select --> run_claimed: 新建/恢复/接管 + run_claimed --> input_check + input_check --> pull_pending: 明确需要云端快照/反馈 + input_check --> environment_prepare: Pack 缺失 + input_check --> local_work: 输入与环境就绪 + pull_pending --> input_check: 用户确认 pull 完成 + environment_prepare --> input_check: 用户确认安装 + doctor + local_work --> checkpoint + checkpoint --> local_work: 继续当前对话 + checkpoint --> handoff_ready: 转交新对话 + checkpoint --> publish_pending: 业务输出可审核 + checkpoint --> done: 仅本地完成 + publish_pending --> review_waiting: 用户确认 publish + review_waiting --> revision_run: pull feedback + review_waiting --> done: ApprovedSnapshot + revision_run --> run_claimed + handoff_ready --> [*] + done --> [*] +``` + +对话状态不是云端状态。`workspace_probe`、intent、LocalRun、lint、checkpoint 和 handoff 全部可以离线完成;只有图中明确的 init、pull、环境准备、publish、状态查询和 Automation 节点与服务端交互。 + +### 7.8 服务端交互矩阵 + +| Codex 中的动作 | 是否访问 ContentCloud Server | 触发条件 | 交换内容 | +| --- | --- | --- | --- | +| 打开文件夹、新建对话 | 否 | 每个新对话 | 只读取本地项目状态 | +| workspace probe、conversation context、offline doctor | 否 | 对话开始 | Environment/Workspace hash、本地进度、handoff 与健康状态 | +| intent 路由、LocalExecutionPlan | 否 | 每个本地创作任务 | 本地 Run、allowlist、已缓存兼容矩阵 | +| 读取资料、知识、剧本,生成草稿 | 否 | 普通创作 | 本地文件和 MCP 结果 | +| Run claim、checkpoint、handoff | 否 | 多对话协作 | 本地结构化状态 | +| 创建 ConnectSession | 是,Web BFF | 用户点击初始化 | 项目 ID -> 一次性 connect key、Profile 摘要和 plugin mention | +| init 交换连接码 | 是,CLI Gateway | 用户确认初始化 | connect key/设备元数据 -> Workspace Credential、项目绑定、签名 Environment Manifest | +| pull approved/feedback/decisions | 是,CLI Gateway | 用户明确要求,或当前任务明确缺少云端治理输入 | 拉取不可变 Bundle 到 inbox/cache,不直接改业务文件 | +| 解析服务端下发任务 | 是,发生在 pull/lease | Web 分派、ApprovedSnapshot 配套流程或 Automation | 业务快照 + 签名 CreativeExecutionBundle | +| 检查环境更新 | 是,CLI Gateway | 用户显式执行 online doctor/check-update、Web 已提示更新,或服务端拒绝过旧 publish/lease | 当前环境 digest -> 可用版本和策略;不上传创作正文 | +| 安装/升级缺失 Pack | 视分发方式而定;必须联网 | Resolver 发现缺失且用户确认,Automation 则必须在租约前 | Codex 访问已配置 Marketplace/CDN;只有解析新 Manifest 时访问 CLI Gateway,不上传创作正文 | +| Provider 生成/搜索 | 否或访问第三方,不是 ContentCloud Server | 用户授权对应 Provider Pack | 按 Provider 数据边界交换最小输入 | +| publish preflight | 否 | 本地输出准备审核 | 本地 lint、披露、字节数、hash 和基线摘要 | +| publish apply | 是,CLI Gateway | 用户看到 preflight 后明确确认 | SubmissionBundle、允许披露、Run/环境 provenance -> SubmissionRevision | +| submission status / pull feedback | 是,CLI Gateway | 用户主动查询、Web 通知后拉取或项目策略要求 | 审核状态、反馈、DecisionDelta、ApprovedSnapshot | +| Automation poll/heartbeat/complete | 是,CLI Gateway | 用户显式启用 Daemon 和 Automation Plan | capability、Task Contract、进度、RunOutput;不使用聊天线程 | + +禁止的隐式交互:每条用户消息都 ping 服务端、上传 transcript、自动同步整个目录、为了 intent 分类发送客户正文、打开文件夹就安装 Pack、后台静默 publish,或用 Workspace Credential 执行审批。 + +每个 CLI/MCP tool 在元数据和返回 Envelope 中声明 `effect_scope`: + +| `effect_scope` | 执行规则 | +| --- | --- | +| `local_read` / `local_write` | 在 RunClaim 和 Workspace 边界内可直接执行;本地破坏性操作仍需确认 | +| `contentcloud_read` | 用户明确说“拉取、查询、检查更新”等即构成该次读取意图;不得后台轮询普通对话 | +| `contentcloud_write` | 必须先返回 preflight,再由用户确认;publish、环境注册等使用幂等键 | +| `provider_read` / `provider_write` | 遵循独立 Provider 授权、费用和数据披露,不能借用 Workspace Credential | +| `automation_machine` | 只允许 Daemon 使用 Device/Run Credential,普通对话和 Skill 不得调用 | + +Scene Plugin 根据 typed metadata 判断是否停下来确认,不通过解析命令名称或自然语言猜测副作用。 + +### 7.9 三条主要对话流 + +#### A. 普通本地创作:默认不联网 + +```mermaid +sequenceDiagram + actor U as 用户 + participant C as Codex Conversation + participant S as Scene Plugin + participant CLI as Local CLI/MCP + participant FS as Shared Workspace + participant P as Selected Skill Pack + + U->>C: 基于本地资料生成一批剧本 + C->>S: 进入ContentCloud场景 + S->>CLI: workspace probe + local run init/claim + CLI->>FS: 读取Environment、来源和冻结输入 + S->>CLI: resolve LocalExecutionPlan + CLI-->>S: 已安装Pack + capability + input refs + S->>P: 执行剧本方法论 + P->>CLI: query / write versioned ScriptPackage + CLI->>FS: lint + 更新LocalRunContext + S-->>U: 结果、校验和下一步;询问是否publish或handoff +``` + +只有用户选择 publish、pull 或发现缺失 Pack 并同意准备环境后,才进入服务端交互。 + +#### B. 服务端剧本与 Skill Pack 一起下发 + +```mermaid +sequenceDiagram + actor U as 用户 + participant C as Codex Conversation + participant S as Scene Plugin + participant CLI as CLI Gateway + participant API as ContentCloud Server + participant FS as Shared Workspace + participant P as Skill/Provider Pack + + U->>C: 拉取已批准剧本并继续素材生产 + C->>S: 解析为governed pull intent + S->>CLI: pull approved/work item + CLI->>API: Workspace Credential + project context + API-->>CLI: Approved ScriptPackage + CreativeExecutionBundle + CLI->>FS: 只读保存业务快照和Bundle + S->>CLI: 验证subject digest、环境版本和Pack refs + alt Pack已安装且匹配 + CLI-->>S: ready + else Pack缺失或不匹配 + CLI-->>U: 展示安装计划、权限和费用 + U->>C: 确认准备环境 + C->>CLI: environment apply + doctor + end + S->>P: 使用冻结剧本执行下一阶段 + P->>FS: 写本地版本化素材/视频候选 + S-->>U: 完成摘要、handoff或publish选项 +``` + +服务端返回的剧本文本始终作为数据读取。只有 Bundle 中经过签名的 Pack ref 进入 Resolver,剧本正文中的“请安装某工具”等内容不得触发安装。 + +#### C. Publish、审核与修订 + +```mermaid +sequenceDiagram + actor U as 用户 + participant S as Scene Plugin + participant CLI as CLI Gateway + participant API as ContentCloud Server + participant W as Reviewer Web + participant FS as Shared Workspace + + U->>S: 提交这版剧本审核 + S->>CLI: publish preflight(本地) + CLI->>FS: lint + hash + disclosure + base snapshot + CLI-->>U: 展示实际上传对象、披露和字节数 + U->>S: 明确确认 + S->>CLI: publish apply + CLI->>API: SubmissionBundle + idempotency key + API-->>CLI: immutable SubmissionRevision + review status + API->>W: 进入审核队列 + W->>API: 批准或提出修改 + U->>S: 查询/拉取审核结果 + S->>CLI: submission status / pull feedback + CLI->>API: 读取治理结果 + API-->>CLI: Feedback/Decision/ApprovedSnapshot + CLI->>FS: 写inbox或只读cache + S->>FS: 新建修订Run或完成当前Run +``` + +Codex 对话不需要保持在线等待审核。用户可以关闭原对话,之后在同一文件夹的新对话中通过 Submission ID、ApprovedSnapshot 或 HandoffRecord 继续。 + +### 7.10 对话交接示例 + +对话 A 完成剧本并准备交给素材对话: + +```text +用户:剧本先到这里,开一个新对话做分镜。 + +ContentCloud: +已创建 handoff hnd_01... +已冻结输入:script_v3 (sha256:...) +已通过:script-lint +下一阶段:storyboard_generate +需要:contentcloud-visual-storytelling@1.2.0 +待决定:视觉风格 A/B +当前 Run claim 已释放。 +``` + +对话 B 在同一个文件夹接管: + +```text +用户:继续 handoff hnd_01... + +ContentCloud: +工作区与输入 hash 验证通过。 +该 handoff 尚未被其他对话接管,已取得 local-run-... 的写 claim。 +上一步完成剧本 v3;当前待选择视觉风格 A/B。 +确认风格后我将生成分镜,不会重新生成或覆盖剧本。 +``` + +这段摘要由结构化状态渲染,不由模型回忆上一段对话。 + +### 7.11 Automation 不是“后台 Codex 对话” + +Automation 不复用任何可见聊天线程,也不靠 HandoffRecord 轮询工作: + +```text +Automation Plan + -> Scheduler 匹配 Capability/Environment/Pack digest + -> Daemon 获取 Task Contract + CreativeExecutionBundle + Run Credential + -> 隔离工作区执行 + -> progress/heartbeat + -> RunOutput 自动形成待审核 SubmissionRevision + -> 用户在 Web 或新的 Codex 对话查看结果 +``` + +HandoffRecord 解决的是本机交互式多对话协作;Task Contract、租约和 Run Credential 解决的是无人值守调度。二者不能混用。 + +### 7.12 需要新增的本地契约 + +在现有命令面上最小扩展: + +```text +contentcloud local run list +contentcloud local run claim --run --session +contentcloud local run release --run --session +contentcloud workspace conversation-context --offline +contentcloud local handoff create --run --next-intent +contentcloud local handoff list +contentcloud local handoff show +contentcloud local handoff accept --session +contentcloud environment plan --run +``` + +MCP 暴露对应的 typed tools,Scene Plugin 只调用这些契约,不用 shell 文本拼装状态。所有写命令返回新的 `context_revision`、修改文件清单和下一动作;`handoff accept` 必须原子完成 handoff claim 与 Run claim,避免两个新对话同时接管。 + +当前实现中的 `LocalRunContext.intent` 只允许 `ingest/query/content`。MVP 可以保留它作为粗粒度 Run 类别,但新增 `capability_id`/`next_capability_id` 表达 `script.generate`、`storyboard.generate` 等精确交接目标,避免持续扩张一个非版本化字符串枚举。 + +## 8. Codex 首版插件结构 + +```text +.agents/ + plugins/ + marketplace.json +plugins/ + contentcloud-video-production/ + .codex-plugin/ + plugin.json + .mcp.json + skills/ + embed.go + contentcloud-workspace/ + SKILL.md + references/ + environment-lifecycle.md + governance-boundaries.md + contentcloud-source-intake/ + SKILL.md + contentcloud-knowledge-extraction/ + SKILL.md + contentcloud-strategy-brief/ + SKILL.md + contentcloud-marketing-video-script/ + SKILL.md + references/ + ... + assets/ + icon.png + logo.png + contentcloud-visual-storytelling/ + .codex-plugin/ + plugin.json + skills/ + contentcloud-visual-storytelling/ + SKILL.md + references/ + scripts/ + assets/ +``` + +### 8.1 单一事实源 + +- Scene/Skill Pack 插件下的 Skills 是 Agent 方法论的事实源。 +- Go CLI 只从 canonical 插件目录 embed 其需要离线审计或兼容交付的同一份 Skills。 +- 不在根 `skills/` 和插件目录维护两份手工副本。 +- `.contentcloud/skills` 可以继续作为已安装环境的受管审计副本,但它是生成物,不是源码。 + +### 8.2 MCP + +场景插件先复用现有 `contentcloud mcp serve`: + +```json +{ + "contentcloud": { + "command": "npx", + "args": [ + "--yes", + "@limecloud/contentcloud@0.4.0", + "mcp", + "serve" + ] + } +} +``` + +要求: + +- npm、Go CLI、插件和 Environment Manifest 使用兼容版本。 +- MCP initialize 返回明确的服务器 instructions。 +- 工具有准确的 read-only、write、destructive、idempotent annotations。 +- 所有 ContentCloud 云端操作仍经过 CLI Gateway。 +- Provider MCP 必须经过 allowlist、权限和数据流审核,并优先作为独立 Provider Pack 发布,避免把可选账号权限塞入场景基座。 +- MCP 不持有 ContentCloud 长期 token,凭据仍在 OS Keychain。 + +### 8.3 场景编排 + +`contentcloud-workspace` 是入口路由 Skill,不承载所有业务方法论: + +- 检查 Environment Lock 和 Workspace 状态。 +- 根据用户 intent 路由到资料、知识、Brief、剧本、素材或视频 Skill。 +- 普通本地创作生成 `LocalExecutionPlan`;服务端来源任务读取并验证 `CreativeExecutionBundle`,两条路径都只启用允许且本机版本匹配的 Skill Pack/Provider Pack。 +- 新对话必须先选择新建、恢复或接管 LocalRun,并在写入前取得 RunClaim。 +- 只把当前任务需要的 references 和工具放进上下文。 +- publish 前展示 preflight 并等待确认。 +- 不自动审批、不自动启用 Daemon、不把本地草稿伪装成云端已批准内容。 + +这就是对话中提到的 `intent orchestration of Skill/MCP`,但编排结果必须受 ContentCloud 业务契约和本地环境锁约束。 + +## 9. 环境安装、升级与重置 + +### 9.1 CLI 命令面 + +建议增加: + +```text +contentcloud environment plan +contentcloud environment plan --bundle +contentcloud environment apply +contentcloud environment status +contentcloud environment doctor +contentcloud environment upgrade --dry-run +contentcloud environment upgrade +contentcloud environment reset --dry-run +contentcloud environment reset +``` + +`init` 可以组合调用 plan/apply,但独立命令便于恢复失败安装和后续升级。 + +### 9.2 安装原则 + +- plan 不消费 connect key、不修改文件、不安装插件。 +- Resolver 只能从签名 Environment Manifest、Execution Bundle 和 Marketplace Registry 的交集选择插件,不能接受模型自由拼出的包名或 URL。 +- apply 前展示 Scene/Skill/Provider Pack、版本、Skills/MCP、网络、文件、费用和凭据范围。 +- 只管理 ContentCloud namespace 和受管文件。 +- 不扫描或上传用户全部插件清单。 +- 安装失败保留可恢复状态,不伪装为 `connected`。 +- `workspace.register` 只有在 environment doctor 全部通过后执行。 + +### 9.3 按任务准备 Skill Pack + +本地生成 `LocalExecutionPlan` 或收到 `CreativeExecutionBundle` 后执行确定性比对: + +```text +本地计划:验证Environment/allowlist/输入digest +服务端Bundle:验证签名、有效期和subject digest + -> 验证 Pack 位于项目 allowlist 且未被市场撤回 + -> 比对 environment.lock / 本地插件实际状态 + -> 已安装且版本、digest 匹配:启用任务并记录 Run 审计 + -> 缺失或不匹配:生成安装计划 +``` + +交互式创作中,Codex 可以向用户展示新增业务能力、权限和费用,经确认安装或升级 Pack,然后 Continue 当前任务;宿主要求新会话或刷新时必须明确提示,不能假装已经加载。 + +`task` scope 只表示该 Pack 由任务选择,并不要求任务结束后立即卸载。已验证版本可以缓存以减少重复安装,但下一任务仍要重新校验 Bundle、allowlist 和 digest。 + +Automation 不走运行中安装分支:Scheduler 只有在设备已经声明并通过所需 capability/Pack digest 校验时才下发租约。缺失 Pack 时进入 `environment_preparation_required`,先在无活动 Run 的窗口完成安装与 doctor,再允许领取任务。 + +### 9.4 升级原则 + +服务端可以判断环境或某个 Pack 有新版本,但不能在活动创作或 Automation Run 中静默替换: + +```text +update_available + -> 用户查看能力与权限变化 + -> 本地 dry-run + -> 用户或租户策略确认 + -> 空闲状态应用 + -> 新会话加载 + -> doctor + -> 注册新 capability digest +``` + +安全修复可以阻止新的 publish 或 Automation,但仍不能不留审计地修改客户工作区。 + +同一 Execution Bundle 始终锁定原版本;需要换版本时签发新 Bundle 并形成新的 Run 审计,不原地篡改历史执行条件。 + +### 9.5 重置原则 + +重置只恢复 ContentCloud 管理的: + +- Scene/Skill/Provider Pack 配置。 +- 受管 Skills/MCP 和任务 Pack 缓存。 +- Workspace managed_replace 文件。 +- Environment Lock。 + +重置不删除: + +- 原始资料。 +- 知识、Brief、剧本、素材、视频和导出。 +- Submission、审批和云端审计。 +- 非 ContentCloud 插件和用户配置。 + +任何实际删除或配置修改都必须先 dry-run 并明确确认。 + +### 9.6 旧工作区迁移 + +旧工作区可能同时存在项目级 `.agents/skills`、`.codex/config.toml` MCP 与新场景插件。 + +迁移必须: + +- 根据 template lock 和 hash 只识别 ContentCloud 管理文件。 +- 使用 TOML parser 删除 ContentCloud 自己的 MCP table,不做字符串替换。 +- 用户修改过的文件拒绝自动处理。 +- 保留 `.contentcloud/skills` 审计副本和业务数据。 +- 完成后把 integration target 更新为 `codex-plugin`。 + +## 10. 与 Automation Loop 的关系 + +### 10.1 交互创作与 Automation 不混为一谈 + +普通创作: + +- 用户在 Codex 中交互。 +- 使用场景插件、LocalRunContext 和本地 Workspace。 +- 不创建云端 TaskRun。 + +Automation: + +- 用户显式创建 Automation Plan。 +- Scheduler 创建 TaskRun。 +- Daemon 在隔离工作区调用本机 Harness Adapter。 +- 输出先成为 SubmissionRevision,不自动批准。 + +### 10.2 剧本与精选 Skill Pack 一起交付 + +本节专指剧本、任务或 ApprovedSnapshot 从服务端下发的路径。普通本地对话创建的剧本不强制上传再下载,而是使用 `LocalExecutionPlan`。 + +“随剧本一起下发”在传输层可以同一个响应返回,但必须是两个独立、可校验的对象: + +```json +{ + "script_package": { + "id": "aps_...", + "content": "...", + "provenance": "..." + }, + "execution_bundle": { + "bundle_id": "ceb_...", + "subject_digest": "sha256:...", + "skill_packs": [ + "contentcloud-visual-storytelling@1.2.0" + ], + "signature": "..." + } +} +``` + +这不是让剧本“携带并执行插件”,而是让控制面同时交付: + +- 可独立审批、迁移和长期保存的业务内容。 +- 完成下一创作步骤所需的受控执行环境声明。 + +服务端 Resolver 根据 Profile、任务类型、所需 capability、输入/输出 Schema、租户策略、已授权 Provider 和市场兼容矩阵选择 Pack。选择结果必须可解释,例如“该剧本包含分镜任务,因此需要视觉叙事 Pack”,但普通用户不需要在十几个候选项中手工试错。 + +一个典型链路是: + +```text +Approved ScriptPackage + CreativeExecutionBundle + -> 本地验证剧本 digest 和 Bundle 签名 + -> Environment Manager 确认所需 Pack 已就绪 + -> Codex Scene Plugin 按 intent 调用 Pack 中的 Skill + -> 素材/视频输出通过 Schema 和 lint + -> 提交新的 SubmissionRevision 等待审核 +``` + +### 10.3 每天生成 100 个剧本 + +该需求应表达为业务计划,而不是 cron shell: + +```text +Automation Plan: 每日剧本批量生产 + profile: contentcloud.video-production + capability: contentcloud.script.generate + schedule: 每日 + requested_count: 100 + inputs: 已批准 Brief + Knowledge Snapshot + output: 100 个待审核 ScriptPackage + approval: 必须人工审核 +``` + +Daemon 领取任务前验证: + +- Environment Profile 和版本匹配。 +- capability ID、Schema 和 digest 匹配。 +- Execution Bundle 中所需 Skill Pack/Provider Pack 的版本和 digest 已预装并验证。 +- 所需本地来源 hash 匹配。 +- 环境没有处于升级或损坏状态。 + +活动 Run 中不安装、升级或临时下载插件。需要补充 Pack 或升级时先暂停新租约、完成环境准备和 doctor,再恢复计划。 + +### 10.4 环境评估 + +Loop 可以周期性评估: + +- Skill/MCP 健康。 +- capability 覆盖。 +- 连续失败和质量门禁。 +- 新环境版本是否解决当前问题。 + +评估只产生更新建议或 ChangeRequest。是否升级由用户或明确的租户策略决定,并在 Web 中显示变化和结果。 + +## 11. 多 Harness 架构 + +### 11.1 不变层 + +以下内容不随 Codex、Claude Code、OpenClaw、WorkBuddy 变化: + +- ContentCloud 业务对象与 Schema。 +- ContentCloud Marketplace Registry 与准入结论。 +- CreativeEnvironmentProfile。 +- CreativeEnvironmentManifest 与 CreativeExecutionBundle。 +- Workspace Template。 +- LocalRunContext、RunClaim 和 HandoffRecord。 +- CLI JSON envelope 和退出码。 +- Capability Manifest。 +- Submission、审批和 Automation 契约。 + +### 11.2 Adapter 层 + +每个 Harness Adapter 负责: + +```text +Detect 检测宿主与版本 +Plan 将受控插件集合转换为宿主原生插件/Skill/MCP变化 +Apply 经确认执行安装或项目配置 +Verify 验证实际组件和版本 +Launch 在支持时打开正确宿主和工作区 +``` + +Adapter 还可以在宿主可靠提供时记录可选 conversation/thread ref,并生成“在同一 workspace path 新建对话”的入口;业务接管始终依赖 handoff/run ID,不能依赖某个宿主的 thread ID。 + +Codex Adapter 首先实现: + +- `.codex-plugin/plugin.json` +- `.agents/plugins/marketplace.json` +- bundled `.mcp.json` +- Scene/Skill/Provider Pack 的 plugin mention 和 `codex://threads/new` +- `codex plugin` 验证流程 + +Claude Code、OpenClaw、WorkBuddy 必须先分别确认它们的 manifest、Marketplace、MCP、权限、更新、Workspace 和 Deep Link 能力,再实现 Adapter。不能只把 Codex 文件改名后声称支持;即使某个宿主没有项目侧边栏或对话 Deep Link,也必须能通过同一 HandoffRecord 协议手工打开文件夹并接管。 + +### 11.3 何时引入生成框架 + +Codex 阶段先把 canonical Skills、Manifest 模型和验证接口设计清楚。第二个 Harness 开始实施时,再引入类似 `wshobson/agents` 的 adapter generator。 + +这样既保留多宿主方向,又避免在第一个宿主尚未跑通时过早建设通用框架。 + +## 12. 与现有 V2 文档的关系 + +本方案保留以下既有边界: + +- 云端 zero-exec。 +- CLI 是程序化服务通信唯一入口。 +- 本地原始资料和未发布草稿是本地事实源。 +- SubmissionRevision、人工决定和 ApprovedSnapshot 是云端事实源。 +- 普通本地创作不创建 TaskRun。 +- Automation 只依赖业务 capability,不自动批准输出。 +- ScriptPackage 等业务对象不包含 Skill 或安装实现;Execution Bundle 与其并行绑定执行条件。 + +需要在方案确认后调整的既有表述: + +1. `docs/roadmap/v2/06-local-workspace-and-publishing.md` + 当前只描述 CLI 内嵌项目级 Skills/MCP,并用单个 `work/current-run.json` 表达当前任务。需要增加 Creative Environment、场景插件、环境锁,以及多对话的 RunClaim、context revision 和 HandoffRecord。 +2. `docs/roadmap/v2/08-system-architecture.md` + 当前写明服务端不显示 Agent/Skill,也不下发模板。应调整为:业务页面不让用户选择底层实现,但环境控制面可以保存签名的交付 manifest、Harness target、版本和 digest。 +3. `docs/roadmap/v2/09-cli-mcp-and-contracts.md` + 当前远程签名 WorkspaceTemplateManifest 尚未实现。本方案应在其上增加 CreativeEnvironmentManifest、LocalExecutionPlan、CreativeExecutionBundle 和 handoff typed tools,而不是把插件列表塞进 ScriptPackage 或业务 Task Contract。 +4. `docs/roadmap/v2/07-automation-and-run-model.md` + 继续保持 Plan 只依赖 capability;增加 Run 领取前的环境版本与健康检查。 + +必须保持一句话边界: + +> 业务层不知道 Agent 如何实现能力;环境控制面知道如何把经过审核的能力交付到指定 Harness。 + +## 13. 安全、治理与供应链 + +### 13.1 用户可见性 + +用户不需要做技术选型,但必须知道: + +- 将安装或升级什么业务能力。 +- 哪些组件读取本地文件。 +- 哪些组件访问网络或第三方服务。 +- 哪些动作会产生云端写入或外部副作用。 +- 当前环境是否健康、是否有更新、最近一次更新结果。 +- 某次任务为什么需要新增 Skill Pack/Provider Pack,以及它会增加什么权限或费用。 +- 当前 Run 是否被另一个对话占用、handoff 引用了哪些冻结输入,以及接管失败的具体冲突。 + +### 13.2 凭据 + +- connect key 只交给 ContentCloud CLI。 +- Workspace/User/Device Credential 继续进入 OS Keychain。 +- Plugin manifest、Environment Manifest、Execution Bundle 和 Skill 不含 token。 +- Provider 账号使用各自 OAuth/凭据边界,不复用 ContentCloud token。 +- 服务端不保存用户模型密钥。 + +### 13.3 供应链 + +- Marketplace Registry、Codex Marketplace、插件、npm 和 binary 使用固定版本、digest 与可验证签名。 +- npm 启动器继续验证 GitHub Release checksums。 +- 第三方 Skill/MCP 记录来源、许可证、版本、维护状态、审核结论、允许权限、数据流和撤回策略。 +- Skill Pack 的 `SKILL.md`、references、assets 和 `scripts/` 作为一个不可拆分发布单元计算 digest,防止只审核文案、不审核辅助代码。 +- Release CI 验证 manifest、Skill、scripts、MCP schema、CLI 命令、输出 Schema 和 capability digest,并运行每个市场条目的场景评测。 +- 禁止运行时从未经签名的第三方 Marketplace 自动拉取新组件。 +- 禁止 Execution Bundle 引用 Profile allowlist 之外、已撤回或 digest 不匹配的市场条目。 + +### 13.4 Tool 与内容安全 + +- 来源文档始终作为不可信数据,不能给 Agent 注入安装或工具指令。 +- ScriptPackage、Brief、评论和模型输出始终作为数据处理,不能成为动态 Skill、插件 manifest 或 shell/MCP 配置。 +- HandoffRecord 不保存完整 transcript、隐藏推理、凭据或未校验的安装指令。 +- MCP tool 标注 read/write/destructive/idempotent 属性。 +- 写操作使用 dry-run、明确范围和用户确认。 +- Provider Tool 输出不能绕过 ContentCloud Schema 和本地 lint 直接成为已批准业务对象。 + +## 14. 实施阶段 + +### Phase 0:确认产品与边界 + +确认: + +- 产品对象是 Creative Environment,而不是通用插件市场。 +- Web 初始化弹窗是唯一入口。 +- 一个本地 Workspace 对应 Codex 中的一个项目文件夹,文件夹内允许多个独立对话;共享资源不等于共享对话上下文。 +- 普通本地对话、Run claim 和 handoff 默认离线,服务端只在 init、显式 pull/publish、环境准备和 Automation 节点参与。 +- AI 视频场景首版必装一个 Scene Plugin,配套能力只从 ContentCloud 精选 Marketplace 的少量 Pack 中解析。 +- 剧本等业务对象与 CreativeExecutionBundle 分离,市场条目不能通过客户内容动态生成。 +- 服务端声明期望环境,客户端经宿主授权应用。 +- 环境升级可见,不做活动任务中的静默热升级。 + +### Phase 1:Codex 最小闭环 + +范围只覆盖当前真实能力:资料/证据、知识、Brief 基础契约、剧本、审核。 + +交付: + +- ContentCloud repo Marketplace,以及服务端 Marketplace Registry 最小元数据和签名契约。 +- `contentcloud-video-production` Codex plugin。 +- 现有两个 Skill 迁入场景插件并保持单一事实源。 +- bundled `contentcloud-local` MCP。 +- `contentcloud-workspace` 入口 Skill。 +- `codex-plugin` target。 +- Web Prompt 加 plugin mention,保持现有 ConnectSession 轮询。 +- Environment Manifest 的插件数组和 Environment Lock 最小契约。 +- Conversation bootstrap:workspace probe -> 新建/恢复/接管 Run -> intent -> LocalExecutionPlan。 +- 本地 `WorkspaceConversationContext`,让新对话能回答项目进度、可接任务和下一步,而不读取旧 transcript 或自动联网。 +- `local run list/claim/release`、context revision CAS、HandoffRecord 及对应 typed MCP tools。 +- 云端副作用分类:本地动作、ContentCloud Server、第三方 Provider 三类必须在工具返回中可识别。 +- 本地安装、连接、doctor、升级前检查测试。 + +退出条件:全新用户从 Web 复制一次 Prompt,在 Codex 完成插件安装与项目连接并生成通过本地 lint 的 ScriptPackage;随后对话 A 创建 handoff,对话 B 在同一文件夹无服务端调用地校验、claim 并继续该 Run。 + +### Phase 2:完整 AI 视频创作链 + +按顺序补齐: + +1. 通用文档解析与来源治理。 +2. 知识库诊断与查询。 +3. 策略和 Brief 编译。 +4. 剧本生成与修订。 +5. 素材需求、生成和权利追踪。 +6. 视频生成、组装、校验和交付。 + +每一步都必须有明确 Schema、Skill、MCP/CLI 工具、lint 和审核入口。不能用一个“大 Agent Prompt”替代领域契约。 + +在真实任务验证后,选取不超过 2-3 个高价值能力制作首批 Skill Pack/Provider Pack,并交付: + +- Marketplace 准入、版本、签名、撤回和兼容矩阵。 +- CreativeExecutionBundle 签发、验证和 subject digest 绑定。 +- LocalExecutionPlan 与服务端 CreativeExecutionBundle 的双路径 Resolver。 +- “ScriptPackage + Execution Bundle”并行下发。 +- 交互式缺失 Pack 的 plan、确认、安装、Continue 流程。 +- 每个 Pack 的权限、输出 Schema 和场景评测。 + +### Phase 3:环境生命周期 + +- 签名 Environment Manifest。 +- Environment Resolver 和签名 Execution Bundle。 +- environment plan/apply/status/doctor/upgrade/reset。 +- 旧项目级 Skill/MCP 迁移。 +- Web 显示环境版本、能力、更新和诊断。 +- Release 与 digest 一致性门禁。 + +### Phase 4:Automation Loop + +- Daemon 绑定 Environment/Profile/Capability digest。 +- Scheduler 在发放租约前验证 Execution Bundle 和 Pack digest,缺失时先进入环境准备。 +- 每日批量剧本等 Automation Plan。 +- 环境健康检查和升级 ChangeRequest。 +- 升级期间暂停新租约,完成后恢复。 +- 输出继续进入 SubmissionReview,不自动批准。 + +### Phase 5:第二个 Harness + +优先顺序建议: + +1. Claude Code。 +2. 根据真实客户渠道选择 OpenClaw 或 WorkBuddy。 +3. 第二个 Adapter 稳定后再抽取通用生成框架。 + +## 15. Codex 验收矩阵 + +### 15.1 Web 与安装 + +- 项目总览只提供一个初始化入口。 +- Prompt 包含服务端选择的必装插件 mention 和环境 Profile。 +- 未安装 Scene/Skill/Provider Pack 时,Codex 能准确展示能力、权限和费用变化,并在确认后 Continue 原任务。 +- connect key 不进入 URL 和日志。 +- 插件不可发现、连接码过期、用户拒绝安装时,Web 状态和帮助文案准确。 + +### 15.2 环境 + +- Environment Manifest 签名、版本和 digest 可验证。 +- Marketplace Registry 只解析受审核条目,插件数组可确定性解析且版本兼容。 +- Environment Lock 只记录 ContentCloud 管理组件。 +- 插件、npm、CLI、Skills、MCP 和 Schema 版本兼容。 +- 新插件模式不重复写项目级 `.agents/skills` 和 ContentCloud MCP。 +- 旧工作区可经确认迁移,不覆盖用户修改。 + +### 15.3 文件夹与多对话交接 + +- 同一 Workspace 文件夹中的多个 Codex 对话能读取相同 Environment Lock、ApprovedSnapshot 和版本化业务输出,但不依赖彼此的 transcript。 +- 新建空对话询问项目下一步时,能从本地 WorkspaceConversationContext 返回活动 Run、ready handoff、待决定项和缓存新鲜度,且服务端请求数为零。 +- 对话 A 能在完成 checkpoint 和 lint 后创建 HandoffRecord、释放 claim;对话 B 通过 handoff ID 校验 digest 并原子接管。 +- handoff 创建和接管在 Pack 已就绪时完全离线,不产生服务端请求或云端 TaskRun。 +- 同一 Run 的第二个 write claim 被拒绝;过期 claim 接管必须检查 context revision 并要求用户确认。 +- 不同 Run 可以并行生成不同版本化输出,不覆盖同一路径或互相修改 LocalRunContext。 +- 输入 digest、context revision、Environment digest 任一不匹配时,接管停止并返回可操作冲突报告。 +- `work/current-run.json` 不再作为多对话权威状态,旧工作区保持只读兼容并可迁移。 +- 新对话 Deep Link 或交接 Prompt 不包含 connect key、Workspace Credential 或客户正文。 + +### 15.4 业务闭环 + +- 原始文档不会被当成 Agent 指令。 +- 来源、证据、知识候选、Brief 和 ScriptPackage 通过确定性校验。 +- publish 前显示实际披露和文件范围。 +- 云端只批准不可变 SubmissionRevision。 +- 未实现的素材/视频能力不会显示为已就绪。 +- ScriptPackage 不包含 Skill、Prompt、安装命令或辅助代码;Execution Bundle 能独立验证签名、subject digest、版本和有效期。 +- 同一响应并行下发 ScriptPackage 与 Execution Bundle 时,两者绑定准确且可分别审批、重放和审计。 + +### 15.5 升级与重置 + +- dry-run 明确列出插件、Skill、MCP、文件和权限变化。 +- task scope Pack 已匹配时不重复安装;缺失时必须先 plan 和确认。 +- 活动 LocalRun/TaskRun 中不升级。 +- 升级失败保留旧 lock 和可用环境。 +- 重置不删除业务文件、凭据、云端记录和非 ContentCloud 插件。 + +### 15.6 Automation + +- Run 只分配给 environment/capability digest 匹配的设备。 +- Run 所需 Pack 必须在领取前预装并通过 digest 校验,活动 Run 中不得动态安装。 +- 每日批量任务使用隔离工作区和冻结输入。 +- 插件缺失、环境损坏或版本不匹配时不领取任务。 +- RunOutput 进入待审核 Submission,不自动批准。 + +## 16. 明确不采用的方案 + +### 16.1 让用户浏览 94 个插件自行组合 + +这会把产品设计和安全责任交给用户,与“围绕场景做实”相反。 + +### 16.2 直接依赖外部 Marketplace + +外部 Marketplace 可用于研究和来源参考,不能成为 ContentCloud 生产运行时的不可控依赖。 + +### 16.3 一个 Prompt 包含全部方法论 + +会造成上下文膨胀、无法测试、无法版本化,也无法让 Automation 复用稳定 capability。 + +### 16.4 服务端直接操控本机 Codex + +云端只能声明期望环境并生成连接流程。本机安装必须由用户授权的 Agent/CLI 执行,保持 zero-exec 和宿主权限边界。 + +### 16.5 对用户完全隐藏环境变化 + +不要求用户理解插件技术细节,但必须让用户看到业务能力、数据权限、外部副作用和升级结果。 + +### 16.6 首版同时实现四个 Harness + +先在 Codex 跑通安装、连接、创作、审核和升级,再通过真实的第二个 Harness 提炼 Adapter。 + +### 16.7 把 Skill 或辅助代码直接塞进 ScriptPackage + +这会混淆业务事实与可执行供应链,使剧本审批等价于代码安装审批,也无法独立撤回有风险的 Pack。业务对象和 Execution Bundle 必须分离。 + +### 16.8 市场中的每个 Skill 都做成独立插件 + +Codex 的安装单元虽然是 plugin,但一个 plugin 可以包含多个内聚 Skills。按文件粒度拆包会制造依赖、版本和权限噪声;只按独立价值、权限边界和发布生命周期拆 Scene/Skill/Provider Pack。 + +### 16.9 用上一段对话摘要代替结构化交接 + +模型摘要可能漏掉输入版本、校验结果、阻断和写入占用,也无法防止并发覆盖。自然语言摘要只用于展示,真正接管必须读取 HandoffRecord、LocalRunContext、digest 和 claim。 + +### 16.10 多个对话共享一个全局 current-run + +这会让后打开的对话误接管其他任务,并在并行创作时覆盖草稿。每个任务必须有独立 run ID;全局 current 指针只保留兼容用途,不参与写入授权。 + +## 17. 工程原则 + +- KISS:当前场景以一个必装 Scene Plugin 为基座,只在真实任务证明需要时增加少量精选 Pack。 +- DRY:Skills 是单一事实源,CLI 和 Harness 产物从同一源构建。 +- YAGNI:Codex 未跑通前不建设完整多 Harness 编译系统。 +- SRP:Conversation 负责交互,LocalRun/Handoff 负责本地连续性,Marketplace/Manifest 负责环境,Submission/ApprovedSnapshot 负责云端治理。 +- OCP:新增 Harness 通过 Adapter 扩展,不修改业务契约。 +- DIP:Automation 和业务流程依赖 capability/Schema,不依赖 Codex 或具体模型。 + +## 18. 官方与参考资料 + +- [OpenAI: Package your plugin](https://developers.openai.com/plugins/build/plugins) +- [OpenAI: Build skills](https://developers.openai.com/plugins/build/skills) +- [OpenAI: Build an MCP server](https://developers.openai.com/plugins/build/mcp-server) +- [OpenAI: Use plugins](https://learn.chatgpt.com/docs/plugins) +- [OpenAI: Skills and plugins](https://learn.chatgpt.com/docs/skills-and-plugins) +- [OpenAI: Codex app deep links](https://developers.openai.com/codex/app/deep-links) +- [OpenAI: Codex advanced configuration](https://developers.openai.com/codex/config-advanced) +- [wshobson/agents](https://github.com/wshobson/agents) diff --git a/docs/roadmap/v2/03-domain-and-data-model.md b/docs/roadmap/v2/03-domain-and-data-model.md index b8f0442..f02d88e 100644 --- a/docs/roadmap/v2/03-domain-and-data-model.md +++ b/docs/roadmap/v2/03-domain-and-data-model.md @@ -98,7 +98,7 @@ V1 的 `ScriptVersion` 曾同时是内容载体和审批主体。V2 保留它的 3. 回填不重算 hash:影子快照沿用 V1 `content_hash`,`schema_version` 标记为 `1.x`。 4. 切换完成并观察稳定后,停止创建新的 ScriptVersion 记录及其 ReviewCycle;读路径按 `12-migration-and-delivery-plan.md` 的退役节奏保留至少一个稳定版本周期。 -> 实现现状:`internal/app/review_cycles.go` 与 `internal/app/review_export.go` 目前仍以 `script_version` 为 subject,改挂 `submission_revision` 是波次一的 P0 改造项,见 `14-implementation-status.md`。 +> 实现现状:新客户审批、交付与结果链已统一使用 `submission_revision` / `approved_snapshot`;V1 `script_version` 仅保留历史读取与旧链接兼容。实现和验收边界见 `14-implementation-status.md`。 ## 3. 通用字段与版本规则 diff --git a/docs/roadmap/v2/14-implementation-status.md b/docs/roadmap/v2/14-implementation-status.md index 1cd97bc..725769e 100644 --- a/docs/roadmap/v2/14-implementation-status.md +++ b/docs/roadmap/v2/14-implementation-status.md @@ -11,15 +11,21 @@ flowchart LR L --> X[local source register/ingest] X --> K[knowledge import/lint/diagnose/pack] K --> P[knowledge publish preflight] - P --> S[不可变 SubmissionRevision + 人工审核] - S --> R[Web 审核] + P --> S[不可变 SubmissionRevision] + S --> R[Web 内部审核] R -->|修改要求| F[pull feedback/decisions] - R -->|批准| A[ApprovedSnapshot] + R -->|内部批准| G[客户 OTP 审批] + G -->|修改要求| F + G -->|客户批准| A[ApprovedSnapshot] A --> C[pull approved 只读缓存] + A --> D[JSON/Markdown/XLSX DeliveryPackage] + D --> O[PerformanceObservation] + O --> T[Rating + Lineage] F --> K C --> B[local Brief/CreativeBatch/ScriptPackage V2] B --> SP[script lint/diff/publish] SP --> R + T --> B ``` 该路径以客户端为主。服务端只接收显式提交、保存治理事实并提供人工审核;普通本地操作不会创建 `TaskRun`。 @@ -39,8 +45,12 @@ flowchart LR | 发布 | knowledge/research/strategy/brief/script/delivery/performance;本地 JSON/lint/hash/大小/路径/preflight;`--review`/`--yes` 确认 | | 拉取 | feedback/decisions 写 inbox,ApprovedSnapshot 写只读 cache,不覆盖业务正文 | | 云端治理 | Submission、不可变 Revision、SourceDisclosure、ReviewFeedbackBundle、DecisionDelta、ApprovedSnapshot | -| 审核 | Web 列表/详情/revision 切换/来源披露/批注/修改要求/批准;不提供正文编辑控件 | -| 数据库 | `00013_v2_workspace_submissions.sql`、Workspace token lookup、RLS、revision/snapshot immutable trigger、审批事务 | +| 内部审核 | Web 列表/详情/revision 切换/来源披露/批注/修改要求/内部批准;不提供正文编辑控件,内部批准不提前生成快照 | +| 客户审批 | ReviewGrant 绑定 SubmissionRevision;OTP 验证、客户批准/修改要求、新 revision 撤销旧未决 Grant;公开页兼容 V1 历史链接 | +| 交付 | 客户批准原子生成 ApprovedSnapshot;从快照生成 JSON/Markdown/XLSX Artifact 与不可变 DeliveryPackage | +| 结果学习 | PerformanceObservation 绑定 ApprovedSnapshot;RatingDecision 支持 `approved_snapshot`;Lineage 串联提交、快照、交付和结果 | +| Brief 策略血缘 | `strategy_version_id` 必填,且必须引用已 pull 的 strategy ApprovedSnapshot 对象 | +| 数据库 | `00013`-`00015`;Workspace token lookup、RLS、revision/snapshot/package immutable trigger、两阶段审批事务、V1 影子快照幂等回填报告 | 核心代码入口: @@ -48,40 +58,46 @@ flowchart LR - `internal/cli/workspace_commands.go` - `internal/cli/v2_commands.go` - `internal/app/submissions.go` +- `internal/app/review_export.go` +- `internal/app/artifacts.go` +- `internal/app/performance_results.go` - `internal/store/postgres/submissions.go` +- `internal/store/postgres/delivery.go` - `internal/httpapi/submission_handlers.go` - `web/src/views/SubmissionsView.tsx` +- `web/src/views/PublicViews.tsx` +- `web/src/views/AssetViews.tsx` ## 3. 已验证规则 - publish 时服务端复算 canonical hash;篡改 payload 会被拒绝。 - 同一幂等键和相同内容返回原 Revision;相同幂等键不同内容冲突。 - 高风险对象使用 metadata-only 或无披露时标记 `evidence_limited`,不能远程批准。 -- 批准固定 Revision hash 并创建不可变 ApprovedSnapshot。 +- 内部批准只把 Submission 推进到 `internally_approved`;不会提前创建 ApprovedSnapshot。 +- ReviewGrant 绑定具体 SubmissionRevision;新 revision 会撤销同一 Submission 上旧 revision 的未决 Grant。 +- 客户 OTP 批准要求同一 revision 已完成内部批准,并在同一事务内写客户决定、Grant、Submission 和不可变 ApprovedSnapshot。 +- ApprovedSnapshot 固定 Revision hash;JSON/Markdown/XLSX Artifact、DeliveryPackage 和 PerformanceObservation 均引用该快照。 - 修改要求在同一事务中写 Decision、Comment 和 `changes_requested` 状态。 - Workspace Credential 只能访问绑定工作区;Web 用户角色负责 approve/request-changes。 - 普通 publish 不创建 TaskRun;服务端没有 LLM、Agent、Skill、MCP 或 Renderer 执行入口。 - Web 审核动作不改写 Revision 正文。 - Brief publish 会强制执行 Brief V2 lint;Script publish 会识别批次目录中的 `script_package` 并强制执行完整 ScriptPackage V2 lint,batch/context 不会被误发布;多个候选必须通过重复 `--file` 明确范围。 - 阻断剧本允许没有镜头,但必须使用 blocked 状态并给出结构化 blocked reasons;review_ready 必须有连续镜头、必要角色、引用、权利和实验声明。 +- Brief 必须声明 `strategy_version_id`,且该对象必须存在于已 pull 的 strategy ApprovedSnapshot 中。 +- V1 已批准 ScriptVersion 可幂等回填为 `origin=v1_import` 的只读影子快照;有效历史 hash 原值保留,无效 hash 只计入报告、不伪造或重算。 +- HTTP Golden Path 已自动验证 revision 内审、Grant、公开 OTP、客户批准、快照查询、三格式交付包和快照结果绑定。 +- 真实 PostgreSQL 已验证 migration、Workspace token lookup、Revision/ApprovedSnapshot 不可变、审批事务、V1 回填幂等和跨租户 RLS。 - 金陵古都香一个真实 DOCX 已自动走通来源登记、DOCX ingest、知识候选导入、lint、15 维诊断、七层 pack 和 publish preflight,且不上传 raw。 -以下规则**尚未成立**,属于 §4 的 P0 缺口,不要据 §2 的"已实现"推断: - -- 云端审核目前只覆盖内部决定。客户 OTP 审批、导出、DeliveryPackage 和 PerformanceObservation 仍绑定 V1 `script_version`(`internal/app/review_cycles.go:36,135`、`internal/app/review_export.go:105,232`),与 Submission 轨没有连接。 -- 因此从 publish 到"客户批准并三格式交付"这一段目前跑不通,Golden Journey 第 9-11 步无法执行。 +以上证明代码状态为 `implemented`,不等于真实业务场景已经 `accepted`。剩余验收项见 §4。 ## 4. 尚未完成 | 优先级 | 缺口 | 完成条件 | | --- | --- | --- | -| P0 | **审批单轨收敛(代码缺口,非 UAT 缺口)** | ReviewCycle/ApprovalDecision/ReviewGrant 的 subject 从 `script_version` 改为 `submission_revision`;DeliveryPackage 与 PerformanceObservation 改引用 ApprovedSnapshot;V1 记录回填 `origin=v1_import` 影子快照。见 `03-domain-and-data-model.md` §2.1/§2.2 | -| P0 | 客户 OTP 审批链接接入 Submission | ReviewGrant 绑定具体 SubmissionRevision,新 revision 使旧链接失效;内部/客户两阶段决定写入同一 revision | -| P0 | 三格式导出改由 ApprovedSnapshot 驱动 | 导出与 DeliveryPackage 从批准快照的 canonical 内容生成,hash 与 revision 一致 | -| P0 | Brief 策略血缘 | `LocalBrief` 增加 `StrategyVersionID` 字段并加入 `internal/localworkspace/script.go` 的必填校验;校验其落在已 pull 的 strategy ApprovedSnapshot 的 eligible IDs 内(契约已改,实现待补) | -| P0 | 金陵古都香迁移/UAT | 现有资料保留 stable ref/status/locator,完成 knowledge -> strategy -> brief -> script -> approval Golden Journey | -| P0 | 金陵全量客户端迁移 | 迁移 source registry、232 个对象、冲突/权利候选和十条旧稿;输出 dry-run 与核对报告 | -| P0 | 真实审批剧本闭环 | 上述单轨改造完成后,真实 publish/人工审批/pull,从 Approved Brief 生成至少三候选、修订、批准并三格式交付 | +| P0 | 金陵全量客户端迁移 | 迁移 source registry、232 个对象、冲突/权利候选和十条旧稿;输出 dry-run、错误清单和人工核对报告,保留 stable ref/status/locator | +| P0 | 金陵 Golden Journey UAT | 由真实业务角色完成 knowledge -> strategy -> brief -> 三候选 script -> 内审 -> 客户 OTP -> 三格式交付 -> 结果导入 -> rating/lineage,并保存验收证据 | +| P0 | 浏览器与生产前验收 | 桌面/移动端执行内部审核、公开 OTP、下载和结果录入;验证错误恢复、链接撤销/过期和生产数据库迁移演练,由责任人签署 | | P1 | Client/Brand/Product 与四层上下文 | 正式聚合、版本继承、override/rebase 和第二客户隔离验收 | | P1 | StrategyVersion 扩展能力 | 候选比较、采纳与跨域 lineage;最小可用审批已随波次一的 Brief 策略血缘一起交付 | | P1 | 九域工作台 | 研究、策略、内容计划、创意、交付、学习等真实对象和页面,不建设在线正文编辑器 | @@ -123,11 +139,31 @@ contentcloud pull decisions contentcloud pull approved --type script ``` -审批是人工治理动作,使用 User Credential: +内部与客户审批是人工治理动作,使用 User Credential 创建客户授权;客户本人通过一次性 OTP 公开页决定: ```bash contentcloud submission approve --reason <结论> --yes contentcloud submission request-changes --reason <要求> --json-pointer /0/shots/0 --yes +contentcloud review create --email +contentcloud review status +contentcloud review list +``` + +客户批准后,以 ApprovedSnapshot 为唯一交付和结果入口: + +```bash +contentcloud artifact export --format json +contentcloud artifact export --format markdown +contentcloud artifact export --format xlsx +contentcloud artifact package +contentcloud artifact packages --project +contentcloud artifact package-show +contentcloud artifact download --out + +contentcloud result import --project +contentcloud result list --project +contentcloud result rate approved_snapshot --project --observation --rating --reason --next-action +contentcloud lineage show --project --type approved_snapshot --id --direction both ``` ## 6. 自动化验证 @@ -141,9 +177,9 @@ pnpm --dir web build node --check packages/contentcloud/bin/contentcloud.js ``` -设置 `CONTENTCLOUD_TEST_DATABASE_URL` 后,Go 测试会执行真实 PostgreSQL migration,并验证 Workspace token lookup、Revision 不可变、修改/批准事务和跨租户 RLS。当前环境未设置该变量,因此本轮不能提供新的真实 PostgreSQL 执行记录。 +本轮已使用本机隔离测试库设置 `CONTENTCLOUD_TEST_DATABASE_URL`,执行真实 PostgreSQL migration,并验证 Workspace token lookup、Revision/ApprovedSnapshot 不可变、修改/两阶段批准事务、V1 影子回填和跨租户 RLS。该结论只代表自动化测试通过,不代表生产数据迁移演练完成。 -代码实现只能标记 `implemented`。发布前仍需南京业务角色 Golden Journey、第二客户场景、浏览器桌面/移动交互和真实 PostgreSQL 验证,完成责任人签署后才能标记 `accepted`。 +代码主链只能标记 `implemented`。发布前仍需金陵业务角色 Golden Journey、第二客户场景、浏览器桌面/移动交互和生产数据迁移演练,完成责任人签署后才能标记 `accepted`。 ## 7. 永久边界 diff --git a/internal/app/artifacts.go b/internal/app/artifacts.go index 7ef53b5..9922e11 100644 --- a/internal/app/artifacts.go +++ b/internal/app/artifacts.go @@ -115,7 +115,12 @@ func (s *Service) ArtifactPresentation(ctx context.Context, actor Actor, artifac if err != nil { return domain.ArtifactPresentation{}, err } - artifacts, err := s.store.Artifacts(ctx, actor.TenantID, target.ScriptVersionID) + var artifacts []domain.Artifact + if target.ApprovedSnapshotID != "" { + artifacts, err = s.store.ArtifactsByApprovedSnapshot(ctx, actor.TenantID, target.ApprovedSnapshotID) + } else { + artifacts, err = s.store.Artifacts(ctx, actor.TenantID, target.ScriptVersionID) + } if err != nil { return domain.ArtifactPresentation{}, err } diff --git a/internal/app/lineage.go b/internal/app/lineage.go index 80c4332..2f5b6ab 100644 --- a/internal/app/lineage.go +++ b/internal/app/lineage.go @@ -265,6 +265,15 @@ func (s *Service) buildProjectLineage(ctx context.Context, tenantID, projectID s } } } + approvedSnapshots, err := s.store.ApprovedSnapshots(ctx, tenantID, projectID, "") + if err != nil { + return nil, err + } + for _, snapshot := range approvedSnapshots { + b.node("approved_snapshot", snapshot.ID, fmt.Sprintf("%s snapshot", snapshot.SubmissionType), "approved", "approval", snapshot.CreatedAt, map[string]any{"origin": snapshot.Origin, "content_hash": snapshot.ContentHash, "external_ref": snapshot.ExternalRef}) + b.edge("submission_revision", snapshot.SubmissionRevisionID, "approved_snapshot", snapshot.ID, "approved_as", "客户批准将不可变 revision 固化为快照") + b.edge("script_version", snapshot.ExternalRef, "approved_snapshot", snapshot.ID, "represented_as", "V1 剧本版本映射为只读影子快照") + } artifacts, err := s.store.Artifacts(ctx, tenantID, "") if err != nil { @@ -275,9 +284,26 @@ func (s *Service) buildProjectLineage(ctx context.Context, tenantID, projectID s continue } b.node("artifact", artifact.ID, artifact.FileName, firstNonEmpty(artifact.ValidationStatus, artifact.PresentationTier), "delivery", artifact.CreatedAt, map[string]any{"kind": artifact.Kind, "media_type": artifact.MediaType, "presentation_tier": artifact.PresentationTier}) - b.edge("script_version", artifact.ScriptVersionID, "artifact", artifact.ID, "exported_as", "剧本版本导出为交付工件") + if artifact.ApprovedSnapshotID != "" { + b.edge("approved_snapshot", artifact.ApprovedSnapshotID, "artifact", artifact.ID, "exported_as", "批准快照确定性导出为交付工件") + } else { + b.edge("script_version", artifact.ScriptVersionID, "artifact", artifact.ID, "exported_as", "V1 剧本版本导出为历史工件") + } b.edge("artifact", artifact.DerivedFromArtifactID, "artifact", artifact.ID, "derived_as", "工件派生安全预览或 rendition") } + deliveryPackages, err := s.store.DeliveryPackages(ctx, tenantID, projectID) + if err != nil { + return nil, err + } + for _, delivery := range deliveryPackages { + b.node("delivery_package", delivery.ID, "DeliveryPackage · "+delivery.ScriptID, delivery.Status, "delivery", delivery.CreatedAt, map[string]any{"script_id": delivery.ScriptID, "artifact_count": len(delivery.Manifest)}) + for _, snapshotID := range delivery.ApprovedSnapshotIDs { + b.edge("approved_snapshot", snapshotID, "delivery_package", delivery.ID, "delivered_as", "客户批准快照组成正式交付包") + } + for _, artifact := range delivery.Manifest { + b.edge("delivery_package", delivery.ID, "artifact", artifact.ID, "contains", "交付包包含确定性格式文件") + } + } batches, err := s.store.PerformanceImportBatches(ctx, tenantID, projectID) if err != nil { @@ -293,7 +319,11 @@ func (s *Service) buildProjectLineage(ctx context.Context, tenantID, projectID s for _, observation := range observations { b.node("performance_observation", observation.ID, observationLabel(observation), observation.SampleStatus, "results", observation.CreatedAt, map[string]any{"platform": observation.Platform, "window_hours": observation.WindowHours, "roi": observation.ROI}) b.edge("performance_import_batch", observation.ImportBatchID, "performance_observation", observation.ID, "imports", "导入批次包含效果观察") - b.edge("script_version", observation.ScriptVersionID, "performance_observation", observation.ID, "measured_by", "效果数据度量剧本版本") + if observation.ApprovedSnapshotID != "" { + b.edge("approved_snapshot", observation.ApprovedSnapshotID, "performance_observation", observation.ID, "measured_by", "效果数据度量已批准快照") + } else { + b.edge("script_version", observation.ScriptVersionID, "performance_observation", observation.ID, "measured_by", "效果数据度量 V1 剧本版本") + } } ratings, err := s.store.RatingDecisions(ctx, tenantID, projectID) if err != nil { diff --git a/internal/app/performance_results.go b/internal/app/performance_results.go index 9e8b876..77dbcb3 100644 --- a/internal/app/performance_results.go +++ b/internal/app/performance_results.go @@ -46,21 +46,22 @@ var performanceRateMetrics = map[string]bool{ } type CreateObservationInput struct { - RowNumber int `json:"row_number,omitempty"` - ProjectID string `json:"project_id,omitempty"` - ScriptVersionID string `json:"script_version_id"` - Platform string `json:"platform"` - AccountAlias string `json:"account_alias"` - PublishedAt time.Time `json:"published_at"` - WindowHours int `json:"window_hours"` - SampleStatus string `json:"sample_status"` - Metrics map[string]float64 `json:"metrics"` - Currency string `json:"currency,omitempty"` - Spend float64 `json:"spend,omitempty"` - GMV float64 `json:"gmv,omitempty"` - SubmittedROI *float64 `json:"roi,omitempty"` - IssueCategory string `json:"issue_category"` - Notes string `json:"notes"` + RowNumber int `json:"row_number,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ApprovedSnapshotID string `json:"approved_snapshot_id,omitempty"` + ScriptVersionID string `json:"script_version_id,omitempty"` + Platform string `json:"platform"` + AccountAlias string `json:"account_alias"` + PublishedAt time.Time `json:"published_at"` + WindowHours int `json:"window_hours"` + SampleStatus string `json:"sample_status"` + Metrics map[string]float64 `json:"metrics"` + Currency string `json:"currency,omitempty"` + Spend float64 `json:"spend,omitempty"` + GMV float64 `json:"gmv,omitempty"` + SubmittedROI *float64 `json:"roi,omitempty"` + IssueCategory string `json:"issue_category"` + Notes string `json:"notes"` } type ImportPerformanceInput struct { @@ -130,6 +131,7 @@ func (s *Service) ImportPerformanceObservations(ctx context.Context, actor Actor dedupRows := map[string]int{} batchCurrency := "" scriptCache := map[string]domain.ScriptVersion{} + snapshotCache := map[string]domain.ApprovedSnapshot{} for index, raw := range in.Observations { rowNumber := raw.RowNumber @@ -137,19 +139,36 @@ func (s *Service) ImportPerformanceObservations(ctx context.Context, actor Actor rowNumber = index + 1 } rowErrors := validatePerformanceInput(rowNumber, in.ProjectID, raw) - script, cached := scriptCache[raw.ScriptVersionID] + var script domain.ScriptVersion + var snapshot domain.ApprovedSnapshot + if strings.TrimSpace(raw.ApprovedSnapshotID) != "" { + cachedSnapshot, cached := snapshotCache[raw.ApprovedSnapshotID] + var loadErr error + if !cached { + cachedSnapshot, loadErr = s.store.ApprovedSnapshot(ctx, actor.TenantID, strings.TrimSpace(raw.ApprovedSnapshotID)) + if loadErr == nil { + snapshotCache[raw.ApprovedSnapshotID] = cachedSnapshot + } + } + snapshot = cachedSnapshot + if loadErr != nil || snapshot.ProjectID != in.ProjectID || snapshot.SubmissionType != "script" { + rowErrors = append(rowErrors, importRowError(rowNumber, "approved_snapshot_id", "RESULT_SNAPSHOT_NOT_FOUND", "批准快照不存在、不属于当前项目或不是 script 快照")) + } + } + cachedScript, cached := scriptCache[raw.ScriptVersionID] if !cached && strings.TrimSpace(raw.ScriptVersionID) != "" { var err error - script, err = s.store.Script(ctx, actor.TenantID, strings.TrimSpace(raw.ScriptVersionID)) + cachedScript, err = s.store.Script(ctx, actor.TenantID, strings.TrimSpace(raw.ScriptVersionID)) if err == nil { - scriptCache[raw.ScriptVersionID] = script + scriptCache[raw.ScriptVersionID] = cachedScript } - if err != nil || script.ProjectID != in.ProjectID { + if err != nil || cachedScript.ProjectID != in.ProjectID { rowErrors = append(rowErrors, importRowError(rowNumber, "script_version_id", "RESULT_SCRIPT_NOT_FOUND", "剧本版本不存在或不属于当前项目")) } - } else if cached && script.ProjectID != in.ProjectID { + } else if cached && cachedScript.ProjectID != in.ProjectID { rowErrors = append(rowErrors, importRowError(rowNumber, "script_version_id", "RESULT_SCRIPT_NOT_FOUND", "剧本版本不存在或不属于当前项目")) } + script = cachedScript currency := strings.ToUpper(strings.TrimSpace(raw.Currency)) if currency != "" { @@ -161,7 +180,11 @@ func (s *Service) ImportPerformanceObservations(ctx context.Context, actor Actor } publishedAt := raw.PublishedAt.UTC() - dedupKey := performanceDedupKey(in.ProjectID, raw.ScriptVersionID, raw.Platform, raw.AccountAlias, publishedAt, raw.WindowHours) + versionID := strings.TrimSpace(raw.ApprovedSnapshotID) + if versionID == "" { + versionID = strings.TrimSpace(raw.ScriptVersionID) + } + dedupKey := performanceDedupKey(in.ProjectID, versionID, raw.Platform, raw.AccountAlias, publishedAt, raw.WindowHours) if previousRow, duplicate := dedupRows[dedupKey]; duplicate { rowErrors = append(rowErrors, importRowError(rowNumber, "", "RESULT_DUPLICATE_ROW", fmt.Sprintf("与第 %d 行重复", previousRow))) } else { @@ -182,25 +205,26 @@ func (s *Service) ImportPerformanceObservations(ctx context.Context, actor Actor roi = &value } observations = append(observations, domain.PerformanceObservation{ - ID: domain.NewID(), - TenantID: actor.TenantID, - ProjectID: in.ProjectID, - RowNumber: rowNumber, - ScriptVersionID: script.ID, - Platform: strings.TrimSpace(raw.Platform), - AccountAlias: strings.TrimSpace(raw.AccountAlias), - PublishedAt: publishedAt, - WindowHours: raw.WindowHours, - SampleStatus: strings.TrimSpace(raw.SampleStatus), - Metrics: metrics, - Currency: currency, - Spend: raw.Spend, - GMV: raw.GMV, - ROI: roi, - DedupKey: dedupKey, - IssueCategory: strings.TrimSpace(raw.IssueCategory), - Notes: strings.TrimSpace(raw.Notes), - CreatedAt: now, + ID: domain.NewID(), + TenantID: actor.TenantID, + ProjectID: in.ProjectID, + RowNumber: rowNumber, + ApprovedSnapshotID: snapshot.ID, + ScriptVersionID: script.ID, + Platform: strings.TrimSpace(raw.Platform), + AccountAlias: strings.TrimSpace(raw.AccountAlias), + PublishedAt: publishedAt, + WindowHours: raw.WindowHours, + SampleStatus: strings.TrimSpace(raw.SampleStatus), + Metrics: metrics, + Currency: currency, + Spend: raw.Spend, + GMV: raw.GMV, + ROI: roi, + DedupKey: dedupKey, + IssueCategory: strings.TrimSpace(raw.IssueCategory), + Notes: strings.TrimSpace(raw.Notes), + CreatedAt: now, }) } @@ -285,8 +309,10 @@ func validatePerformanceInput(rowNumber int, projectID string, in CreateObservat if in.ProjectID != "" && in.ProjectID != projectID { add("project_id", "RESULT_PROJECT_MISMATCH", "行内项目与导入项目不一致") } - if strings.TrimSpace(in.ScriptVersionID) == "" { - add("script_version_id", "RESULT_FIELD_REQUIRED", "剧本版本必填") + hasSnapshot := strings.TrimSpace(in.ApprovedSnapshotID) != "" + hasLegacyScript := strings.TrimSpace(in.ScriptVersionID) != "" + if hasSnapshot == hasLegacyScript { + add("approved_snapshot_id", "RESULT_VERSION_REQUIRED", "approved_snapshot_id 必填,且不能与兼容字段 script_version_id 同时使用") } for field, value := range map[string]string{"platform": in.Platform, "account_alias": in.AccountAlias, "sample_status": in.SampleStatus, "issue_category": in.IssueCategory, "notes": in.Notes} { if dangerousSpreadsheetText(value) { @@ -399,6 +425,9 @@ func (s *Service) CreateRatingDecision(ctx context.Context, actor Actor, in Crea if err != nil || observation.ProjectID != in.ProjectID { return result, domain.NotFound("结果观察") } + if (in.SubjectType == "approved_snapshot" && observation.ApprovedSnapshotID != in.SubjectID) || (in.SubjectType == "script_version" && observation.ScriptVersionID != in.SubjectID) { + return result, domain.Conflict("RATING_OBSERVATION_SUBJECT_MISMATCH", "评级引用的结果观察不属于当前版本主体") + } } decision := domain.RatingDecision{ ID: domain.NewID(), @@ -435,6 +464,11 @@ func (s *Service) validateRatingSubject(ctx context.Context, actor Actor, projec return domain.Invalid("RATING_SUBJECT_REQUIRED", "评级对象必填") } switch subjectType { + case "approved_snapshot": + value, err := s.store.ApprovedSnapshot(ctx, actor.TenantID, subjectID) + if err != nil || value.ProjectID != projectID || value.SubmissionType != "script" { + return domain.NotFound("评级对象") + } case "script_version": v, err := s.store.Script(ctx, actor.TenantID, subjectID) if err != nil || v.ProjectID != projectID { @@ -457,7 +491,7 @@ func (s *Service) validateRatingSubject(ctx context.Context, actor Actor, projec } return domain.NotFound("评级对象") default: - return domain.Invalid("RATING_SUBJECT_TYPE_INVALID", "评级对象类型必须是 script_version、content_framework 或 shot_pattern") + return domain.Invalid("RATING_SUBJECT_TYPE_INVALID", "评级对象类型必须是 approved_snapshot、script_version、content_framework 或 shot_pattern") } return nil } diff --git a/internal/app/review_export.go b/internal/app/review_export.go index 429ba9e..61b600b 100644 --- a/internal/app/review_export.go +++ b/internal/app/review_export.go @@ -13,6 +13,7 @@ import ( "github.com/limecloud/contentcloud/internal/domain" "github.com/limecloud/contentcloud/internal/exportfmt" + "github.com/limecloud/contentcloud/internal/localworkspace" ) type CreateReviewCommentInput struct { @@ -96,25 +97,57 @@ func (s *Service) ResolveReviewComment(ctx context.Context, actor Actor, id, req } type ReviewProjection struct { - Project domain.Project `json:"project"` - Script domain.ScriptVersion `json:"script"` - Comments []domain.ReviewComment `json:"comments"` - Verified bool `json:"verified"` + Project domain.Project `json:"project"` + Script domain.ScriptVersion `json:"script,omitempty"` + Submission *SubmissionReviewSubject `json:"submission,omitempty"` + Comments []domain.ReviewComment `json:"comments"` + Verified bool `json:"verified"` } -func (s *Service) CreateReviewGrant(ctx context.Context, actor Actor, scriptID, reviewerEmail, requestID string) (domain.ReviewGrant, error) { +type SubmissionReviewSubject struct { + SubmissionID string `json:"submission_id"` + SubmissionRevisionID string `json:"submission_revision_id"` + SubjectHash string `json:"subject_hash"` + SchemaVersion string `json:"schema_version"` + Objects json.RawMessage `json:"objects"` +} + +type ReviewDecisionResult struct { + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Status string `json:"status"` + ApprovedSnapshot *domain.ApprovedSnapshot `json:"approved_snapshot,omitempty"` +} + +type SubmissionReviewStatus struct { + Submission domain.Submission `json:"submission"` + Revision domain.SubmissionRevision `json:"revision"` + Grants []domain.ReviewGrant `json:"grants"` +} + +func (s *Service) CreateReviewGrant(ctx context.Context, actor Actor, revisionID, reviewerEmail, requestID string) (domain.ReviewGrant, error) { if !canManage(actor.Role) { return domain.ReviewGrant{}, domain.Policy("ROLE_DENIED", "当前角色不能创建客户审批", "联系项目负责人") } - script, err := s.store.Script(ctx, actor.TenantID, scriptID) + revision, err := s.store.SubmissionRevision(ctx, actor.TenantID, revisionID) if err != nil { return domain.ReviewGrant{}, err } - if _, err := s.projectForWrite(ctx, actor, script.ProjectID); err != nil { + submission, err := s.store.Submission(ctx, actor.TenantID, revision.SubmissionID) + if err != nil { return domain.ReviewGrant{}, err } - if script.Status != "internally_approved" && script.Status != "client_review" { - return domain.ReviewGrant{}, domain.Conflict("SCRIPT_STATE_INVALID", "只有已通过内审的版本可发起客户审批") + if _, err := s.projectForWrite(ctx, actor, revision.ProjectID); err != nil { + return domain.ReviewGrant{}, err + } + if submission.SubmissionType != "script" { + return domain.ReviewGrant{}, domain.Invalid("REVIEW_SUBJECT_INVALID", "客户审批只接受 script SubmissionRevision") + } + if submission.CurrentRevisionID != revision.ID || (submission.Status != "internally_approved" && submission.Status != "client_review") { + return domain.ReviewGrant{}, domain.Conflict("SUBMISSION_STATE_INVALID", "只有当前已通过内审的 script SubmissionRevision 可发起客户审批") + } + if err := s.requireInternalSubmissionApproval(ctx, revision); err != nil { + return domain.ReviewGrant{}, err } if !strings.Contains(reviewerEmail, "@") { return domain.ReviewGrant{}, domain.Invalid("REVIEWER_EMAIL_INVALID", "客户审批邮箱无效") @@ -128,20 +161,16 @@ func (s *Service) CreateReviewGrant(ctx context.Context, actor Actor, scriptID, return domain.ReviewGrant{}, err } now := s.now().UTC() - v := domain.ReviewGrant{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: script.ProjectID, SubjectType: "script_version", SubjectID: script.ID, SubjectHash: script.ContentHash, ReviewerEmail: strings.ToLower(strings.TrimSpace(reviewerEmail)), TokenHash: tokenHash, OTPHash: domain.TokenHash(otp), ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, PlaintextToken: plain, PlaintextOTP: otp} + v := domain.ReviewGrant{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, ReviewerEmail: strings.ToLower(strings.TrimSpace(reviewerEmail)), TokenHash: tokenHash, OTPHash: domain.TokenHash(otp), ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, PlaintextToken: plain, PlaintextOTP: otp} stored := v stored.PlaintextToken = "" stored.PlaintextOTP = "" - if err := s.store.CreateReviewGrant(ctx, stored); err != nil { + submission.Status = "client_review" + submission.UpdatedAt = now + if err := s.store.CreateSubmissionReviewGrant(ctx, submission, stored); err != nil { return v, err } - if script.Status == "internally_approved" { - script.Status = "client_review" - if err := s.store.SaveScript(ctx, script); err != nil { - return v, err - } - } - s.audit(ctx, actor, script.ProjectID, "review_grant.created", "review_grant", v.ID, requestID, map[string]any{"script_version_id": script.ID, "expires_at": v.ExpiresAt}) + s.audit(ctx, actor, revision.ProjectID, "review_grant.created", "review_grant", v.ID, requestID, map[string]any{"submission_revision_id": revision.ID, "expires_at": v.ExpiresAt}) return v, nil } @@ -153,7 +182,30 @@ func newReviewOTP() (string, error) { return fmt.Sprintf("%06d", value.Int64()), nil } -func (s *Service) ReviewGrants(ctx context.Context, actor Actor, scriptVersionID string) ([]domain.ReviewGrant, error) { +func (s *Service) ReviewGrants(ctx context.Context, actor Actor, revisionID string) ([]domain.ReviewGrant, error) { + if _, err := s.store.SubmissionRevision(ctx, actor.TenantID, revisionID); err != nil { + return nil, err + } + return s.store.ReviewGrants(ctx, actor.TenantID, revisionID) +} + +func (s *Service) SubmissionReviewStatus(ctx context.Context, actor Actor, revisionID string) (SubmissionReviewStatus, error) { + revision, err := s.store.SubmissionRevision(ctx, actor.TenantID, revisionID) + if err != nil { + return SubmissionReviewStatus{}, err + } + submission, err := s.store.Submission(ctx, actor.TenantID, revision.SubmissionID) + if err != nil { + return SubmissionReviewStatus{}, err + } + grants, err := s.store.ReviewGrants(ctx, actor.TenantID, revision.ID) + if err != nil { + return SubmissionReviewStatus{}, err + } + return SubmissionReviewStatus{Submission: submission, Revision: revision, Grants: grants}, nil +} + +func (s *Service) LegacyReviewGrants(ctx context.Context, actor Actor, scriptVersionID string) ([]domain.ReviewGrant, error) { if _, err := s.store.Script(ctx, actor.TenantID, scriptVersionID); err != nil { return nil, err } @@ -178,10 +230,10 @@ func (s *Service) RevokeReviewGrant(ctx context.Context, actor Actor, grantID, r } now := s.now().UTC() grant.RevokedAt = &now - if err := s.store.SaveReviewGrant(ctx, grant); err != nil { + if err := s.store.RevokeReviewGrant(ctx, grant.TenantID, grant.ID, now); err != nil { return grant, err } - s.audit(ctx, actor, grant.ProjectID, "review_grant.revoked", "review_grant", grant.ID, requestID, map[string]any{"script_version_id": grant.SubjectID}) + s.audit(ctx, actor, grant.ProjectID, "review_grant.revoked", "review_grant", grant.ID, requestID, map[string]any{"subject_type": grant.SubjectType, "subject_id": grant.SubjectID}) grant.TokenHash = "" grant.OTPHash = "" return grant, nil @@ -192,10 +244,6 @@ func (s *Service) ReviewProjection(ctx context.Context, reviewToken string) (Rev if err != nil { return ReviewProjection{}, err } - script, err := s.store.Script(ctx, grant.TenantID, grant.SubjectID) - if err != nil || script.ContentHash != grant.SubjectHash { - return ReviewProjection{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效") - } project, err := s.store.Project(ctx, grant.TenantID, grant.ProjectID) if err != nil { return ReviewProjection{}, err @@ -203,14 +251,38 @@ func (s *Service) ReviewProjection(ctx context.Context, reviewToken string) (Rev if grant.VerifiedAt == nil { return ReviewProjection{Project: domain.Project{ID: project.ID, BrandName: project.BrandName, ProductName: project.ProductName}, Verified: false}, nil } - comments, _ := s.store.ReviewComments(ctx, grant.TenantID, script.ID) + if grant.SubjectType == "script_version" { + script, err := s.store.Script(ctx, grant.TenantID, grant.SubjectID) + if err != nil || script.ContentHash != grant.SubjectHash { + return ReviewProjection{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效") + } + comments, _ := s.store.ReviewComments(ctx, grant.TenantID, script.ID) + return ReviewProjection{Project: project, Script: script, Comments: clientVisibleComments(comments), Verified: true}, nil + } + if grant.SubjectType != "submission_revision" { + return ReviewProjection{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象类型无效") + } + revision, err := s.store.SubmissionRevision(ctx, grant.TenantID, grant.SubjectID) + if err != nil || revision.ContentHash != grant.SubjectHash { + return ReviewProjection{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效") + } + submission, err := s.store.Submission(ctx, grant.TenantID, revision.SubmissionID) + if err != nil || submission.CurrentRevisionID != revision.ID || submission.Status != "client_review" { + return ReviewProjection{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + } + comments, _ := s.store.ReviewComments(ctx, grant.TenantID, revision.ID) + subject := &SubmissionReviewSubject{SubmissionID: submission.ID, SubmissionRevisionID: revision.ID, SubjectHash: revision.ContentHash, SchemaVersion: revision.SchemaVersion, Objects: revision.Objects} + return ReviewProjection{Project: project, Submission: subject, Comments: clientVisibleComments(comments), Verified: true}, nil +} + +func clientVisibleComments(comments []domain.ReviewComment) []domain.ReviewComment { publicComments := []domain.ReviewComment{} for _, comment := range comments { if comment.Visibility == "client" { publicComments = append(publicComments, comment) } } - return ReviewProjection{Project: project, Script: script, Comments: publicComments, Verified: grant.VerifiedAt != nil}, nil + return publicComments } func (s *Service) VerifyReviewGrant(ctx context.Context, reviewToken, otp string) (ReviewProjection, error) { @@ -223,72 +295,157 @@ func (s *Service) VerifyReviewGrant(ctx context.Context, reviewToken, otp string } now := s.now().UTC() grant.VerifiedAt = &now - if err := s.store.SaveReviewGrant(ctx, grant); err != nil { + if err := s.store.MarkReviewGrantVerified(ctx, grant.TenantID, grant.ID, now); err != nil { return ReviewProjection{}, err } return s.ReviewProjection(ctx, reviewToken) } -func (s *Service) DecideReviewGrant(ctx context.Context, reviewToken, decision, reason, shotID, requestID string) (domain.ScriptVersion, error) { +func (s *Service) DecideReviewGrant(ctx context.Context, reviewToken, decision, reason, shotID, requestID string) (ReviewDecisionResult, error) { grant, err := s.reviewGrant(ctx, reviewToken) if err != nil { - return domain.ScriptVersion{}, err + return ReviewDecisionResult{}, err } if grant.VerifiedAt == nil { - return domain.ScriptVersion{}, domain.E("authentication", "review_otp", "REVIEW_VERIFICATION_REQUIRED", "请先完成邮箱验证码验证", 3) + return ReviewDecisionResult{}, domain.E("authentication", "review_otp", "REVIEW_VERIFICATION_REQUIRED", "请先完成邮箱验证码验证", 3) } if grant.DecisionAt != nil { - return domain.ScriptVersion{}, domain.Conflict("REVIEW_ALREADY_DECIDED", "该审批链接已完成最终决策") + return ReviewDecisionResult{}, domain.Conflict("REVIEW_ALREADY_DECIDED", "该审批链接已完成最终决策") + } + if grant.SubjectType == "script_version" { + return s.decideLegacyReviewGrant(ctx, grant, decision, reason, shotID, requestID) + } + if grant.SubjectType != "submission_revision" { + return ReviewDecisionResult{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象类型无效") + } + return s.decideSubmissionReviewGrant(ctx, grant, decision, reason, shotID, requestID) +} + +func (s *Service) decideSubmissionReviewGrant(ctx context.Context, grant domain.ReviewGrant, decision, reason, shotID, requestID string) (ReviewDecisionResult, error) { + revision, err := s.store.SubmissionRevision(ctx, grant.TenantID, grant.SubjectID) + if err != nil || revision.ContentHash != grant.SubjectHash { + return ReviewDecisionResult{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效") } + submission, err := s.store.Submission(ctx, grant.TenantID, revision.SubmissionID) + if err != nil || submission.CurrentRevisionID != revision.ID || submission.Status != "client_review" || submission.SubmissionType != "script" { + return ReviewDecisionResult{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + } + if err := s.requireInternalSubmissionApproval(ctx, revision); err != nil { + return ReviewDecisionResult{}, err + } + now := s.now().UTC() + previous := submission.Status + var comment *domain.ReviewComment + var snapshot *domain.ApprovedSnapshot + switch decision { + case "approve": + if err := s.requireResolvedComments(ctx, grant.TenantID, revision.ID, "client"); err != nil { + return ReviewDecisionResult{}, err + } + canonical, err := canonicalSubmissionContent(submission, revision) + if err != nil { + return ReviewDecisionResult{}, err + } + submission.Status = "approved" + snapshot = &domain.ApprovedSnapshot{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: revision.ProjectID, WorkspaceID: revision.WorkspaceID, SubmissionID: submission.ID, SubmissionRevisionID: revision.ID, SubmissionType: submission.SubmissionType, SchemaVersion: revision.SchemaVersion, ContentHash: revision.ContentHash, SubjectHash: revision.ContentHash, CanonicalContent: canonical, EligibleIDs: revision.EligibleObjectIDs(), Artifacts: revision.Artifacts, CreatedBy: "client:" + grant.ReviewerEmail, CreatedAt: now, Origin: "current"} + case "return": + reason = strings.TrimSpace(reason) + if reason == "" { + return ReviewDecisionResult{}, domain.Invalid("REVIEW_REASON_REQUIRED", "退回修改必须填写原因") + } + submission.Status = "changes_requested" + cycleID := s.submissionReviewCycleID(ctx, grant.TenantID, revision.ID) + comment = &domain.ReviewComment{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, ReviewCycleID: cycleID, SubjectType: "submission_revision", SubjectID: revision.ID, ShotID: shotID, Body: reason, Visibility: "client", AuthorID: "client:" + grant.ReviewerEmail, CreatedAt: now} + default: + return ReviewDecisionResult{}, domain.Invalid("DECISION_INVALID", "客户审批决策无效") + } + approval := domain.ApprovalDecision{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, DecisionStage: "client", ActorID: "client:" + grant.ReviewerEmail, Decision: decision, Reason: reason, PreviousState: previous, ResultingState: submission.Status, CreatedAt: now} + if snapshot != nil { + snapshot.DecisionID = approval.ID + } + submission.UpdatedAt = now + grant.DecisionAt = &now + if err := s.store.CompleteSubmissionClientReview(ctx, submission, grant, approval, comment, snapshot); err != nil { + return ReviewDecisionResult{}, err + } + s.audit(ctx, Actor{UserID: "client:" + grant.ReviewerEmail, TenantID: grant.TenantID, Type: "client"}, grant.ProjectID, "submission.client_reviewed", "submission_revision", revision.ID, requestID, map[string]any{"decision": decision, "to": submission.Status, "snapshot_id": valueOrEmptySnapshotID(snapshot)}) + return ReviewDecisionResult{SubjectType: "submission_revision", SubjectID: revision.ID, Status: submission.Status, ApprovedSnapshot: snapshot}, nil +} + +func (s *Service) decideLegacyReviewGrant(ctx context.Context, grant domain.ReviewGrant, decision, reason, shotID, requestID string) (ReviewDecisionResult, error) { script, err := s.store.Script(ctx, grant.TenantID, grant.SubjectID) if err != nil || script.ContentHash != grant.SubjectHash || script.Status != "client_review" { - return domain.ScriptVersion{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + return ReviewDecisionResult{}, domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") } previous := script.Status switch decision { case "approve": if err := s.requireResolvedComments(ctx, grant.TenantID, script.ID, "client"); err != nil { - return script, err + return ReviewDecisionResult{}, err } if err := s.validateScriptApprovalDependencies(ctx, grant.TenantID, script); err != nil { script.Status = "review_required" _ = s.store.SaveScript(ctx, script) - return script, err + return ReviewDecisionResult{}, err } script.Status = "approved" case "return": if strings.TrimSpace(reason) == "" { - return script, domain.Invalid("REVIEW_REASON_REQUIRED", "退回修改必须填写原因") + return ReviewDecisionResult{}, domain.Invalid("REVIEW_REASON_REQUIRED", "退回修改必须填写原因") } script.Status = "revision_requested" default: - return script, domain.Invalid("DECISION_INVALID", "客户审批决策无效") - } - if err := s.store.SaveScript(ctx, script); err != nil { - return script, err + return ReviewDecisionResult{}, domain.Invalid("DECISION_INVALID", "客户审批决策无效") } now := s.now().UTC() grant.DecisionAt = &now - if err := s.store.SaveReviewGrant(ctx, grant); err != nil { - return script, err - } - approval := domain.ApprovalDecision{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, SubjectType: "script_version", SubjectID: script.ID, SubjectHash: script.ContentHash, ActorID: "client:" + grant.ReviewerEmail, Decision: decision, Reason: reason, PreviousState: previous, ResultingState: script.Status, CreatedAt: now} - if err := s.store.CreateApproval(ctx, approval); err != nil { - return script, err - } + approval := domain.ApprovalDecision{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, SubjectType: "script_version", SubjectID: script.ID, SubjectHash: script.ContentHash, DecisionStage: "legacy", ActorID: "client:" + grant.ReviewerEmail, Decision: decision, Reason: reason, PreviousState: previous, ResultingState: script.Status, CreatedAt: now} + var comment *domain.ReviewComment if decision == "return" && reason != "" { cycles, _ := s.store.ReviewCycles(ctx, grant.TenantID, script.ID) cycleID := "" if len(cycles) > 0 { cycleID = cycles[0].ID } - _ = s.store.CreateReviewComment(ctx, domain.ReviewComment{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, ReviewCycleID: cycleID, SubjectType: "script_version", SubjectID: script.ID, ShotID: shotID, Body: reason, Visibility: "client", AuthorID: "client:" + grant.ReviewerEmail, CreatedAt: now}) + value := domain.ReviewComment{ID: domain.NewID(), TenantID: grant.TenantID, ProjectID: grant.ProjectID, ReviewCycleID: cycleID, SubjectType: "script_version", SubjectID: script.ID, ShotID: shotID, Body: reason, Visibility: "client", AuthorID: "client:" + grant.ReviewerEmail, CreatedAt: now} + comment = &value + } + if err := s.store.CompleteLegacyClientReview(ctx, script, grant, approval, comment); err != nil { + return ReviewDecisionResult{}, err } if decision == "approve" { _ = s.supersedeApprovedScriptVersions(ctx, grant.TenantID, script) } s.audit(ctx, Actor{UserID: "client:" + grant.ReviewerEmail, TenantID: grant.TenantID, Type: "client"}, grant.ProjectID, "script.client_reviewed", "script_version", script.ID, requestID, map[string]any{"decision": decision, "to": script.Status}) - return script, nil + return ReviewDecisionResult{SubjectType: "script_version", SubjectID: script.ID, Status: script.Status}, nil +} + +func (s *Service) requireInternalSubmissionApproval(ctx context.Context, revision domain.SubmissionRevision) error { + decisions, err := s.store.Approvals(ctx, revision.TenantID, revision.ID) + if err != nil { + return err + } + for _, decision := range decisions { + if decision.SubjectType == "submission_revision" && decision.SubjectHash == revision.ContentHash && decision.DecisionStage == "internal" && decision.Decision == "approve" { + return nil + } + } + return domain.Policy("INTERNAL_APPROVAL_REQUIRED", "客户审批前必须完成同一 revision 的内部批准", "先完成 SubmissionRevision 内审") +} + +func (s *Service) submissionReviewCycleID(ctx context.Context, tenantID, revisionID string) string { + cycles, err := s.store.ReviewCycles(ctx, tenantID, revisionID) + if err == nil && len(cycles) > 0 { + return cycles[0].ID + } + return "" +} + +func valueOrEmptySnapshotID(snapshot *domain.ApprovedSnapshot) string { + if snapshot == nil { + return "" + } + return snapshot.ID } func (s *Service) validateScriptApprovalDependencies(ctx context.Context, tenantID string, script domain.ScriptVersion) error { @@ -341,7 +498,7 @@ func (s *Service) supersedeApprovedScriptVersions(ctx context.Context, tenantID for _, grant := range grants { if grant.RevokedAt == nil && grant.DecisionAt == nil { grant.RevokedAt = &now - _ = s.store.SaveReviewGrant(ctx, grant) + _ = s.store.RevokeReviewGrant(ctx, grant.TenantID, grant.ID, now) } } } @@ -408,6 +565,146 @@ func (s *Service) ExportScript(ctx context.Context, actor Actor, scriptID, forma return artifact, nil } +func (s *Service) ExportApprovedSnapshot(ctx context.Context, actor Actor, snapshotID, scriptID, format, requestID string) (domain.Artifact, error) { + if err := requireRole(actor, "tenant_admin", "project_manager", "editor"); err != nil { + return domain.Artifact{}, err + } + snapshot, rendered, err := s.renderApprovedSnapshotScript(ctx, actor, snapshotID, scriptID) + if err != nil { + return domain.Artifact{}, err + } + file, err := renderedScriptFile(rendered, format) + if err != nil { + return domain.Artifact{}, err + } + now := s.now().UTC() + artifact := snapshotArtifact(snapshot, rendered, file, actor.UserID, now) + artifact.ObjectKey = fmt.Sprintf("tenants/%s/projects/%s/approved-snapshots/%s/exports/%s/%s", actor.TenantID, snapshot.ProjectID, snapshot.ID, artifact.ID, artifact.FileName) + if err := s.blobs.Put(ctx, artifact.ObjectKey, file.Body); err != nil { + return artifact, err + } + if err := s.store.CreateArtifact(ctx, artifact); err != nil { + return artifact, err + } + s.audit(ctx, actor, snapshot.ProjectID, "approved_snapshot.exported", "artifact", artifact.ID, requestID, map[string]any{"approved_snapshot_id": snapshot.ID, "script_id": rendered.Package.ID, "format": file.Format, "sha256": file.SHA256}) + return artifact, nil +} + +func (s *Service) CreateDeliveryPackage(ctx context.Context, actor Actor, snapshotID, scriptID, requestID string) (domain.DeliveryPackage, error) { + if err := requireRole(actor, "tenant_admin", "project_manager", "editor"); err != nil { + return domain.DeliveryPackage{}, err + } + snapshot, rendered, err := s.renderApprovedSnapshotScript(ctx, actor, snapshotID, scriptID) + if err != nil { + return domain.DeliveryPackage{}, err + } + now := s.now().UTC() + value := domain.DeliveryPackage{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: snapshot.ProjectID, ApprovedSnapshotIDs: []string{snapshot.ID}, ScriptID: rendered.Package.ID, Status: "ready", CreatedBy: actor.UserID, CreatedAt: now} + artifacts := make([]domain.Artifact, 0, len(rendered.Files)) + for _, file := range rendered.Files { + artifact := snapshotArtifact(snapshot, rendered, file, actor.UserID, now) + artifact.ObjectKey = fmt.Sprintf("tenants/%s/projects/%s/delivery-packages/%s/%s", actor.TenantID, snapshot.ProjectID, value.ID, artifact.FileName) + if err := s.blobs.Put(ctx, artifact.ObjectKey, file.Body); err != nil { + return domain.DeliveryPackage{}, err + } + artifacts = append(artifacts, artifact) + } + if err := s.store.CreateDeliveryPackage(ctx, value, artifacts); err != nil { + return domain.DeliveryPackage{}, err + } + value.Manifest = artifacts + s.audit(ctx, actor, snapshot.ProjectID, "delivery_package.created", "delivery_package", value.ID, requestID, map[string]any{"approved_snapshot_id": snapshot.ID, "script_id": rendered.Package.ID, "file_count": len(artifacts), "revision_hash": snapshot.ContentHash}) + return value, nil +} + +func (s *Service) DeliveryPackages(ctx context.Context, actor Actor, projectID string) ([]domain.DeliveryPackage, error) { + if _, err := s.store.Project(ctx, actor.TenantID, projectID); err != nil { + return nil, err + } + return s.store.DeliveryPackages(ctx, actor.TenantID, projectID) +} + +func (s *Service) DeliveryPackage(ctx context.Context, actor Actor, id string) (domain.DeliveryPackage, error) { + return s.store.DeliveryPackage(ctx, actor.TenantID, id) +} + +func (s *Service) ApprovedSnapshotArtifacts(ctx context.Context, actor Actor, snapshotID string) ([]domain.Artifact, error) { + if _, err := s.store.ApprovedSnapshot(ctx, actor.TenantID, snapshotID); err != nil { + return nil, err + } + return s.store.ArtifactsByApprovedSnapshot(ctx, actor.TenantID, snapshotID) +} + +func (s *Service) renderApprovedSnapshotScript(ctx context.Context, actor Actor, snapshotID, scriptID string) (domain.ApprovedSnapshot, localworkspace.RenderedScriptDelivery, error) { + snapshot, err := s.store.ApprovedSnapshot(ctx, actor.TenantID, snapshotID) + if err != nil { + return snapshot, localworkspace.RenderedScriptDelivery{}, err + } + if _, err := s.projectForWrite(ctx, actor, snapshot.ProjectID); err != nil { + return snapshot, localworkspace.RenderedScriptDelivery{}, err + } + if snapshot.Origin != "current" || snapshot.SubmissionType != "script" || snapshot.SubmissionRevisionID == "" { + return snapshot, localworkspace.RenderedScriptDelivery{}, domain.Policy("SNAPSHOT_NOT_DELIVERABLE", "只有当前轨道客户批准的 script ApprovedSnapshot 可生成新交付", "V1 影子快照仅用于历史读取和结果归因") + } + raw, err := approvedSnapshotObject(snapshot, scriptID) + if err != nil { + return snapshot, localworkspace.RenderedScriptDelivery{}, err + } + rendered, err := localworkspace.RenderScriptPackageV2(raw) + return snapshot, rendered, err +} + +func approvedSnapshotObject(snapshot domain.ApprovedSnapshot, objectID string) (json.RawMessage, error) { + var canonical struct { + Objects []json.RawMessage `json:"objects"` + } + if err := json.Unmarshal(snapshot.CanonicalContent, &canonical); err != nil || len(canonical.Objects) == 0 { + return nil, domain.Invalid("APPROVED_SNAPSHOT_INVALID", "批准快照缺少 canonical objects") + } + eligible := map[string]bool{} + for _, id := range snapshot.EligibleIDs { + eligible[id] = true + } + matches := []json.RawMessage{} + for _, raw := range canonical.Objects { + var identity struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &identity) == nil && identity.ID != "" && eligible[identity.ID] && (objectID == "" || identity.ID == objectID) { + matches = append(matches, raw) + } + } + if len(matches) == 0 { + return nil, domain.NotFound("批准快照中的 script") + } + if len(matches) > 1 { + return nil, domain.Invalid("DELIVERY_SCRIPT_REQUIRED", "批准快照包含多个 script,必须明确 script_id") + } + return matches[0], nil +} + +func renderedScriptFile(rendered localworkspace.RenderedScriptDelivery, format string) (localworkspace.RenderedScriptFile, error) { + if format == "md" { + format = "markdown" + } + for _, file := range rendered.Files { + if file.Format == format { + return file, nil + } + } + return localworkspace.RenderedScriptFile{}, domain.Invalid("EXPORT_FORMAT_INVALID", "导出格式必须为 markdown、xlsx 或 json") +} + +func snapshotArtifact(snapshot domain.ApprovedSnapshot, rendered localworkspace.RenderedScriptDelivery, file localworkspace.RenderedScriptFile, createdBy string, now time.Time) domain.Artifact { + schemaID := domain.ArtifactExportSchemaMD + if file.Format == "json" { + schemaID = localworkspace.ScriptPackageV2Schema + } else if file.Format == "xlsx" { + schemaID = domain.ArtifactExportSchemaXLSX + } + return domain.Artifact{ID: domain.NewID(), TenantID: snapshot.TenantID, ProjectID: snapshot.ProjectID, ApprovedSnapshotID: snapshot.ID, Kind: "delivery", CapabilityID: domain.ArtifactExportCapability, CapabilityVersion: "2.0.0", CapabilityDigest: "contentcloud-script-delivery@2", SchemaID: schemaID, MediaType: file.MediaType, FileName: file.Name, SHA256: file.SHA256, ByteSize: int64(len(file.Body)), Visibility: "client", RetentionClass: "audit", Purpose: "delivery", ValidationStatus: "valid", PresentationTier: "cloud_native", Metadata: map[string]any{"format": file.Format, "script_id": rendered.Package.ID, "script_hash": rendered.ScriptHash, "revision_hash": snapshot.ContentHash, "approved_snapshot_id": snapshot.ID, "created_by": createdBy}, CreatedAt: now} +} + func (s *Service) Artifacts(ctx context.Context, actor Actor, scriptID string) ([]domain.Artifact, error) { if scriptID != "" { if _, err := s.store.Script(ctx, actor.TenantID, scriptID); err != nil { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index f11f991..b891298 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -16,7 +16,8 @@ import ( func TestEndToEndScriptFlow(t *testing.T) { ctx := context.Background() - service := app.New(memory.New(), slog.Default()) + store := memory.New() + service := app.New(store, slog.Default()) session, err := service.Register(ctx, "owner@example.com", "long-enough-password", "Owner", "Agency") must(t, err) actor, _, err := service.SessionActor(ctx, session.ID) @@ -102,10 +103,19 @@ func TestEndToEndScriptFlow(t *testing.T) { must(t, err) clientComment, err := service.CreateReviewComment(ctx, actor, app.CreateReviewCommentInput{SubjectID: script.ID, ShotID: script.Package.Shots[0].ShotID, Body: "client note", Visibility: "client"}, "req-comment-client") must(t, err) - grant, err := service.CreateReviewGrant(ctx, actor, script.ID, "client@example.com", "req-12") - must(t, err) - revokedGrant, err := service.CreateReviewGrant(ctx, actor, script.ID, "revoked@example.com", "req-12-revoked") - must(t, err) + script.Status = "client_review" + must(t, store.SaveScript(ctx, script)) + newLegacyGrant := func(email, otp string) domain.ReviewGrant { + plain, tokenHash, tokenErr := domain.NewOpaqueToken("crg_", 32) + must(t, tokenErr) + stored := domain.ReviewGrant{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, SubjectType: "script_version", SubjectID: script.ID, SubjectHash: script.ContentHash, ReviewerEmail: email, TokenHash: tokenHash, OTPHash: domain.TokenHash(otp), ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now()} + must(t, store.CreateReviewGrant(ctx, stored)) + stored.PlaintextToken = plain + stored.PlaintextOTP = otp + return stored + } + grant := newLegacyGrant("client@example.com", "123456") + revokedGrant := newLegacyGrant("revoked@example.com", "654321") revokedToken := revokedGrant.PlaintextToken revokedGrant, err = service.RevokeReviewGrant(ctx, actor, revokedGrant.ID, "req-12-revoke") must(t, err) @@ -115,7 +125,7 @@ func TestEndToEndScriptFlow(t *testing.T) { if _, err := service.ReviewProjection(ctx, revokedToken); err == nil { t.Fatal("revoked review token must fail immediately") } - grants, err := service.ReviewGrants(ctx, actor, script.ID) + grants, err := service.LegacyReviewGrants(ctx, actor, script.ID) must(t, err) if len(grants) != 2 || grants[0].TokenHash != "" || grants[0].OTPHash != "" { t.Fatalf("review grant list leaked secrets or omitted history: %#v", grants) @@ -137,11 +147,13 @@ func TestEndToEndScriptFlow(t *testing.T) { assertDomainCode(t, err, "REVIEW_COMMENTS_UNRESOLVED") _, err = service.ResolveReviewComment(ctx, actor, clientComment.ID, "req-comment-client-resolve") must(t, err) - script, err = service.DecideReviewGrant(ctx, grant.PlaintextToken, "approve", "approved", "", "req-13") + legacyDecision, err := service.DecideReviewGrant(ctx, grant.PlaintextToken, "approve", "approved", "", "req-13") must(t, err) - if script.Status != "approved" { - t.Fatalf("expected approved, got %s", script.Status) + if legacyDecision.Status != "approved" { + t.Fatalf("expected approved, got %s", legacyDecision.Status) } + script, err = service.Script(ctx, actor, script.ID) + must(t, err) revisedBrief, err := service.CreateBrief(ctx, actor, app.CreateBriefInput{ProjectID: project.ID, Objective: "conversion", Audience: brief.Audience, DemandMoment: brief.DemandMoment, Scene: brief.Scene, Conflict: brief.Conflict, PrimarySellingPoint: brief.PrimarySellingPoint, EvidenceSummary: brief.EvidenceSummary, CTA: "view product details", Channel: brief.Channel, AspectRatio: brief.AspectRatio, TargetDurationSeconds: brief.TargetDurationSeconds, PrimaryTestVariable: "cta", ApprovedKnowledgeIDs: brief.ApprovedKnowledgeIDs, FrameworkIDs: brief.FrameworkIDs, VisualizationPlanIDs: brief.VisualizationPlanIDs, SupersedesID: brief.ID, RevisionReason: "change the primary objective and test variable"}, "req-brief-revision") must(t, err) revisedBrief, err = service.ReviewBrief(ctx, actor, revisedBrief.ID, "submit", "req-brief-revision-submit") diff --git a/internal/app/submissions.go b/internal/app/submissions.go index 06d900e..6631537 100644 --- a/internal/app/submissions.go +++ b/internal/app/submissions.go @@ -15,6 +15,12 @@ type SubmissionDetails struct { Comments []domain.ReviewComment `json:"comments"` } +type SubmissionApprovalResult struct { + Submission domain.Submission `json:"submission"` + Decision domain.ApprovalDecision `json:"decision"` + ApprovedSnapshot *domain.ApprovedSnapshot `json:"approved_snapshot,omitempty"` +} + func (s *Service) RegisterWorkspace(ctx context.Context, actor Actor, binding domain.WorkspaceBinding, templateID, templateVersion string, targets []string, requestID string) (domain.WorkspaceBinding, error) { if actor.Type != "workspace" || actor.WorkspaceID != binding.ID { return binding, domain.Policy("WORKSPACE_SCOPE_DENIED", "工作区凭据与绑定不匹配", "重新执行项目初始化") @@ -164,47 +170,57 @@ func (s *Service) SubmissionDetails(ctx context.Context, actor Actor, id string) return SubmissionDetails{Submission: submission, Revisions: revisions, Comments: comments}, nil } -func (s *Service) ApproveSubmission(ctx context.Context, actor Actor, revisionID, reason, requestID string) (domain.ApprovedSnapshot, error) { +func (s *Service) ApproveSubmission(ctx context.Context, actor Actor, revisionID, reason, requestID string) (SubmissionApprovalResult, error) { if err := requireRole(actor, "tenant_admin", "project_manager", "reviewer"); err != nil { - return domain.ApprovedSnapshot{}, err + return SubmissionApprovalResult{}, err } if strings.TrimSpace(reason) == "" { - return domain.ApprovedSnapshot{}, domain.Invalid("APPROVAL_REASON_REQUIRED", "批准必须填写整版结论") + return SubmissionApprovalResult{}, domain.Invalid("APPROVAL_REASON_REQUIRED", "批准必须填写整版结论") } revision, err := s.store.SubmissionRevision(ctx, actor.TenantID, revisionID) if err != nil { - return domain.ApprovedSnapshot{}, err + return SubmissionApprovalResult{}, err } submission, err := s.store.Submission(ctx, actor.TenantID, revision.SubmissionID) if err != nil { - return domain.ApprovedSnapshot{}, err + return SubmissionApprovalResult{}, err } if submission.CurrentRevisionID != revision.ID || (submission.Status != "submitted" && submission.Status != "in_review") { - return domain.ApprovedSnapshot{}, domain.Conflict("SUBMISSION_STATE_INVALID", "只能批准当前待审 SubmissionRevision") + return SubmissionApprovalResult{}, domain.Conflict("SUBMISSION_STATE_INVALID", "只能批准当前待审 SubmissionRevision") } if revision.EvidenceLimited { - return domain.ApprovedSnapshot{}, domain.Policy("EVIDENCE_LEVEL_INSUFFICIENT", "高风险内容的来源披露不足,不能远程批准", "上传 evidence_pack/full_source,或完成受治理的本地核验") + return SubmissionApprovalResult{}, domain.Policy("EVIDENCE_LEVEL_INSUFFICIENT", "高风险内容的来源披露不足,不能远程批准", "上传 evidence_pack/full_source,或完成受治理的本地核验") } if err := s.requireResolvedComments(ctx, actor.TenantID, revision.ID, ""); err != nil { - return domain.ApprovedSnapshot{}, err - } - canonical, err := json.Marshal(map[string]any{ - "schema_version": revision.SchemaVersion, "submission_type": submission.SubmissionType, "objects": revision.Objects, - "source_disclosures": revision.SourceDisclosures, "artifacts": revision.Artifacts, "local_run_summary": revision.LocalRunSummary, - }) - if err != nil { - return domain.ApprovedSnapshot{}, err + return SubmissionApprovalResult{}, err } now := s.now().UTC() - decision := domain.ApprovalDecision{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, ActorID: actor.UserID, Decision: "approve", Reason: strings.TrimSpace(reason), PreviousState: submission.Status, ResultingState: "approved", CreatedAt: now} - snapshot := domain.ApprovedSnapshot{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, WorkspaceID: revision.WorkspaceID, SubmissionID: submission.ID, SubmissionRevisionID: revision.ID, SubmissionType: submission.SubmissionType, SchemaVersion: revision.SchemaVersion, ContentHash: revision.ContentHash, SubjectHash: revision.ContentHash, CanonicalContent: canonical, EligibleIDs: revision.EligibleObjectIDs(), Artifacts: revision.Artifacts, DecisionID: decision.ID, CreatedBy: actor.UserID, CreatedAt: now} - submission.Status = "approved" + resultingState := "approved" + if submission.SubmissionType == "script" { + resultingState = "internally_approved" + } + decision := domain.ApprovalDecision{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, DecisionStage: "internal", ActorID: actor.UserID, Decision: "approve", Reason: strings.TrimSpace(reason), PreviousState: submission.Status, ResultingState: resultingState, CreatedAt: now} + submission.Status = resultingState submission.UpdatedAt = now + result := SubmissionApprovalResult{Submission: submission, Decision: decision} + if submission.SubmissionType == "script" { + if err := s.store.RecordSubmissionApproval(ctx, submission, decision); err != nil { + return SubmissionApprovalResult{}, err + } + s.audit(ctx, actor, revision.ProjectID, "submission.internally_approved", "submission_revision", revision.ID, requestID, map[string]any{"submission_id": submission.ID, "content_hash": revision.ContentHash}) + return result, nil + } + canonical, err := canonicalSubmissionContent(submission, revision) + if err != nil { + return SubmissionApprovalResult{}, err + } + snapshot := domain.ApprovedSnapshot{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, WorkspaceID: revision.WorkspaceID, SubmissionID: submission.ID, SubmissionRevisionID: revision.ID, SubmissionType: submission.SubmissionType, SchemaVersion: revision.SchemaVersion, ContentHash: revision.ContentHash, SubjectHash: revision.ContentHash, CanonicalContent: canonical, EligibleIDs: revision.EligibleObjectIDs(), Artifacts: revision.Artifacts, DecisionID: decision.ID, CreatedBy: actor.UserID, CreatedAt: now, Origin: "current"} if err := s.store.ApproveSubmissionRevision(ctx, submission, snapshot, decision); err != nil { - return domain.ApprovedSnapshot{}, err + return SubmissionApprovalResult{}, err } s.audit(ctx, actor, revision.ProjectID, "submission.approved", "submission_revision", revision.ID, requestID, map[string]any{"submission_id": submission.ID, "snapshot_id": snapshot.ID, "content_hash": snapshot.ContentHash}) - return snapshot, nil + result.ApprovedSnapshot = &snapshot + return result, nil } func (s *Service) RequestSubmissionChanges(ctx context.Context, actor Actor, revisionID, reason, jsonPointer, requestID string) (domain.Submission, error) { @@ -226,7 +242,7 @@ func (s *Service) RequestSubmissionChanges(ctx context.Context, actor Actor, rev if err != nil { return submission, err } - if submission.CurrentRevisionID != revision.ID || (submission.Status != "submitted" && submission.Status != "in_review") { + if submission.CurrentRevisionID != revision.ID || (submission.Status != "submitted" && submission.Status != "in_review" && submission.Status != "internally_approved" && submission.Status != "client_review") { return submission, domain.Conflict("SUBMISSION_STATE_INVALID", "只能退回当前待审 SubmissionRevision") } cycles, err := s.store.ReviewCycles(ctx, actor.TenantID, revision.ID) @@ -246,7 +262,7 @@ func (s *Service) RequestSubmissionChanges(ctx context.Context, actor Actor, rev } now := s.now().UTC() comment := domain.ReviewComment{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, ReviewCycleID: cycle.ID, SubjectType: "submission_revision", SubjectID: revision.ID, JSONPointer: jsonPointer, Body: reason, Visibility: "internal", AuthorID: actor.UserID, CreatedAt: now} - decision := domain.ApprovalDecision{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, ActorID: actor.UserID, Decision: "request_changes", Reason: reason, PreviousState: submission.Status, ResultingState: "changes_requested", CreatedAt: now} + decision := domain.ApprovalDecision{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: revision.ProjectID, SubjectType: "submission_revision", SubjectID: revision.ID, SubjectHash: revision.ContentHash, DecisionStage: "internal", ActorID: actor.UserID, Decision: "request_changes", Reason: reason, PreviousState: submission.Status, ResultingState: "changes_requested", CreatedAt: now} submission.Status = "changes_requested" submission.UpdatedAt = now if err := s.store.RequestSubmissionChanges(ctx, submission, decision, comment); err != nil { @@ -256,6 +272,13 @@ func (s *Service) RequestSubmissionChanges(ctx context.Context, actor Actor, rev return submission, nil } +func canonicalSubmissionContent(submission domain.Submission, revision domain.SubmissionRevision) (json.RawMessage, error) { + return json.Marshal(map[string]any{ + "schema_version": revision.SchemaVersion, "submission_type": submission.SubmissionType, "objects": revision.Objects, + "source_disclosures": revision.SourceDisclosures, "artifacts": revision.Artifacts, "local_run_summary": revision.LocalRunSummary, + }) +} + func (s *Service) ApprovedSnapshots(ctx context.Context, actor Actor, projectID, submissionType string) ([]domain.ApprovedSnapshot, error) { if actor.Type == "workspace" { binding, err := s.store.WorkspaceBinding(ctx, actor.TenantID, actor.WorkspaceID) diff --git a/internal/app/submissions_test.go b/internal/app/submissions_test.go index 91ecd65..18194f6 100644 --- a/internal/app/submissions_test.go +++ b/internal/app/submissions_test.go @@ -67,10 +67,14 @@ func TestWorkspaceSubmissionApprovalCreatesImmutableSnapshotWithoutTaskRun(t *te if err != nil || replayed.ID != revision.ID { t.Fatalf("idempotent retry failed: %#v %v", replayed, err) } - snapshot, err := service.ApproveSubmission(ctx, admin, revision.ID, "facts and citations reviewed", "req-approve") + approval, err := service.ApproveSubmission(ctx, admin, revision.ID, "facts and citations reviewed", "req-approve") if err != nil { t.Fatal(err) } + if approval.ApprovedSnapshot == nil { + t.Fatal("knowledge approval must create an ApprovedSnapshot") + } + snapshot := *approval.ApprovedSnapshot if snapshot.SubmissionRevisionID != revision.ID || snapshot.ContentHash != revision.ContentHash || len(snapshot.EligibleIDs) != 1 || snapshot.EligibleIDs[0] != "fact-1" { t.Fatalf("unexpected approved snapshot: %#v", snapshot) } diff --git a/internal/app/v2_full_chain_test.go b/internal/app/v2_full_chain_test.go new file mode 100644 index 0000000..60493d8 --- /dev/null +++ b/internal/app/v2_full_chain_test.go @@ -0,0 +1,209 @@ +package app_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/limecloud/contentcloud/internal/app" + "github.com/limecloud/contentcloud/internal/domain" + "github.com/limecloud/contentcloud/internal/localworkspace" + "github.com/limecloud/contentcloud/internal/store/memory" +) + +func TestV2ScriptApprovalDeliveryAndLearningChain(t *testing.T) { + ctx, service, store, actor, binding := v2ScriptFixture(t) + revision := publishV2Script(t, ctx, service, binding, "script-v2", "publish-v1") + + if _, err := service.CreateReviewGrant(ctx, actor, revision.ID, "client@example.com", "grant-before-internal"); err == nil { + t.Fatal("customer review must not start before internal approval") + } + internal, err := service.ApproveSubmission(ctx, actor, revision.ID, "internal review passed", "internal-approve") + if err != nil { + t.Fatal(err) + } + if internal.ApprovedSnapshot != nil || internal.Submission.Status != "internally_approved" || internal.Decision.DecisionStage != "internal" { + t.Fatalf("script internal approval created a premature snapshot: %#v", internal) + } + + grant, err := service.CreateReviewGrant(ctx, actor, revision.ID, "client@example.com", "grant") + if err != nil { + t.Fatal(err) + } + if grant.SubjectType != "submission_revision" || grant.SubjectID != revision.ID || grant.SubjectHash != revision.ContentHash { + t.Fatalf("grant is not bound to the immutable revision: %#v", grant) + } + status, err := service.SubmissionReviewStatus(ctx, actor, revision.ID) + if err != nil || status.Submission.Status != "client_review" || status.Revision.ID != revision.ID || len(status.Grants) != 1 || status.Grants[0].ID != grant.ID { + t.Fatalf("revision review status is incomplete: %#v err=%v", status, err) + } + unverified, err := service.ReviewProjection(ctx, grant.PlaintextToken) + if err != nil || unverified.Verified || unverified.Submission != nil { + t.Fatalf("unverified projection leaked content: %#v err=%v", unverified, err) + } + if _, err := service.VerifyReviewGrant(ctx, grant.PlaintextToken, "000000"); err == nil { + t.Fatal("wrong OTP must fail") + } + verified, err := service.VerifyReviewGrant(ctx, grant.PlaintextToken, grant.PlaintextOTP) + if err != nil || !verified.Verified || verified.Submission == nil || verified.Submission.SubmissionRevisionID != revision.ID { + t.Fatalf("verified projection is incomplete: %#v err=%v", verified, err) + } + + clientDecision, err := service.DecideReviewGrant(ctx, grant.PlaintextToken, "approve", "client approved", "", "client-approve") + if err != nil { + t.Fatal(err) + } + if clientDecision.Status != "approved" || clientDecision.ApprovedSnapshot == nil { + t.Fatalf("client approval did not create a snapshot: %#v", clientDecision) + } + snapshot := *clientDecision.ApprovedSnapshot + if snapshot.SubmissionRevisionID != revision.ID || snapshot.ContentHash != revision.ContentHash || snapshot.Origin != "current" || snapshot.CreatedBy != "client:client@example.com" { + t.Fatalf("snapshot lineage is incomplete: %#v", snapshot) + } + if _, err := service.DecideReviewGrant(ctx, grant.PlaintextToken, "approve", "duplicate", "", "duplicate"); err == nil { + t.Fatal("a review grant must be single-use") + } + decisions, err := store.Approvals(ctx, actor.TenantID, revision.ID) + if err != nil || len(decisions) != 2 || decisions[0].DecisionStage != "client" || decisions[1].DecisionStage != "internal" { + t.Fatalf("internal/client decisions were not preserved on one revision: %#v err=%v", decisions, err) + } + + delivery, err := service.CreateDeliveryPackage(ctx, actor, snapshot.ID, "script-v2", "delivery") + if err != nil { + t.Fatal(err) + } + if delivery.Status != "ready" || len(delivery.Manifest) != 3 || len(delivery.ApprovedSnapshotIDs) != 1 || delivery.ApprovedSnapshotIDs[0] != snapshot.ID { + t.Fatalf("delivery package is incomplete: %#v", delivery) + } + formats := map[string]bool{} + for _, artifact := range delivery.Manifest { + formats[artifact.Metadata["format"].(string)] = true + if artifact.ApprovedSnapshotID != snapshot.ID || artifact.Metadata["revision_hash"] != revision.ContentHash { + t.Fatalf("artifact is not derived from the approved snapshot: %#v", artifact) + } + _, body, err := service.ArtifactBytes(ctx, actor, artifact.ID) + if err != nil { + t.Fatal(err) + } + if artifact.Metadata["format"] == "xlsx" { + if _, err := zip.NewReader(bytes.NewReader(body), int64(len(body))); err != nil { + t.Fatalf("xlsx delivery is not a valid archive: %v", err) + } + } + } + if !formats["json"] || !formats["markdown"] || !formats["xlsx"] { + t.Fatalf("delivery formats are incomplete: %#v", formats) + } + + now := time.Now().UTC().Add(-24 * time.Hour) + imported, err := service.ImportPerformanceObservations(ctx, actor, app.ImportPerformanceInput{ProjectID: binding.ProjectID, SourceName: "results.csv", SourceFormat: "csv", Observations: []app.CreateObservationInput{{RowNumber: 2, ApprovedSnapshotID: snapshot.ID, Platform: "douyin", AccountAlias: "brand-main", PublishedAt: now, WindowHours: 24, SampleStatus: "seed_candidate", Metrics: map[string]float64{"impressions": 1000}, Currency: "CNY", Spend: 100, GMV: 300, IssueCategory: "creative"}}}, "results") + if err != nil { + t.Fatal(err) + } + if imported.Observations[0].ApprovedSnapshotID != snapshot.ID || imported.Observations[0].ScriptVersionID != "" { + t.Fatalf("performance observation used the legacy version source: %#v", imported.Observations[0]) + } + if _, err := service.CreateRatingDecision(ctx, actor, app.CreateRatingDecisionInput{ProjectID: binding.ProjectID, SubjectType: "approved_snapshot", SubjectID: snapshot.ID, ObservationIDs: []string{imported.Observations[0].ID}, Rating: "seed_candidate", Reason: "validated outcome", NextAction: "create one controlled variant"}, "rating"); err != nil { + t.Fatal(err) + } + graph, err := service.ProjectLineage(ctx, actor, binding.ProjectID, app.LineageQuery{FocusType: "approved_snapshot", FocusID: snapshot.ID, Direction: "downstream"}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"delivery_package:" + delivery.ID, "performance_observation:" + imported.Observations[0].ID} { + if !lineageHasNode(graph, key) { + t.Fatalf("lineage is missing %s: %#v", key, graph) + } + } +} + +func TestV2NewRevisionRevokesPendingClientGrant(t *testing.T) { + ctx, service, store, actor, binding := v2ScriptFixture(t) + first := publishV2Script(t, ctx, service, binding, "script-first", "publish-first") + if _, err := service.ApproveSubmission(ctx, actor, first.ID, "internal approved", "approve-first"); err != nil { + t.Fatal(err) + } + grant, err := service.CreateReviewGrant(ctx, actor, first.ID, "client@example.com", "grant-first") + if err != nil { + t.Fatal(err) + } + second := publishV2Script(t, ctx, service, binding, "script-second", "publish-second") + stored, err := store.ReviewGrant(ctx, actor.TenantID, grant.ID) + if err != nil || stored.RevokedAt == nil { + t.Fatalf("new revision did not revoke the previous grant: %#v err=%v", stored, err) + } + if _, err := service.ReviewProjection(ctx, grant.PlaintextToken); err == nil { + t.Fatal("revoked grant remained usable") + } + if _, err := service.CreateReviewGrant(ctx, actor, second.ID, "client@example.com", "grant-second-too-early"); err == nil { + t.Fatal("new revision inherited internal approval from the previous revision") + } +} + +func v2ScriptFixture(t *testing.T) (context.Context, *app.Service, *memory.Store, app.Actor, domain.WorkspaceBinding) { + t.Helper() + ctx := context.Background() + store := memory.New() + service := app.New(store, slog.Default()) + session, err := service.Register(ctx, "v2-full-chain@example.com", "long-enough-password", "Owner", "Agency") + if err != nil { + t.Fatal(err) + } + actor, _, err := service.SessionActor(ctx, session.ID) + if err != nil { + t.Fatal(err) + } + project, err := service.CreateProject(ctx, actor, app.CreateProjectInput{BrandName: "Brand", ProductName: "Product"}, "project") + if err != nil { + t.Fatal(err) + } + connect, err := service.CreateConnectSession(ctx, actor, project.ID, "connect") + if err != nil { + t.Fatal(err) + } + connected, err := service.ConnectDevice(ctx, app.ConnectDeviceInput{ConnectKey: connect.PlaintextConnectKey, Hostname: "local"}) + if err != nil { + t.Fatal(err) + } + workspaceActor, binding, err := service.WorkspaceActor(ctx, connected.WorkspaceToken) + if err != nil { + t.Fatal(err) + } + binding, err = service.RegisterWorkspace(ctx, workspaceActor, binding, "workspace_marketing_video", "2.0.0", []string{"codex"}, "register") + if err != nil { + t.Fatal(err) + } + return ctx, service, store, actor, binding +} + +func publishV2Script(t *testing.T, ctx context.Context, service *app.Service, binding domain.WorkspaceBinding, scriptID, idempotencyKey string) domain.SubmissionRevision { + t.Helper() + workspaceActor := app.Actor{TenantID: binding.TenantID, WorkspaceID: binding.ID, Type: "workspace", Role: "workspace"} + pkg := localworkspace.ScriptPackageV2{ID: scriptID, Kind: "script_package", Status: "review_ready", SchemaVersion: "2.0", Deliverability: "review_ready", ProjectID: binding.ProjectID, ScriptID: scriptID, Title: "Approved script", Channel: "douyin", DurationMS: 15000, AspectRatio: "9:16", Shots: []localworkspace.ScriptShotV2{}, Citations: []localworkspace.ScriptCitationV2{}, AssetRequirements: []localworkspace.ScriptAssetRequirement{}, BlockedReasons: []localworkspace.ScriptBlockedReason{}, MissingInputs: []string{}} + body, err := json.Marshal([]localworkspace.ScriptPackageV2{pkg}) + if err != nil { + t.Fatal(err) + } + bundle := domain.SubmissionBundle{BundleVersion: "1.0", SchemaVersion: localworkspace.ScriptPackageV2Schema, SubmissionType: "script", ProjectID: binding.ProjectID, WorkspaceID: binding.ID, Objects: body, SourceDisclosures: []domain.SourceDisclosure{}, Artifacts: []domain.SubmissionArtifact{}, LocalRunSummary: domain.LocalRunSummary{Checks: []domain.LocalRunCheck{{Name: "script-lint", Status: "passed"}}}, IdempotencyKey: idempotencyKey} + if err := bundle.SetComputedHash(); err != nil { + t.Fatal(err) + } + revision, err := service.CreateSubmission(ctx, workspaceActor, binding, bundle, idempotencyKey) + if err != nil { + t.Fatal(err) + } + return revision +} + +func lineageHasNode(graph domain.LineageGraph, key string) bool { + for _, node := range graph.Nodes { + if node.Key == key { + return true + } + } + return false +} diff --git a/internal/cli/artifacts.go b/internal/cli/artifacts.go index 7d5fb4d..e7218ca 100644 --- a/internal/cli/artifacts.go +++ b/internal/cli/artifacts.go @@ -50,19 +50,59 @@ func (r *Root) artifactCommand() *cobra.Command { return r.writeOK("artifact.presentation", result) }} - var format string - export := &cobra.Command{Use: "export ", Args: cobra.ExactArgs(1), Short: "Create an approved script export", RunE: func(cmd *cobra.Command, args []string) error { + var format, exportScriptID string + export := &cobra.Command{Use: "export ", Args: cobra.ExactArgs(1), Short: "Create an export from a client-approved snapshot", RunE: func(cmd *cobra.Command, args []string) error { _, client, _, err := r.userClient() if err != nil { return err } var result domain.Artifact - if err := client.Dispatch(cmd.Context(), "artifact.export", map[string]any{"script_id": args[0], "format": format}, &result); err != nil { + if err := client.Dispatch(cmd.Context(), "artifact.export", map[string]any{"snapshot_id": args[0], "script_id": exportScriptID, "format": format}, &result); err != nil { return err } return r.writeOK("artifact.export", result) }} export.Flags().StringVar(&format, "format", "json", "markdown, xlsx, or json") + export.Flags().StringVar(&exportScriptID, "script", "", "script ID when the snapshot contains multiple scripts") + + var deliveryScriptID string + delivery := &cobra.Command{Use: "package ", Args: cobra.ExactArgs(1), Short: "Create a three-format DeliveryPackage", RunE: func(cmd *cobra.Command, args []string) error { + _, client, _, err := r.userClient() + if err != nil { + return err + } + var result domain.DeliveryPackage + if err := client.Dispatch(cmd.Context(), "delivery.create", map[string]any{"snapshot_id": args[0], "script_id": deliveryScriptID}, &result); err != nil { + return err + } + return r.writeOK("delivery.create", result) + }} + delivery.Flags().StringVar(&deliveryScriptID, "script", "", "script ID when the snapshot contains multiple scripts") + var deliveryProjectID string + deliveryList := &cobra.Command{Use: "packages", Short: "List immutable DeliveryPackages", RunE: func(cmd *cobra.Command, args []string) error { + _, client, _, err := r.userClient() + if err != nil { + return err + } + var result []domain.DeliveryPackage + if err := client.Dispatch(cmd.Context(), "delivery.list", map[string]any{"project_id": deliveryProjectID}, &result); err != nil { + return err + } + return r.writeOK("delivery.list", result) + }} + deliveryList.Flags().StringVar(&deliveryProjectID, "project", "", "project ID") + _ = deliveryList.MarkFlagRequired("project") + deliveryShow := &cobra.Command{Use: "package-show ", Args: cobra.ExactArgs(1), Short: "Show a DeliveryPackage manifest", RunE: func(cmd *cobra.Command, args []string) error { + _, client, _, err := r.userClient() + if err != nil { + return err + } + var result domain.DeliveryPackage + if err := client.Dispatch(cmd.Context(), "delivery.show", map[string]any{"id": args[0]}, &result); err != nil { + return err + } + return r.writeOK("delivery.show", result) + }} var out string download := &cobra.Command{Use: "download ", Args: cobra.ExactArgs(1), Short: "Download a server-hosted artifact to an explicit path", RunE: func(cmd *cobra.Command, args []string) error { @@ -105,7 +145,7 @@ func (r *Root) artifactCommand() *cobra.Command { } return r.writeOK("artifact.open.status", result) }} - cmd.AddCommand(list, presentation, export, download, register, open, openStatus) + cmd.AddCommand(list, presentation, export, delivery, deliveryList, deliveryShow, download, register, open, openStatus) return cmd } diff --git a/internal/cli/business_commands.go b/internal/cli/business_commands.go index f04418e..eb7002b 100644 --- a/internal/cli/business_commands.go +++ b/internal/cli/business_commands.go @@ -1141,16 +1141,16 @@ func (r *Root) reviewCommand() *cobra.Command { cmd := &cobra.Command{Use: "review", Short: "Create customer-bound review grants"} var email string var createDryRun bool - create := &cobra.Command{Use: "create ", Args: cobra.ExactArgs(1), Short: "Create a seven-day customer approval link", RunE: func(cmd *cobra.Command, args []string) error { + create := &cobra.Command{Use: "create ", Args: cobra.ExactArgs(1), Short: "Create a seven-day customer approval link", RunE: func(cmd *cobra.Command, args []string) error { if createDryRun { - return r.writeOK("review.create", map[string]any{"dry_run": true, "script_id": args[0], "reviewer_email": email}) + return r.writeOK("review.create", map[string]any{"dry_run": true, "revision_id": args[0], "reviewer_email": email}) } _, client, _, err := r.userClient() if err != nil { return err } var result domain.ReviewGrant - if err := client.Dispatch(cmd.Context(), "review.create", map[string]any{"script_id": args[0], "reviewer_email": email}, &result); err != nil { + if err := client.Dispatch(cmd.Context(), "review.create", map[string]any{"revision_id": args[0], "reviewer_email": email}, &result); err != nil { return err } return r.writeOK("review.create", result) @@ -1158,13 +1158,13 @@ func (r *Root) reviewCommand() *cobra.Command { create.Flags().StringVar(&email, "email", "", "bound customer approver email") create.Flags().BoolVar(&createDryRun, "dry-run", false, "validate without creating a customer approval link") _ = create.MarkFlagRequired("email") - list := &cobra.Command{Use: "list ", Args: cobra.ExactArgs(1), Short: "List customer approval grants without secret hashes", RunE: func(cmd *cobra.Command, args []string) error { + list := &cobra.Command{Use: "list ", Args: cobra.ExactArgs(1), Short: "List customer approval grants without secret hashes", RunE: func(cmd *cobra.Command, args []string) error { _, client, _, err := r.userClient() if err != nil { return err } var result []domain.ReviewGrant - if err := client.Dispatch(cmd.Context(), "review.list", map[string]any{"script_id": args[0]}, &result); err != nil { + if err := client.Dispatch(cmd.Context(), "review.list", map[string]any{"revision_id": args[0]}, &result); err != nil { return err } return r.writeOK("review.list", result) @@ -1189,16 +1189,16 @@ func (r *Root) reviewCommand() *cobra.Command { }} revoke.Flags().BoolVar(&revokeYes, "yes", false, "confirm this high-risk write") revoke.Flags().BoolVar(&revokeDryRun, "dry-run", false, "validate without revoking the grant") - status := &cobra.Command{Use: "status ", Args: cobra.ExactArgs(1), Short: "Show the customer review state for a ScriptVersion", RunE: func(cmd *cobra.Command, args []string) error { + status := &cobra.Command{Use: "status ", Args: cobra.ExactArgs(1), Short: "Show the customer review state for a SubmissionRevision", RunE: func(cmd *cobra.Command, args []string) error { _, client, _, err := r.userClient() if err != nil { return err } - var result domain.ScriptVersion - if err := client.Dispatch(cmd.Context(), "script.show", map[string]any{"id": args[0]}, &result); err != nil { + var result app.SubmissionReviewStatus + if err := client.Dispatch(cmd.Context(), "review.status", map[string]any{"revision_id": args[0]}, &result); err != nil { return err } - return r.writeOK("review.status", map[string]any{"script_version_id": result.ID, "content_hash": result.ContentHash, "status": result.Status}) + return r.writeOK("review.status", result) }} cmd.AddCommand(create, list, revoke, status) return cmd @@ -1267,7 +1267,7 @@ func (r *Root) resultCommand() *cobra.Command { var observationIDs []string var rating, reason, nextAction string var ratingDryRun bool - rate := &cobra.Command{Use: "rate ", Args: cobra.ExactArgs(2), Short: "Create an immutable human rating decision", RunE: func(cmd *cobra.Command, args []string) error { + rate := &cobra.Command{Use: "rate ", Args: cobra.ExactArgs(2), Short: "Create an immutable human rating decision", RunE: func(cmd *cobra.Command, args []string) error { cfg, client, _, err := r.userClient() if err != nil { return err diff --git a/internal/cli/result_import.go b/internal/cli/result_import.go index 0a45b2b..efc32b7 100644 --- a/internal/cli/result_import.go +++ b/internal/cli/result_import.go @@ -77,12 +77,19 @@ func observationsFromRows(rows [][]string) ([]app.CreateObservationInput, error) for i, header := range rows[0] { headers[i] = strings.ToLower(strings.TrimSpace(header)) } - required := map[string]bool{"script_version_id": false, "platform": false, "account_alias": false, "published_at": false, "window_hours": false, "sample_status": false} + required := map[string]bool{"platform": false, "account_alias": false, "published_at": false, "window_hours": false, "sample_status": false} + hasVersionColumn := false for _, header := range headers { + if header == "approved_snapshot_id" || header == "script_version_id" { + hasVersionColumn = true + } if _, ok := required[header]; ok { required[header] = true } } + if !hasVersionColumn { + return nil, domain.Invalid("RESULT_COLUMN_REQUIRED", "结果表缺少列: approved_snapshot_id") + } for name, present := range required { if !present { return nil, domain.Invalid("RESULT_COLUMN_REQUIRED", "结果表缺少列: "+name) @@ -111,6 +118,8 @@ func observationsFromRows(rows [][]string) ([]app.CreateObservationInput, error) input.ProjectID = value case "script_version_id": input.ScriptVersionID = value + case "approved_snapshot_id": + input.ApprovedSnapshotID = value case "platform": input.Platform = value case "account_alias": diff --git a/internal/cli/root.go b/internal/cli/root.go index 6fc17b2..7421cef 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ import ( builtinskills "github.com/limecloud/contentcloud/skills" ) -const Version = "0.3.0" +const Version = "0.4.0" type Root struct { json bool @@ -761,8 +761,8 @@ func commandSchemas() map[string]any { "brief.list": userRead([]string{"--project"}, "brief list"), "brief.show": userRead([]string{"brief-id"}, "brief version"), "brief.create": write("user", []string{"--project", "--file", "--dry-run"}, "new immutable brief version"), "brief.revise": write("user", []string{"brief-id", "--project", "--file", "--reason", "--dry-run"}, "replacement immutable brief version"), "brief.submit": write("user", []string{"brief-id", "--dry-run"}, "brief in internal review"), "brief.return": write("user", []string{"brief-id", "--reason", "--dry-run"}, "brief returned for revision"), "brief.approve": write("user", []string{"brief-id", "--dry-run"}, "approved brief"), "run.create": write("user", []string{"brief-id", "--idempotency-key"}, "task run"), "run.list": userRead([]string{"--project"}, "run list"), "run.show": userRead([]string{"run-id"}, "task run"), "run.attempts": userRead([]string{"run-id"}, "immutable execution attempt list"), "run.log": userRead([]string{"run-id"}, "sanitized persisted progress"), "run.cancel": high([]string{"run-id"}, "canceled task run"), "script.list": userRead([]string{"--project"}, "script list"), "script.show": userRead([]string{"script-id"}, "canonical Script Package"), "script.revise": write("user", []string{"baseline-version-id", "--reason", "--brief", "--invariant", "--changed-field", "--idempotency-key", "--dry-run"}, "script revision run"), "script.variant": write("user", []string{"baseline-version-id", "--reason", "--hypothesis", "--changed-field", "--invariant", "--brief", "--idempotency-key", "--dry-run"}, "single-variable variant run"), "script.review": write("user", []string{"script-id", "decision", "--conclusion", "--assignee", "--dry-run"}, "internal review transition"), "review_cycle.list": userRead([]string{"script-id"}, "immutable review cycles"), - "artifact.list": userRead([]string{"--script"}, "server-computed artifact presentations"), "artifact.presentation": userRead([]string{"artifact-id"}, "presentation tier and allowed actions"), "artifact.export": write("user", []string{"script-id", "--format"}, "versioned artifact"), "artifact.download": userRead([]string{"artifact-id", "--out"}, "local artifact path"), "artifact.register": write("device", []string{"file", "--script", "--schema", "--media-type", "--metadata", "--dry-run"}, "local-only extension artifact envelope"), "artifact.open": write("user", []string{"artifact-id", "--device", "--dry-run"}, "60-second declarative open request"), "artifact.open.status": userRead([]string{"open-request-id"}, "local-open request state"), - "review.create": write("user", []string{"script-id", "--email", "--dry-run"}, "one-time customer review link"), "review.list": userRead([]string{"script-id"}, "customer review grants"), "review.revoke": high([]string{"grant-id", "--dry-run"}, "revoked customer review grant"), "review.status": userRead([]string{"script-id"}, "customer review state"), + "artifact.list": userRead([]string{"--script"}, "server-computed artifact presentations"), "artifact.presentation": userRead([]string{"artifact-id"}, "presentation tier and allowed actions"), "artifact.export": write("user", []string{"approved-snapshot-id", "--script", "--format"}, "snapshot-derived artifact"), "delivery.create": write("user", []string{"approved-snapshot-id", "--script"}, "three-format delivery package"), "delivery.list": userRead([]string{"--project"}, "delivery package list"), "delivery.show": userRead([]string{"delivery-package-id"}, "delivery package"), "artifact.download": userRead([]string{"artifact-id", "--out"}, "local artifact path"), "artifact.register": write("device", []string{"file", "--script", "--schema", "--media-type", "--metadata", "--dry-run"}, "local-only extension artifact envelope"), "artifact.open": write("user", []string{"artifact-id", "--device", "--dry-run"}, "60-second declarative open request"), "artifact.open.status": userRead([]string{"open-request-id"}, "local-open request state"), + "review.create": write("user", []string{"submission-revision-id", "--email", "--dry-run"}, "one-time customer review link"), "review.list": userRead([]string{"submission-revision-id"}, "customer review grants"), "review.revoke": high([]string{"grant-id", "--dry-run"}, "revoked customer review grant"), "review.status": userRead([]string{"submission-revision-id"}, "customer review state"), "result.list": userRead([]string{"--project"}, "observation list"), "result.import": write("user", []string{"json-or-csv-or-xlsx-file", "--project", "--dry-run"}, "atomic performance import batch"), "result.batches": userRead([]string{"--project"}, "immutable import batch list"), "result.batch-show": userRead([]string{"batch-id"}, "import batch and observations"), "result.rate": write("user", []string{"subject-type", "subject-id", "--project", "--observation", "--rating", "--reason", "--next-action", "--dry-run"}, "manual rating decision"), "result.ratings": userRead([]string{"--project"}, "manual rating decision list"), "lineage.show": userRead([]string{"--project", "--type", "--id", "--direction"}, "bidirectional project lineage graph"), "lineage.impact": userRead([]string{"--project", "--type", "--id"}, "affected objects with reasons and actions"), "audit.list": userRead([]string{"--project", "--limit"}, "immutable audit event list"), "daemon.run": write("device", []string{"--once", "--fixture", "--adapter"}, "leased run result"), "skills.list": read(nil, "embedded skills"), "skills.read": read([]string{"name", "--path"}, "skill content"), "skills.status": read(nil, "skill version state"), "skills.install": write("none", []string{"name", "--target"}, "local install path"), "schema": read([]string{"command"}, "CLI contract"), "request.get": userRead([]string{"projects|tenants|runs"}, "allowlisted resource"), diff --git a/internal/cli/v2_commands.go b/internal/cli/v2_commands.go index 17f1393..1a1ff4d 100644 --- a/internal/cli/v2_commands.go +++ b/internal/cli/v2_commands.go @@ -250,25 +250,25 @@ func (r *Root) submissionCommand() *cobra.Command { }} var reason string var yes, dryRun bool - approve := &cobra.Command{Use: "approve ", Args: cobra.ExactArgs(1), Short: "Approve a current SubmissionRevision and create an ApprovedSnapshot", RunE: func(cmd *cobra.Command, args []string) error { + approve := &cobra.Command{Use: "approve ", Args: cobra.ExactArgs(1), Short: "Record internal approval for a current SubmissionRevision", RunE: func(cmd *cobra.Command, args []string) error { if strings.TrimSpace(reason) == "" { return domain.Invalid("APPROVAL_REASON_REQUIRED", "--reason 必填") } if dryRun { - return r.writeOK("submission.approve", map[string]any{"dry_run": true, "revision_id": args[0], "reason": reason, "would_create_approved_snapshot": true}) + return r.writeOK("submission.approve", map[string]any{"dry_run": true, "revision_id": args[0], "reason": reason, "script_requires_client_approval": true}) } if !yes { - return confirmationRequired("批准会锁定当前 revision hash 并创建不可变 ApprovedSnapshot") + return confirmationRequired("批准会锁定当前 revision hash;script 还需客户 OTP 批准后才创建 ApprovedSnapshot") } _, client, _, err := r.userClient() if err != nil { return err } - var snapshot domain.ApprovedSnapshot - if err := client.Dispatch(cmd.Context(), "submission.approve", map[string]any{"revision_id": args[0], "reason": reason}, &snapshot); err != nil { + var result app.SubmissionApprovalResult + if err := client.Dispatch(cmd.Context(), "submission.approve", map[string]any{"revision_id": args[0], "reason": reason}, &result); err != nil { return err } - return r.writeOK("submission.approve", snapshot) + return r.writeOK("submission.approve", result) }} approve.Flags().StringVar(&reason, "reason", "", "human approval conclusion") approve.Flags().BoolVar(&yes, "yes", false, "confirm immutable approval") diff --git a/internal/domain/content.go b/internal/domain/content.go index e91759c..b1c6865 100644 --- a/internal/domain/content.go +++ b/internal/domain/content.go @@ -218,26 +218,27 @@ type VisualizationPlan struct { } type PerformanceObservation struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` - ImportBatchID string `json:"import_batch_id"` - RowNumber int `json:"row_number"` - ScriptVersionID string `json:"script_version_id"` - Platform string `json:"platform"` - AccountAlias string `json:"account_alias"` - PublishedAt time.Time `json:"published_at"` - WindowHours int `json:"window_hours"` - SampleStatus string `json:"sample_status"` - Metrics map[string]float64 `json:"metrics"` - Currency string `json:"currency,omitempty"` - Spend float64 `json:"spend"` - GMV float64 `json:"gmv"` - ROI *float64 `json:"roi,omitempty"` - DedupKey string `json:"dedup_key"` - IssueCategory string `json:"issue_category"` - Notes string `json:"notes"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + ImportBatchID string `json:"import_batch_id"` + RowNumber int `json:"row_number"` + ApprovedSnapshotID string `json:"approved_snapshot_id,omitempty"` + ScriptVersionID string `json:"script_version_id,omitempty"` + Platform string `json:"platform"` + AccountAlias string `json:"account_alias"` + PublishedAt time.Time `json:"published_at"` + WindowHours int `json:"window_hours"` + SampleStatus string `json:"sample_status"` + Metrics map[string]float64 `json:"metrics"` + Currency string `json:"currency,omitempty"` + Spend float64 `json:"spend"` + GMV float64 `json:"gmv"` + ROI *float64 `json:"roi,omitempty"` + DedupKey string `json:"dedup_key"` + IssueCategory string `json:"issue_category"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` } type PerformanceImportRowError struct { @@ -319,7 +320,8 @@ type Artifact struct { ID string `json:"id"` TenantID string `json:"tenant_id"` ProjectID string `json:"project_id"` - ScriptVersionID string `json:"script_version_id"` + ApprovedSnapshotID string `json:"approved_snapshot_id,omitempty"` + ScriptVersionID string `json:"script_version_id,omitempty"` Kind string `json:"kind"` CapabilityID string `json:"capability_id"` CapabilityVersion string `json:"capability_version"` @@ -343,6 +345,18 @@ type Artifact struct { CreatedAt time.Time `json:"created_at"` } +type DeliveryPackage struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + ApprovedSnapshotIDs []string `json:"approved_snapshot_ids"` + ScriptID string `json:"script_id"` + Status string `json:"status"` + Manifest []Artifact `json:"manifest"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + type ArtifactCapabilityRef struct { ID string `json:"id"` Version string `json:"version"` diff --git a/internal/domain/model.go b/internal/domain/model.go index 3a18bf6..b254f59 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -526,6 +526,7 @@ type ApprovalDecision struct { SubjectType string `json:"subject_type"` SubjectID string `json:"subject_id"` SubjectHash string `json:"subject_hash"` + DecisionStage string `json:"decision_stage"` ActorID string `json:"actor_id"` Decision string `json:"decision"` Reason string `json:"reason"` diff --git a/internal/domain/submission.go b/internal/domain/submission.go index 90fe154..16225db 100644 --- a/internal/domain/submission.go +++ b/internal/domain/submission.go @@ -128,6 +128,8 @@ type ApprovedSnapshot struct { DecisionID string `json:"decision_id"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` + Origin string `json:"origin"` + ExternalRef string `json:"external_ref,omitempty"` } type ReviewFeedbackBundle struct { diff --git a/internal/httpapi/bootstrap.md b/internal/httpapi/bootstrap.md index 623a49b..7669f95 100644 --- a/internal/httpapi/bootstrap.md +++ b/internal/httpapi/bootstrap.md @@ -1,6 +1,8 @@ # Initialize a ContentCloud Project -You are setting up a local-first ContentCloud workspace for the user. Complete the setup from the current Agent session and report the verified result. Do not merely print commands for the user to run. +You are reading this because the user pasted a short ContentCloud initialization Prompt into a local Codex or Claude session. This document is served over HTTP before the project-level ContentCloud Skill is installed. + +Complete the setup on the user's machine from the current Agent session and report the verified result. Do not merely print commands for the user to run, and do not ask the cloud control plane to execute local work. ## Request values diff --git a/internal/httpapi/cli_dispatch.go b/internal/httpapi/cli_dispatch.go index 0cdedf6..2e0d590 100644 --- a/internal/httpapi/cli_dispatch.go +++ b/internal/httpapi/cli_dispatch.go @@ -581,13 +581,42 @@ func (s *Server) handleUserDispatch(w http.ResponseWriter, r *http.Request, req s.dispatchResult(w, r, req.Command, v, err) case "artifact.export": var in struct { - ScriptID string `json:"script_id"` - Format string `json:"format"` + SnapshotID string `json:"snapshot_id"` + ScriptID string `json:"script_id"` + Format string `json:"format"` + } + if !decodeParams(w, r, s, req, &in) { + return true + } + v, err := s.service.ExportApprovedSnapshot(r.Context(), actor, in.SnapshotID, in.ScriptID, in.Format, requestID) + s.dispatchResult(w, r, req.Command, v, err) + case "delivery.create": + var in struct { + SnapshotID string `json:"snapshot_id"` + ScriptID string `json:"script_id"` + } + if !decodeParams(w, r, s, req, &in) { + return true + } + v, err := s.service.CreateDeliveryPackage(r.Context(), actor, in.SnapshotID, in.ScriptID, requestID) + s.dispatchResult(w, r, req.Command, v, err) + case "delivery.list": + var in struct { + ProjectID string `json:"project_id"` + } + if !decodeParams(w, r, s, req, &in) { + return true + } + v, err := s.service.DeliveryPackages(r.Context(), actor, in.ProjectID) + s.dispatchResult(w, r, req.Command, v, err) + case "delivery.show": + var in struct { + ID string `json:"id"` } if !decodeParams(w, r, s, req, &in) { return true } - v, err := s.service.ExportScript(r.Context(), actor, in.ScriptID, in.Format, requestID) + v, err := s.service.DeliveryPackage(r.Context(), actor, in.ID) s.dispatchResult(w, r, req.Command, v, err) case "artifact.download": var in struct { @@ -604,22 +633,31 @@ func (s *Server) handleUserDispatch(w http.ResponseWriter, r *http.Request, req s.ok(w, r, req.Command, map[string]any{"artifact": artifact, "content_base64": base64.StdEncoding.EncodeToString(data)}) case "review.create": var in struct { - ScriptID string `json:"script_id"` + RevisionID string `json:"revision_id"` ReviewerEmail string `json:"reviewer_email"` } if !decodeParams(w, r, s, req, &in) { return true } - v, err := s.service.CreateReviewGrant(r.Context(), actor, in.ScriptID, in.ReviewerEmail, requestID) + v, err := s.service.CreateReviewGrant(r.Context(), actor, in.RevisionID, in.ReviewerEmail, requestID) s.dispatchResult(w, r, req.Command, v, err) case "review.list": var in struct { - ScriptID string `json:"script_id"` + RevisionID string `json:"revision_id"` + } + if !decodeParams(w, r, s, req, &in) { + return true + } + v, err := s.service.ReviewGrants(r.Context(), actor, in.RevisionID) + s.dispatchResult(w, r, req.Command, v, err) + case "review.status": + var in struct { + RevisionID string `json:"revision_id"` } if !decodeParams(w, r, s, req, &in) { return true } - v, err := s.service.ReviewGrants(r.Context(), actor, in.ScriptID) + v, err := s.service.SubmissionReviewStatus(r.Context(), actor, in.RevisionID) s.dispatchResult(w, r, req.Command, v, err) case "review.revoke": var in struct { diff --git a/internal/httpapi/content_handlers.go b/internal/httpapi/content_handlers.go index 09ab1df..436114a 100644 --- a/internal/httpapi/content_handlers.go +++ b/internal/httpapi/content_handlers.go @@ -484,6 +484,16 @@ func (s *Server) reviewGrants(w http.ResponseWriter, r *http.Request) { s.ok(w, r, "review_grant.list", v) } +func (s *Server) legacyReviewGrants(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + v, err := s.service.LegacyReviewGrants(r.Context(), actor, chi.URLParam(r, "id")) + if err != nil { + s.fail(w, r, "review_grant.list", err) + return + } + s.ok(w, r, "review_grant.list", v) +} + func (s *Server) revokeReviewGrant(w http.ResponseWriter, r *http.Request) { actor, _ := auth(r) v, err := s.service.RevokeReviewGrant(r.Context(), actor, chi.URLParam(r, "id"), middleware.GetReqID(r.Context())) @@ -557,6 +567,69 @@ func (s *Server) exportScript(w http.ResponseWriter, r *http.Request) { s.ok(w, r, "artifact.export", v) } +func (s *Server) exportApprovedSnapshot(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + var in struct { + ScriptID string `json:"script_id"` + Format string `json:"format"` + } + if !s.decode(w, r, &in) { + return + } + value, err := s.service.ExportApprovedSnapshot(r.Context(), actor, chi.URLParam(r, "id"), in.ScriptID, in.Format, middleware.GetReqID(r.Context())) + if err != nil { + s.fail(w, r, "artifact.export", err) + return + } + s.ok(w, r, "artifact.export", value) +} + +func (s *Server) approvedSnapshotArtifacts(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + value, err := s.service.ApprovedSnapshotArtifacts(r.Context(), actor, chi.URLParam(r, "id")) + if err != nil { + s.fail(w, r, "artifact.list", err) + return + } + s.ok(w, r, "artifact.list", value) +} + +func (s *Server) createDeliveryPackage(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + var in struct { + ScriptID string `json:"script_id"` + } + if !s.decode(w, r, &in) { + return + } + value, err := s.service.CreateDeliveryPackage(r.Context(), actor, chi.URLParam(r, "id"), in.ScriptID, middleware.GetReqID(r.Context())) + if err != nil { + s.fail(w, r, "delivery.create", err) + return + } + s.ok(w, r, "delivery.create", value) +} + +func (s *Server) deliveryPackages(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + value, err := s.service.DeliveryPackages(r.Context(), actor, chi.URLParam(r, "projectID")) + if err != nil { + s.fail(w, r, "delivery.list", err) + return + } + s.ok(w, r, "delivery.list", value) +} + +func (s *Server) deliveryPackage(w http.ResponseWriter, r *http.Request) { + actor, _ := auth(r) + value, err := s.service.DeliveryPackage(r.Context(), actor, chi.URLParam(r, "id")) + if err != nil { + s.fail(w, r, "delivery.show", err) + return + } + s.ok(w, r, "delivery.show", value) +} + func (s *Server) downloadArtifact(w http.ResponseWriter, r *http.Request) { actor, _ := auth(r) artifact, data, err := s.service.ArtifactBytes(r.Context(), actor, chi.URLParam(r, "id")) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8044859..972f1b0 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -147,8 +147,9 @@ func (s *Server) Handler() http.Handler { r.Post("/scripts/{id}/comments", s.createReviewComment) r.Get("/scripts/{id}/review-cycles", s.reviewCycles) r.Post("/comments/{id}/resolve", s.resolveReviewComment) - r.Get("/scripts/{id}/review-grants", s.reviewGrants) - r.Post("/scripts/{id}/review-grants", s.createReviewGrant) + r.Get("/scripts/{id}/review-grants", s.legacyReviewGrants) + r.Get("/submission-revisions/{id}/review-grants", s.reviewGrants) + r.Post("/submission-revisions/{id}/review-grants", s.createReviewGrant) r.Post("/review-grants/{id}/revoke", s.revokeReviewGrant) r.Get("/scripts/{id}/artifacts", s.artifacts) r.Post("/scripts/{id}/exports", s.exportScript) @@ -171,6 +172,11 @@ func (s *Server) Handler() http.Handler { r.Post("/submission-revisions/{id}/approve", s.approveSubmission) r.Post("/submission-revisions/{id}/request-changes", s.requestSubmissionChanges) r.Get("/projects/{projectID}/approved-snapshots", s.approvedSnapshots) + r.Get("/approved-snapshots/{id}/artifacts", s.approvedSnapshotArtifacts) + r.Post("/approved-snapshots/{id}/exports", s.exportApprovedSnapshot) + r.Post("/approved-snapshots/{id}/delivery-packages", s.createDeliveryPackage) + r.Get("/projects/{projectID}/delivery-packages", s.deliveryPackages) + r.Get("/delivery-packages/{id}", s.deliveryPackage) }) r.Get("/*", s.static) return r diff --git a/internal/httpapi/submission_handlers_test.go b/internal/httpapi/submission_handlers_test.go index 9503d98..675b7e2 100644 --- a/internal/httpapi/submission_handlers_test.go +++ b/internal/httpapi/submission_handlers_test.go @@ -8,10 +8,12 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/limecloud/contentcloud/internal/app" "github.com/limecloud/contentcloud/internal/domain" "github.com/limecloud/contentcloud/internal/httpapi" + "github.com/limecloud/contentcloud/internal/localworkspace" "github.com/limecloud/contentcloud/internal/store/memory" ) @@ -62,3 +64,101 @@ func TestSubmissionBFFReviewDoesNotEditRevisionContent(t *testing.T) { t.Fatalf("BFF review mutated content or lost comment: %#v %v", persisted, err) } } + +func TestV2SubmissionBFFCompletesClientDeliveryAndResultChain(t *testing.T) { + ctx := t.Context() + service := app.New(memory.New(), slog.Default()) + session, err := service.Register(ctx, "v2-bff@example.com", "long-enough-password", "Owner", "Agency") + if err != nil { + t.Fatal(err) + } + actor, _, err := service.SessionActor(ctx, session.ID) + if err != nil { + t.Fatal(err) + } + project, err := service.CreateProject(ctx, actor, app.CreateProjectInput{BrandName: "Brand", ProductName: "Product", Channel: "douyin"}, "project") + if err != nil { + t.Fatal(err) + } + connect, err := service.CreateConnectSession(ctx, actor, project.ID, "connect") + if err != nil { + t.Fatal(err) + } + connected, err := service.ConnectDevice(ctx, app.ConnectDeviceInput{ConnectKey: connect.PlaintextConnectKey, Hostname: "local"}) + if err != nil { + t.Fatal(err) + } + workspaceActor, binding, err := service.WorkspaceActor(ctx, connected.WorkspaceToken) + if err != nil { + t.Fatal(err) + } + pkg := localworkspace.ScriptPackageV2{ID: "script-bff", Kind: "script_package", Status: "review_ready", SchemaVersion: "2.0", Deliverability: "review_ready", ProjectID: project.ID, ScriptID: "script-bff", Title: "BFF approved script", Channel: "douyin", DurationMS: 15000, AspectRatio: "9:16", Shots: []localworkspace.ScriptShotV2{}, Citations: []localworkspace.ScriptCitationV2{}, AssetRequirements: []localworkspace.ScriptAssetRequirement{}, BlockedReasons: []localworkspace.ScriptBlockedReason{}, MissingInputs: []string{}} + objects, err := json.Marshal([]localworkspace.ScriptPackageV2{pkg}) + if err != nil { + t.Fatal(err) + } + bundle := domain.SubmissionBundle{BundleVersion: "1.0", SchemaVersion: localworkspace.ScriptPackageV2Schema, SubmissionType: "script", ProjectID: project.ID, WorkspaceID: binding.ID, Objects: objects, SourceDisclosures: []domain.SourceDisclosure{}, Artifacts: []domain.SubmissionArtifact{}, LocalRunSummary: domain.LocalRunSummary{Checks: []domain.LocalRunCheck{{Name: "script-lint", Status: "passed"}}}, IdempotencyKey: "bff-script"} + if err := bundle.SetComputedHash(); err != nil { + t.Fatal(err) + } + revision, err := service.CreateSubmission(ctx, workspaceActor, binding, bundle, "publish") + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(httpapi.New(service, slog.Default(), false, "").Handler()) + defer server.Close() + jar, _ := cookiejar.New(nil) + baseURL, _ := url.Parse(server.URL) + jar.SetCookies(baseURL, []*http.Cookie{{Name: "cc_session", Value: session.ID, Path: "/"}}) + client := &http.Client{Jar: jar} + + internal := callBFF[app.SubmissionApprovalResult](t, client, http.MethodPost, server.URL+"/api/bff/submission-revisions/"+revision.ID+"/approve", map[string]string{"reason": "internal approved"}) + if internal.ApprovedSnapshot != nil || internal.Submission.Status != "internally_approved" || internal.Decision.DecisionStage != "internal" { + t.Fatalf("internal approval did not stop before client approval: %#v", internal) + } + grant := callBFF[domain.ReviewGrant](t, client, http.MethodPost, server.URL+"/api/bff/submission-revisions/"+revision.ID+"/review-grants", map[string]string{"reviewer_email": "client@example.com"}) + if grant.SubjectType != "submission_revision" || grant.SubjectID != revision.ID || grant.PlaintextToken == "" || grant.PlaintextOTP == "" { + t.Fatalf("revision grant response is incomplete: %#v", grant) + } + grants := callBFF[[]domain.ReviewGrant](t, client, http.MethodGet, server.URL+"/api/bff/submission-revisions/"+revision.ID+"/review-grants", nil) + if len(grants) != 1 || grants[0].PlaintextToken != "" || grants[0].PlaintextOTP != "" { + t.Fatalf("grant history omitted the grant or leaked secrets: %#v", grants) + } + unverified := callBFF[app.ReviewProjection](t, client, http.MethodGet, server.URL+"/api/review/"+grant.PlaintextToken+"/projection", nil) + if unverified.Verified || unverified.Submission != nil { + t.Fatalf("unverified projection leaked revision content: %#v", unverified) + } + verified := callBFF[app.ReviewProjection](t, client, http.MethodPost, server.URL+"/api/review/"+grant.PlaintextToken+"/verify", map[string]string{"otp": grant.PlaintextOTP}) + if !verified.Verified || verified.Submission == nil || verified.Submission.SubmissionRevisionID != revision.ID || verified.Submission.SubjectHash != revision.ContentHash { + t.Fatalf("verified V2 projection is incomplete: %#v", verified) + } + decision := callBFF[app.ReviewDecisionResult](t, client, http.MethodPost, server.URL+"/api/review/"+grant.PlaintextToken+"/decision", map[string]string{"decision": "approve", "reason": "client approved"}) + if decision.ApprovedSnapshot == nil || decision.Status != "approved" { + t.Fatalf("client decision did not create an ApprovedSnapshot: %#v", decision) + } + snapshot := *decision.ApprovedSnapshot + if snapshot.SubmissionRevisionID != revision.ID || snapshot.ContentHash != revision.ContentHash || snapshot.Origin != "current" { + t.Fatalf("snapshot lost revision lineage: %#v", snapshot) + } + listed := callBFF[[]domain.ApprovedSnapshot](t, client, http.MethodGet, server.URL+"/api/bff/projects/"+project.ID+"/approved-snapshots?type=script", nil) + if len(listed) != 1 || listed[0].ID != snapshot.ID { + t.Fatalf("snapshot list mismatch: %#v", listed) + } + artifact := callBFF[domain.Artifact](t, client, http.MethodPost, server.URL+"/api/bff/approved-snapshots/"+snapshot.ID+"/exports", map[string]string{"format": "json"}) + if artifact.ApprovedSnapshotID != snapshot.ID || artifact.Metadata["revision_hash"] != revision.ContentHash { + t.Fatalf("snapshot export lineage is incomplete: %#v", artifact) + } + delivery := callBFF[domain.DeliveryPackage](t, client, http.MethodPost, server.URL+"/api/bff/approved-snapshots/"+snapshot.ID+"/delivery-packages", map[string]any{}) + if len(delivery.Manifest) != 3 || len(delivery.ApprovedSnapshotIDs) != 1 || delivery.ApprovedSnapshotIDs[0] != snapshot.ID { + t.Fatalf("delivery package is incomplete: %#v", delivery) + } + deliveries := callBFF[[]domain.DeliveryPackage](t, client, http.MethodGet, server.URL+"/api/bff/projects/"+project.ID+"/delivery-packages", nil) + if len(deliveries) != 1 || len(deliveries[0].Manifest) != 3 { + t.Fatalf("delivery list mismatch: %#v", deliveries) + } + performance := callBFF[app.ImportPerformanceResult](t, client, http.MethodPost, server.URL+"/api/bff/projects/"+project.ID+"/results", app.CreateObservationInput{ApprovedSnapshotID: snapshot.ID, Platform: "douyin", AccountAlias: "brand-main", PublishedAt: time.Now().UTC().Add(-24 * time.Hour), WindowHours: 24, SampleStatus: "seed_candidate", Metrics: map[string]float64{"impressions": 1000}, Currency: "CNY", Spend: 100, GMV: 300, IssueCategory: "creative"}) + if len(performance.Observations) != 1 || performance.Observations[0].ApprovedSnapshotID != snapshot.ID || performance.Observations[0].ScriptVersionID != "" { + t.Fatalf("performance observation did not bind the snapshot: %#v", performance) + } +} diff --git a/internal/localworkspace/script.go b/internal/localworkspace/script.go index e409ee4..c147caa 100644 --- a/internal/localworkspace/script.go +++ b/internal/localworkspace/script.go @@ -324,6 +324,20 @@ type ScriptDeliveryManifest struct { CreatedAt time.Time `json:"created_at"` } +type RenderedScriptFile struct { + Format string + Name string + MediaType string + Body []byte + SHA256 string +} + +type RenderedScriptDelivery struct { + Package ScriptPackageV2 + ScriptHash string + Files []RenderedScriptFile +} + func LintBrief(root, file string) (KnowledgeLintReport, LocalBrief, error) { resolved, err := FindRoot(root) if err != nil { @@ -742,13 +756,11 @@ func ExportApprovedScript(root, scriptID, outputDirectory string, now time.Time) if err != nil { return ScriptDeliveryManifest{}, err } - var pkg ScriptPackageV2 - if err := strictUnmarshal(raw, &pkg); err != nil { - return ScriptDeliveryManifest{}, domain.Invalid("APPROVED_SCRIPT_INVALID", "批准快照中的 ScriptPackage V2 无效:"+err.Error()) - } - if pkg.Deliverability != "review_ready" { - return ScriptDeliveryManifest{}, domain.Policy("APPROVED_SCRIPT_BLOCKED", "blocked 剧本不能生成正式交付包", "修订并批准 review_ready ScriptPackage") + rendered, err := RenderScriptPackageV2(raw) + if err != nil { + return ScriptDeliveryManifest{}, err } + pkg := rendered.Package outputRoot := outputDirectory if strings.TrimSpace(outputRoot) == "" { outputRoot = filepath.Join(resolved, "outputs", "delivery", localSafeName(pkg.ID)) @@ -763,41 +775,49 @@ func ExportApprovedScript(root, scriptID, outputDirectory string, now time.Time) } outputRoot = absolute } + files := []ScriptDeliveryFile{} + for _, file := range rendered.Files { + path := filepath.Join(outputRoot, file.Name) + if err := replaceFile(path, file.Body, 0o600); err != nil { + return ScriptDeliveryManifest{}, err + } + files = append(files, ScriptDeliveryFile{Format: file.Format, Path: relativeWorkspacePath(resolved, path), SHA256: file.SHA256, ByteSize: int64(len(file.Body)), MediaType: file.MediaType}) + } + manifest := ScriptDeliveryManifest{SchemaVersion: "1.0", ScriptID: pkg.ID, ApprovedSnapshotID: snapshot.ID, ScriptHash: rendered.ScriptHash, Files: files, CreatedAt: localNow(now)} + if err := replaceJSON(filepath.Join(outputRoot, "manifest.json"), manifest, 0o600); err != nil { + return ScriptDeliveryManifest{}, err + } + return manifest, nil +} + +func RenderScriptPackageV2(raw json.RawMessage) (RenderedScriptDelivery, error) { + var pkg ScriptPackageV2 + if err := strictUnmarshal(raw, &pkg); err != nil { + return RenderedScriptDelivery{}, domain.Invalid("APPROVED_SCRIPT_INVALID", "批准快照中的 ScriptPackage V2 无效:"+err.Error()) + } + if pkg.Deliverability != "review_ready" { + return RenderedScriptDelivery{}, domain.Policy("APPROVED_SCRIPT_BLOCKED", "blocked 剧本不能生成正式交付包", "修订并批准 review_ready ScriptPackage") + } jsonBody, err := json.MarshalIndent(pkg, "", " ") if err != nil { - return ScriptDeliveryManifest{}, err + return RenderedScriptDelivery{}, err } jsonBody = append(jsonBody, '\n') - markdown := []byte(renderScriptV2Markdown(pkg)) xlsx, err := renderScriptV2XLSX(pkg) if err != nil { - return ScriptDeliveryManifest{}, err - } - formats := []struct { - name, media string - body []byte - }{ - {"script.json", "application/json", jsonBody}, - {"script.md", "text/markdown", markdown}, - {"script.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsx}, - } - files := []ScriptDeliveryFile{} - for _, format := range formats { - path := filepath.Join(outputRoot, format.name) - if err := replaceFile(path, format.body, 0o600); err != nil { - return ScriptDeliveryManifest{}, err - } - files = append(files, ScriptDeliveryFile{Format: strings.TrimPrefix(filepath.Ext(format.name), "."), Path: relativeWorkspacePath(resolved, path), SHA256: digest(format.body), ByteSize: int64(len(format.body)), MediaType: format.media}) + return RenderedScriptDelivery{}, err } hash, err := domain.CanonicalHash(pkg) if err != nil { - return ScriptDeliveryManifest{}, err + return RenderedScriptDelivery{}, err } - manifest := ScriptDeliveryManifest{SchemaVersion: "1.0", ScriptID: pkg.ID, ApprovedSnapshotID: snapshot.ID, ScriptHash: "sha256:" + hash, Files: files, CreatedAt: localNow(now)} - if err := replaceJSON(filepath.Join(outputRoot, "manifest.json"), manifest, 0o600); err != nil { - return ScriptDeliveryManifest{}, err + files := []RenderedScriptFile{ + {Format: "json", Name: "script.json", MediaType: "application/json", Body: jsonBody, SHA256: digest(jsonBody)}, + {Format: "markdown", Name: "script.md", MediaType: "text/markdown", Body: []byte(renderScriptV2Markdown(pkg))}, + {Format: "xlsx", Name: "script.xlsx", MediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Body: xlsx, SHA256: digest(xlsx)}, } - return manifest, nil + files[1].SHA256 = digest(files[1].Body) + return RenderedScriptDelivery{Package: pkg, ScriptHash: "sha256:" + hash, Files: files}, nil } func lintScriptPackage(pkg ScriptPackageV2, batch CreativeBatch, query KnowledgeQueryResult, refs map[string]LocalKnowledgeItem) ScriptLintReport { diff --git a/internal/store/memory/content.go b/internal/store/memory/content.go index d9fb04e..1eac80a 100644 --- a/internal/store/memory/content.go +++ b/internal/store/memory/content.go @@ -3,6 +3,7 @@ package memory import ( "context" "sort" + "time" "github.com/limecloud/contentcloud/internal/domain" ) @@ -462,13 +463,31 @@ func (s *Store) ReviewGrantByTokenHash(_ context.Context, hash string) (domain.R return domain.ReviewGrant{}, domain.NotFound("客户审批授权") } -func (s *Store) SaveReviewGrant(_ context.Context, v domain.ReviewGrant) error { +func (s *Store) MarkReviewGrantVerified(_ context.Context, tenantID, id string, verifiedAt time.Time) error { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.reviewGrants[v.ID]; !ok { - return domain.NotFound("客户审批授权") + grant, ok := s.reviewGrants[id] + if !ok || grant.TenantID != tenantID || grant.RevokedAt != nil || grant.DecisionAt != nil || !verifiedAt.Before(grant.ExpiresAt) { + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权已撤销、已完成或已过期") + } + if grant.VerifiedAt == nil { + grant.VerifiedAt = &verifiedAt + s.reviewGrants[id] = grant + } + return nil +} + +func (s *Store) RevokeReviewGrant(_ context.Context, tenantID, id string, revokedAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + grant, ok := s.reviewGrants[id] + if !ok || grant.TenantID != tenantID || grant.DecisionAt != nil { + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权不存在或已完成") + } + if grant.RevokedAt == nil { + grant.RevokedAt = &revokedAt + s.reviewGrants[id] = grant } - s.reviewGrants[v.ID] = v return nil } @@ -492,6 +511,19 @@ func (s *Store) Artifacts(_ context.Context, tenantID, scriptVersionID string) ( return out, nil } +func (s *Store) ArtifactsByApprovedSnapshot(_ context.Context, tenantID, snapshotID string) ([]domain.Artifact, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := []domain.Artifact{} + for _, value := range s.artifacts { + if value.TenantID == tenantID && value.ApprovedSnapshotID == snapshotID { + out = append(out, value) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + func (s *Store) Artifact(_ context.Context, tenantID, id string) (domain.Artifact, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -502,6 +534,48 @@ func (s *Store) Artifact(_ context.Context, tenantID, id string) (domain.Artifac return v, nil } +func (s *Store) CreateDeliveryPackage(_ context.Context, value domain.DeliveryPackage, artifacts []domain.Artifact) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.deliveryPackages[value.ID]; exists { + return domain.Conflict("DELIVERY_PACKAGE_EXISTS", "交付包已存在") + } + for _, artifact := range artifacts { + if _, exists := s.artifacts[artifact.ID]; exists { + return domain.Conflict("ARTIFACT_EXISTS", "交付 Artifact 已存在") + } + } + for _, artifact := range artifacts { + s.artifacts[artifact.ID] = artifact + } + value.Manifest = append([]domain.Artifact(nil), artifacts...) + s.deliveryPackages[value.ID] = value + return nil +} + +func (s *Store) DeliveryPackages(_ context.Context, tenantID, projectID string) ([]domain.DeliveryPackage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := []domain.DeliveryPackage{} + for _, value := range s.deliveryPackages { + if value.TenantID == tenantID && (projectID == "" || value.ProjectID == projectID) { + out = append(out, value) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +func (s *Store) DeliveryPackage(_ context.Context, tenantID, id string) (domain.DeliveryPackage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + value, ok := s.deliveryPackages[id] + if !ok || value.TenantID != tenantID { + return value, domain.NotFound("DeliveryPackage") + } + return value, nil +} + func (s *Store) CreatePerformanceImportBatch(_ context.Context, batch domain.PerformanceImportBatch, observations []domain.PerformanceObservation) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go index 35536c2..3b70803 100644 --- a/internal/store/memory/memory.go +++ b/internal/store/memory/memory.go @@ -52,6 +52,7 @@ type Store struct { submissionRevisions map[string]domain.SubmissionRevision approvedSnapshots map[string]domain.ApprovedSnapshot artifacts map[string]domain.Artifact + deliveryPackages map[string]domain.DeliveryPackage artifactOpenRequests map[string]domain.ArtifactOpenRequest performanceBatches map[string]domain.PerformanceImportBatch observations map[string]domain.PerformanceObservation @@ -67,7 +68,7 @@ func New() *Store { sources: map[string]domain.Source{}, revisions: map[string]domain.SourceRevision{}, evidence: map[string]domain.EvidenceSpan{}, assets: map[string]domain.Asset{}, rightsRecords: map[string]domain.RightsRecord{}, knowledge: map[string]domain.KnowledgeItem{}, knowledgeConflicts: map[string]domain.KnowledgeConflict{}, decisionRequests: map[string]domain.DecisionRequest{}, benchmarks: map[string]domain.BenchmarkContent{}, frameworks: map[string]domain.ContentFramework{}, shotPatterns: map[string]domain.ShotPattern{}, sellingPoints: map[string]domain.SellingPoint{}, visualizationPlans: map[string]domain.VisualizationPlan{}, briefs: map[string]domain.BriefVersion{}, snapshots: map[string]domain.ContextSnapshot{}, runs: map[string]domain.TaskRun{}, runAttempts: map[string]domain.RunAttempt{}, logicalScripts: map[string]domain.Script{}, - scripts: map[string]domain.ScriptVersion{}, approvals: map[string]domain.ApprovalDecision{}, reviewCycles: map[string]domain.ReviewCycle{}, reviewComments: map[string]domain.ReviewComment{}, reviewGrants: map[string]domain.ReviewGrant{}, submissions: map[string]domain.Submission{}, submissionRevisions: map[string]domain.SubmissionRevision{}, approvedSnapshots: map[string]domain.ApprovedSnapshot{}, artifacts: map[string]domain.Artifact{}, artifactOpenRequests: map[string]domain.ArtifactOpenRequest{}, performanceBatches: map[string]domain.PerformanceImportBatch{}, observations: map[string]domain.PerformanceObservation{}, ratingDecisions: map[string]domain.RatingDecision{}, audits: []domain.AuditEvent{}, + scripts: map[string]domain.ScriptVersion{}, approvals: map[string]domain.ApprovalDecision{}, reviewCycles: map[string]domain.ReviewCycle{}, reviewComments: map[string]domain.ReviewComment{}, reviewGrants: map[string]domain.ReviewGrant{}, submissions: map[string]domain.Submission{}, submissionRevisions: map[string]domain.SubmissionRevision{}, approvedSnapshots: map[string]domain.ApprovedSnapshot{}, artifacts: map[string]domain.Artifact{}, deliveryPackages: map[string]domain.DeliveryPackage{}, artifactOpenRequests: map[string]domain.ArtifactOpenRequest{}, performanceBatches: map[string]domain.PerformanceImportBatch{}, observations: map[string]domain.PerformanceObservation{}, ratingDecisions: map[string]domain.RatingDecision{}, audits: []domain.AuditEvent{}, } } @@ -980,6 +981,9 @@ func (s *Store) SaveScript(_ context.Context, v domain.ScriptVersion) error { func (s *Store) CreateApproval(_ context.Context, v domain.ApprovalDecision) error { s.mu.Lock() defer s.mu.Unlock() + if v.DecisionStage == "" { + v.DecisionStage = "legacy" + } s.approvals[v.ID] = v return nil } diff --git a/internal/store/memory/submissions.go b/internal/store/memory/submissions.go index a3b8291..ce5ece0 100644 --- a/internal/store/memory/submissions.go +++ b/internal/store/memory/submissions.go @@ -3,6 +3,7 @@ package memory import ( "context" "sort" + "time" "github.com/limecloud/contentcloud/internal/domain" ) @@ -78,6 +79,14 @@ func (s *Store) CreateSubmissionRevision(_ context.Context, submission domain.Su s.submissions[submission.ID] = submission s.submissionRevisions[revision.ID] = revision s.reviewCycles[cycle.ID] = cycle + for id, grant := range s.reviewGrants { + grantRevision, ok := s.submissionRevisions[grant.SubjectID] + if grant.SubjectType == "submission_revision" && ok && grantRevision.SubmissionID == submission.ID && grant.SubjectID != revision.ID && grant.RevokedAt == nil && grant.DecisionAt == nil { + revokedAt := revision.CreatedAt + grant.RevokedAt = &revokedAt + s.reviewGrants[id] = grant + } + } return nil } @@ -161,6 +170,18 @@ func (s *Store) ApprovedSnapshot(_ context.Context, tenantID, id string) (domain return value, nil } +func (s *Store) RecordSubmissionApproval(_ context.Context, submission domain.Submission, decision domain.ApprovalDecision) error { + s.mu.Lock() + defer s.mu.Unlock() + existing, ok := s.submissions[submission.ID] + if !ok || existing.TenantID != submission.TenantID { + return domain.NotFound("Submission") + } + s.submissions[submission.ID] = submission + s.approvals[decision.ID] = decision + return nil +} + func (s *Store) ApproveSubmissionRevision(_ context.Context, submission domain.Submission, snapshot domain.ApprovedSnapshot, decision domain.ApprovalDecision) error { s.mu.Lock() defer s.mu.Unlock() @@ -187,5 +208,84 @@ func (s *Store) RequestSubmissionChanges(_ context.Context, submission domain.Su s.submissions[submission.ID] = submission s.approvals[decision.ID] = decision s.reviewComments[comment.ID] = comment + for id, grant := range s.reviewGrants { + if grant.TenantID == submission.TenantID && grant.SubjectID == submission.CurrentRevisionID && grant.SubjectType == "submission_revision" && grant.RevokedAt == nil && grant.DecisionAt == nil { + revokedAt := decision.CreatedAt + grant.RevokedAt = &revokedAt + s.reviewGrants[id] = grant + } + } + return nil +} + +func (s *Store) CreateSubmissionReviewGrant(_ context.Context, submission domain.Submission, grant domain.ReviewGrant) error { + s.mu.Lock() + defer s.mu.Unlock() + existing, ok := s.submissions[submission.ID] + if !ok || existing.TenantID != submission.TenantID { + return domain.NotFound("Submission") + } + if _, ok := s.submissionRevisions[grant.SubjectID]; !ok { + return domain.NotFound("SubmissionRevision") + } + s.submissions[submission.ID] = submission + s.reviewGrants[grant.ID] = grant return nil } + +func (s *Store) CompleteSubmissionClientReview(_ context.Context, submission domain.Submission, grant domain.ReviewGrant, decision domain.ApprovalDecision, comment *domain.ReviewComment, snapshot *domain.ApprovedSnapshot) error { + s.mu.Lock() + defer s.mu.Unlock() + existing, ok := s.submissions[submission.ID] + if !ok || existing.TenantID != submission.TenantID { + return domain.NotFound("Submission") + } + storedGrant, ok := s.reviewGrants[grant.ID] + if !ok || storedGrant.DecisionAt != nil || storedGrant.RevokedAt != nil || time.Now().After(storedGrant.ExpiresAt) { + return domain.Conflict("REVIEW_ALREADY_DECIDED", "该审批链接已失效或已完成最终决策") + } + s.submissions[submission.ID] = submission + s.reviewGrants[grant.ID] = grant + s.approvals[decision.ID] = decision + if comment != nil { + s.reviewComments[comment.ID] = *comment + } + if snapshot != nil { + if _, exists := s.approvedSnapshots[snapshot.ID]; exists { + return domain.Conflict("APPROVED_SNAPSHOT_EXISTS", "批准快照已存在") + } + s.approvedSnapshots[snapshot.ID] = *snapshot + } + return nil +} + +func (s *Store) CompleteLegacyClientReview(_ context.Context, script domain.ScriptVersion, grant domain.ReviewGrant, decision domain.ApprovalDecision, comment *domain.ReviewComment) error { + s.mu.Lock() + defer s.mu.Unlock() + storedGrant, ok := s.reviewGrants[grant.ID] + if !ok || storedGrant.TenantID != grant.TenantID || storedGrant.VerifiedAt == nil || storedGrant.RevokedAt != nil || storedGrant.DecisionAt != nil || !decision.CreatedAt.Before(storedGrant.ExpiresAt) { + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权未验证、已撤销、已完成或已过期") + } + storedScript, ok := s.scripts[script.ID] + if !ok || storedScript.TenantID != script.TenantID || storedScript.ContentHash != script.ContentHash || storedScript.Status != "client_review" { + return domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + } + storedScript.Status = script.Status + s.scripts[script.ID] = storedScript + decision.DecisionStage = defaultMemoryDecisionStage(decision.DecisionStage) + s.approvals[decision.ID] = decision + if comment != nil { + s.reviewComments[comment.ID] = *comment + } + decidedAt := decision.CreatedAt + storedGrant.DecisionAt = &decidedAt + s.reviewGrants[grant.ID] = storedGrant + return nil +} + +func defaultMemoryDecisionStage(value string) string { + if value == "" { + return "legacy" + } + return value +} diff --git a/internal/store/postgres/content_integration_test.go b/internal/store/postgres/content_integration_test.go index 4b907de..c190643 100644 --- a/internal/store/postgres/content_integration_test.go +++ b/internal/store/postgres/content_integration_test.go @@ -212,16 +212,18 @@ func TestSourceLifecycleWithPostgres(t *testing.T) { t.Fatalf("review grant list must persist the grant without exposing secrets: %#v", persistedGrants) } revokedAt := now.Add(3 * time.Minute).Truncate(time.Microsecond) - reviewGrant.RevokedAt = &revokedAt - if err := store.SaveReviewGrant(ctx, reviewGrant); err != nil { + if err := store.RevokeReviewGrant(ctx, reviewGrant.TenantID, reviewGrant.ID, revokedAt); err != nil { t.Fatal(err) } + if err := store.MarkReviewGrantVerified(ctx, reviewGrant.TenantID, reviewGrant.ID, revokedAt.Add(time.Second)); err == nil { + t.Fatal("late verification must not restore a revoked review grant") + } persistedGrant, err := store.ReviewGrant(ctx, actor.TenantID, reviewGrant.ID) if err != nil { t.Fatal(err) } - if persistedGrant.RevokedAt == nil || !persistedGrant.RevokedAt.Equal(revokedAt) { - t.Fatalf("review grant revocation was not persisted: %#v", persistedGrant) + if persistedGrant.RevokedAt == nil || !persistedGrant.RevokedAt.Equal(revokedAt) || persistedGrant.VerifiedAt != nil { + t.Fatalf("review grant revocation was overwritten by late verification: %#v", persistedGrant) } artifactEnvelope := domain.ExtensionArtifactEnvelopeV1{EnvelopeVersion: "1.0", ProjectID: project.ID, ScriptVersionID: secondVersion.ID, Capability: domain.ArtifactCapabilityRef{ID: domain.ArtifactExportCapability, Version: "1.0.0", Digest: "postgres-artifact-digest"}, SchemaID: "test.timeline/1.0", MediaType: "application/octet-stream", SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 128, Renditions: []domain.ArtifactRenditionRef{}, Metadata: map[string]any{"variant": "A"}} artifact := domain.Artifact{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, ScriptVersionID: secondVersion.ID, Kind: "extension", CapabilityID: artifactEnvelope.Capability.ID, CapabilityVersion: artifactEnvelope.Capability.Version, CapabilityDigest: artifactEnvelope.Capability.Digest, SchemaID: artifactEnvelope.SchemaID, MediaType: artifactEnvelope.MediaType, FileName: "timeline.bin", SHA256: artifactEnvelope.SHA256, ByteSize: artifactEnvelope.Size, Visibility: "internal", RetentionClass: "project", Purpose: "primary", SourceDeviceID: device.ID, ValidationStatus: "valid", Envelope: &artifactEnvelope, PresentationTier: "metadata_only", Metadata: artifactEnvelope.Metadata, CreatedAt: now} diff --git a/internal/store/postgres/delivery.go b/internal/store/postgres/delivery.go new file mode 100644 index 0000000..f011a69 --- /dev/null +++ b/internal/store/postgres/delivery.go @@ -0,0 +1,109 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/limecloud/contentcloud/internal/domain" +) + +func (s *Store) CreateDeliveryPackage(ctx context.Context, value domain.DeliveryPackage, artifacts []domain.Artifact) error { + return s.withTenant(ctx, value.TenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `INSERT INTO delivery_packages(id,tenant_id,project_id,script_id,status,created_by,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, value.ID, value.TenantID, value.ProjectID, value.ScriptID, value.Status, value.CreatedBy, value.CreatedAt); err != nil { + return dbError(err) + } + for position, snapshotID := range value.ApprovedSnapshotIDs { + if _, err := tx.Exec(ctx, `INSERT INTO delivery_package_snapshots(tenant_id,delivery_package_id,approved_snapshot_id,position) VALUES($1,$2,$3,$4)`, value.TenantID, value.ID, snapshotID, position); err != nil { + return dbError(err) + } + } + for position, artifact := range artifacts { + if err := insertArtifact(ctx, tx, artifact); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO delivery_package_artifacts(tenant_id,delivery_package_id,artifact_id,position) VALUES($1,$2,$3,$4)`, value.TenantID, value.ID, artifact.ID, position); err != nil { + return dbError(err) + } + } + return nil + }) +} + +func (s *Store) DeliveryPackages(ctx context.Context, tenantID, projectID string) ([]domain.DeliveryPackage, error) { + values := []domain.DeliveryPackage{} + err := s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, `SELECT id,tenant_id,project_id,script_id,status,created_by,created_at FROM delivery_packages WHERE tenant_id=$1 AND ($2='' OR project_id::text=$2) ORDER BY created_at DESC`, tenantID, projectID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var value domain.DeliveryPackage + if err := rows.Scan(&value.ID, &value.TenantID, &value.ProjectID, &value.ScriptID, &value.Status, &value.CreatedBy, &value.CreatedAt); err != nil { + return err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return err + } + for index := range values { + if err := loadDeliveryPackageRelations(ctx, tx, &values[index]); err != nil { + return err + } + } + return nil + }) + return values, err +} + +func (s *Store) DeliveryPackage(ctx context.Context, tenantID, id string) (domain.DeliveryPackage, error) { + var value domain.DeliveryPackage + err := s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, `SELECT id,tenant_id,project_id,script_id,status,created_by,created_at FROM delivery_packages WHERE tenant_id=$1 AND id=$2`, tenantID, id).Scan(&value.ID, &value.TenantID, &value.ProjectID, &value.ScriptID, &value.Status, &value.CreatedBy, &value.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return domain.NotFound("DeliveryPackage") + } + if err != nil { + return err + } + return loadDeliveryPackageRelations(ctx, tx, &value) + }) + return value, err +} + +func loadDeliveryPackageRelations(ctx context.Context, tx pgx.Tx, value *domain.DeliveryPackage) error { + snapshotRows, err := tx.Query(ctx, `SELECT approved_snapshot_id FROM delivery_package_snapshots WHERE tenant_id=$1 AND delivery_package_id=$2 ORDER BY position`, value.TenantID, value.ID) + if err != nil { + return err + } + defer snapshotRows.Close() + value.ApprovedSnapshotIDs = []string{} + for snapshotRows.Next() { + var id string + if err := snapshotRows.Scan(&id); err != nil { + return err + } + value.ApprovedSnapshotIDs = append(value.ApprovedSnapshotIDs, id) + } + if err := snapshotRows.Err(); err != nil { + return err + } + snapshotRows.Close() + artifactRows, err := tx.Query(ctx, artifactSelect+` WHERE tenant_id=$1 AND id IN (SELECT artifact_id FROM delivery_package_artifacts WHERE tenant_id=$1 AND delivery_package_id=$2) ORDER BY (SELECT position FROM delivery_package_artifacts WHERE delivery_package_id=$2 AND artifact_id=artifacts.id)`, value.TenantID, value.ID) + if err != nil { + return err + } + defer artifactRows.Close() + value.Manifest = []domain.Artifact{} + for artifactRows.Next() { + artifact, err := scanArtifact(artifactRows) + if err != nil { + return err + } + value.Manifest = append(value.Manifest, artifact) + } + return artifactRows.Err() +} diff --git a/internal/store/postgres/performance_results.go b/internal/store/postgres/performance_results.go index f91a6dd..893901f 100644 --- a/internal/store/postgres/performance_results.go +++ b/internal/store/postgres/performance_results.go @@ -9,7 +9,7 @@ import ( "github.com/limecloud/contentcloud/internal/domain" ) -const performanceObservationSelect = `SELECT id,tenant_id,project_id,COALESCE(import_batch_id::text,''),COALESCE(row_number,0),script_version_id,platform,account_alias,published_at,window_hours,sample_status,metrics,currency,spend,gmv,roi,COALESCE(dedup_key,''),issue_category,notes,created_at FROM performance_observations` +const performanceObservationSelect = `SELECT id,tenant_id,project_id,COALESCE(import_batch_id::text,''),COALESCE(row_number,0),COALESCE(approved_snapshot_id::text,''),COALESCE(script_version_id::text,''),platform,account_alias,published_at,window_hours,sample_status,metrics,currency,spend,gmv,roi,COALESCE(dedup_key,''),issue_category,notes,created_at FROM performance_observations` func (s *Store) CreatePerformanceImportBatch(ctx context.Context, batch domain.PerformanceImportBatch, observations []domain.PerformanceObservation) error { return s.withTenant(ctx, batch.TenantID, func(tx pgx.Tx) error { @@ -18,7 +18,7 @@ func (s *Store) CreatePerformanceImportBatch(ctx context.Context, batch domain.P return dbError(err) } for _, v := range observations { - _, err = tx.Exec(ctx, `INSERT INTO performance_observations(id,tenant_id,project_id,import_batch_id,row_number,script_version_id,platform,account_alias,published_at,window_hours,sample_status,metrics,currency,spend,gmv,roi,dedup_key,issue_category,notes,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, v.ID, v.TenantID, v.ProjectID, v.ImportBatchID, v.RowNumber, v.ScriptVersionID, v.Platform, v.AccountAlias, v.PublishedAt, v.WindowHours, v.SampleStatus, jsonValue(v.Metrics), v.Currency, v.Spend, v.GMV, v.ROI, v.DedupKey, v.IssueCategory, v.Notes, v.CreatedAt) + _, err = tx.Exec(ctx, `INSERT INTO performance_observations(id,tenant_id,project_id,import_batch_id,row_number,approved_snapshot_id,script_version_id,platform,account_alias,published_at,window_hours,sample_status,metrics,currency,spend,gmv,roi,dedup_key,issue_category,notes,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, v.ID, v.TenantID, v.ProjectID, v.ImportBatchID, v.RowNumber, nullable(v.ApprovedSnapshotID), nullable(v.ScriptVersionID), v.Platform, v.AccountAlias, v.PublishedAt, v.WindowHours, v.SampleStatus, jsonValue(v.Metrics), v.Currency, v.Spend, v.GMV, v.ROI, v.DedupKey, v.IssueCategory, v.Notes, v.CreatedAt) if err != nil { return dbError(err) } @@ -118,7 +118,7 @@ func (s *Store) PerformanceObservations(ctx context.Context, tenantID, projectID func scanPerformanceObservation(row pgx.Row) (domain.PerformanceObservation, error) { var v domain.PerformanceObservation var metrics []byte - err := row.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.ImportBatchID, &v.RowNumber, &v.ScriptVersionID, &v.Platform, &v.AccountAlias, &v.PublishedAt, &v.WindowHours, &v.SampleStatus, &metrics, &v.Currency, &v.Spend, &v.GMV, &v.ROI, &v.DedupKey, &v.IssueCategory, &v.Notes, &v.CreatedAt) + err := row.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.ImportBatchID, &v.RowNumber, &v.ApprovedSnapshotID, &v.ScriptVersionID, &v.Platform, &v.AccountAlias, &v.PublishedAt, &v.WindowHours, &v.SampleStatus, &metrics, &v.Currency, &v.Spend, &v.GMV, &v.ROI, &v.DedupKey, &v.IssueCategory, &v.Notes, &v.CreatedAt) if err != nil { return v, err } diff --git a/internal/store/postgres/performance_results_integration_test.go b/internal/store/postgres/performance_results_integration_test.go index f6f5737..7071021 100644 --- a/internal/store/postgres/performance_results_integration_test.go +++ b/internal/store/postgres/performance_results_integration_test.go @@ -2,12 +2,15 @@ package postgres_test import ( "context" + "encoding/json" "fmt" "log/slog" "os" "testing" "time" + "github.com/jackc/pgx/v5" + "github.com/limecloud/contentcloud/internal/app" "github.com/limecloud/contentcloud/internal/domain" storepg "github.com/limecloud/contentcloud/internal/store/postgres" @@ -51,10 +54,50 @@ func TestPerformanceImportTransactionWithPostgres(t *testing.T) { t.Fatal(err) } logical := domain.Script{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, Title: "Results script", CreatedAt: now} - script, err := store.CreateScript(ctx, logical, domain.ScriptVersion{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, RunID: run.ID, ChangeType: "initial", InvariantFields: []string{}, ChangedFields: []string{}, Status: "approved", InputSnapshotID: snapshot.ID, ContentHash: "results-script-" + suffix, Package: domain.ScriptPackage{SchemaVersion: "1.1", Title: "Results script"}, Validation: domain.ValidationReport{Valid: true}, CreatedAt: now}) + script, err := store.CreateScript(ctx, logical, domain.ScriptVersion{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, RunID: run.ID, ChangeType: "initial", InvariantFields: []string{}, ChangedFields: []string{}, Status: "approved", InputSnapshotID: snapshot.ID, ContentHash: domain.TokenHash("results-script-" + suffix), Package: domain.ScriptPackage{SchemaVersion: "1.1", Title: "Results script"}, Validation: domain.ValidationReport{Valid: true}, CreatedAt: now}) if err != nil { t.Fatal(err) } + invalidRun := run + invalidRun.ID = domain.NewID() + invalidRun.IdempotencyKey = "invalid-results-run-" + suffix + if err := store.CreateRun(ctx, invalidRun); err != nil { + t.Fatal(err) + } + invalidLogical := domain.Script{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, Title: "Invalid legacy hash", CreatedAt: now} + if _, err := store.CreateScript(ctx, invalidLogical, domain.ScriptVersion{ID: domain.NewID(), TenantID: actor.TenantID, ProjectID: project.ID, RunID: invalidRun.ID, ChangeType: "initial", InvariantFields: []string{}, ChangedFields: []string{}, Status: "approved", InputSnapshotID: snapshot.ID, ContentHash: "legacy-invalid-hash", Package: domain.ScriptPackage{SchemaVersion: "1.1", Title: "Invalid legacy hash"}, Validation: domain.ValidationReport{Valid: true}, CreatedAt: now}); err != nil { + t.Fatal(err) + } + conn, err := pgx.Connect(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + type backfillReport struct { + Inserted int `json:"inserted"` + SkippedInvalidHash int `json:"skipped_invalid_hash"` + } + var firstJSON, secondJSON []byte + if err := conn.QueryRow(ctx, `SELECT contentcloud_backfill_v1_approved_snapshots($1::uuid)`, actor.TenantID).Scan(&firstJSON); err != nil { + t.Fatal(err) + } + if err := conn.QueryRow(ctx, `SELECT contentcloud_backfill_v1_approved_snapshots($1::uuid)`, actor.TenantID).Scan(&secondJSON); err != nil { + t.Fatal(err) + } + var firstBackfill, secondBackfill backfillReport + if err := json.Unmarshal(firstJSON, &firstBackfill); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(secondJSON, &secondBackfill); err != nil { + t.Fatal(err) + } + legacySnapshots, err := store.ApprovedSnapshots(ctx, actor.TenantID, project.ID, "script") + if err != nil { + t.Fatal(err) + } + if firstBackfill.Inserted != 1 || firstBackfill.SkippedInvalidHash != 1 || secondBackfill.Inserted != 0 || secondBackfill.SkippedInvalidHash != 1 || len(legacySnapshots) != 1 || legacySnapshots[0].Origin != "v1_import" || legacySnapshots[0].ExternalRef != script.ID || legacySnapshots[0].ContentHash != script.ContentHash || legacySnapshots[0].SubjectHash != script.ContentHash { + t.Fatalf("V1 shadow backfill is not idempotent or hash-preserving: first=%#v second=%#v snapshots=%#v", firstBackfill, secondBackfill, legacySnapshots) + } result, err := service.ImportPerformanceObservations(ctx, actor, app.ImportPerformanceInput{ProjectID: project.ID, SourceName: "results.csv", SourceFormat: "csv", Observations: []app.CreateObservationInput{{RowNumber: 2, ScriptVersionID: script.ID, Platform: "douyin", AccountAlias: "brand-main", PublishedAt: now.Add(-24 * time.Hour), WindowHours: 24, SampleStatus: "seed_candidate", Metrics: map[string]float64{"impressions": 1000}, Currency: "CNY", Spend: 100, GMV: 250, IssueCategory: "creative"}}}, "req-results") if err != nil { diff --git a/internal/store/postgres/production.go b/internal/store/postgres/production.go index b38c3db..a4a5652 100644 --- a/internal/store/postgres/production.go +++ b/internal/store/postgres/production.go @@ -344,14 +344,14 @@ func (s *Store) SaveScript(ctx context.Context, v domain.ScriptVersion) error { func (s *Store) CreateApproval(ctx context.Context, v domain.ApprovalDecision) error { return s.withTenant(ctx, v.TenantID, func(tx pgx.Tx) error { - _, err := tx.Exec(ctx, `INSERT INTO approval_decisions(id,tenant_id,project_id,subject_type,subject_id,subject_hash,actor_id,decision,reason,previous_state,resulting_state,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, v.ID, v.TenantID, v.ProjectID, v.SubjectType, v.SubjectID, v.SubjectHash, v.ActorID, v.Decision, v.Reason, v.PreviousState, v.ResultingState, v.CreatedAt) + _, err := tx.Exec(ctx, `INSERT INTO approval_decisions(id,tenant_id,project_id,subject_type,subject_id,subject_hash,decision_stage,actor_id,decision,reason,previous_state,resulting_state,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, v.ID, v.TenantID, v.ProjectID, v.SubjectType, v.SubjectID, v.SubjectHash, defaultDecisionStage(v.DecisionStage), v.ActorID, v.Decision, v.Reason, v.PreviousState, v.ResultingState, v.CreatedAt) return dbError(err) }) } func (s *Store) Approvals(ctx context.Context, tenantID, subjectID string) ([]domain.ApprovalDecision, error) { out := []domain.ApprovalDecision{} err := s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { - query := `SELECT id,tenant_id,project_id,subject_type,subject_id,subject_hash,actor_id,decision,reason,previous_state,resulting_state,created_at FROM approval_decisions WHERE tenant_id=$1` + query := `SELECT id,tenant_id,project_id,subject_type,subject_id,subject_hash,decision_stage,actor_id,decision,reason,previous_state,resulting_state,created_at FROM approval_decisions WHERE tenant_id=$1` args := []any{tenantID} if subjectID != "" { query += ` AND subject_id=$2` @@ -365,7 +365,7 @@ func (s *Store) Approvals(ctx context.Context, tenantID, subjectID string) ([]do defer rows.Close() for rows.Next() { var v domain.ApprovalDecision - if err := rows.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.SubjectType, &v.SubjectID, &v.SubjectHash, &v.ActorID, &v.Decision, &v.Reason, &v.PreviousState, &v.ResultingState, &v.CreatedAt); err != nil { + if err := rows.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.SubjectType, &v.SubjectID, &v.SubjectHash, &v.DecisionStage, &v.ActorID, &v.Decision, &v.Reason, &v.PreviousState, &v.ResultingState, &v.CreatedAt); err != nil { return err } out = append(out, v) @@ -375,6 +375,13 @@ func (s *Store) Approvals(ctx context.Context, tenantID, subjectID string) ([]do return out, err } +func defaultDecisionStage(value string) string { + if value == "" { + return "legacy" + } + return value +} + func (s *Store) AppendAudit(ctx context.Context, v domain.AuditEvent) error { return s.withTenant(ctx, v.TenantID, func(tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO audit_events(id,tenant_id,project_id,actor_type,actor_id,action,subject_type,subject_id,summary,request_id,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, v.ID, v.TenantID, nullable(v.ProjectID), v.ActorType, v.ActorID, v.Action, v.SubjectType, v.SubjectID, jsonValue(v.Summary), v.RequestID, v.CreatedAt) diff --git a/internal/store/postgres/review.go b/internal/store/postgres/review.go index b600f63..6eeefab 100644 --- a/internal/store/postgres/review.go +++ b/internal/store/postgres/review.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "time" "github.com/jackc/pgx/v5" @@ -205,14 +206,59 @@ func (s *Store) ReviewGrantByTokenHash(ctx context.Context, hash string) (domain }) return result, err } -func (s *Store) SaveReviewGrant(ctx context.Context, v domain.ReviewGrant) error { - return s.withTenant(ctx, v.TenantID, func(tx pgx.Tx) error { - result, err := tx.Exec(ctx, `UPDATE review_grants SET verified_at=$3,revoked_at=$4,decision_at=$5 WHERE tenant_id=$1 AND id=$2`, v.TenantID, v.ID, v.VerifiedAt, v.RevokedAt, v.DecisionAt) +func (s *Store) MarkReviewGrantVerified(ctx context.Context, tenantID, id string, verifiedAt time.Time) error { + return s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { + result, err := tx.Exec(ctx, `UPDATE review_grants SET verified_at=COALESCE(verified_at,$3) WHERE tenant_id=$1 AND id=$2 AND revoked_at IS NULL AND decision_at IS NULL AND expires_at>$3`, tenantID, id, verifiedAt) if err != nil { return dbError(err) } if result.RowsAffected() == 0 { - return domain.NotFound("客户审批授权") + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权已撤销、已完成或已过期") + } + return nil + }) +} + +func (s *Store) RevokeReviewGrant(ctx context.Context, tenantID, id string, revokedAt time.Time) error { + return s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { + result, err := tx.Exec(ctx, `UPDATE review_grants SET revoked_at=COALESCE(revoked_at,$3) WHERE tenant_id=$1 AND id=$2 AND decision_at IS NULL`, tenantID, id, revokedAt) + if err != nil { + return dbError(err) + } + if result.RowsAffected() == 0 { + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权不存在或已完成") + } + return nil + }) +} + +func (s *Store) CompleteLegacyClientReview(ctx context.Context, script domain.ScriptVersion, grant domain.ReviewGrant, decision domain.ApprovalDecision, comment *domain.ReviewComment) error { + return s.withTenant(ctx, grant.TenantID, func(tx pgx.Tx) error { + var verifiedAt, revokedAt, decisionAt *time.Time + var expiresAt time.Time + if err := tx.QueryRow(ctx, `SELECT verified_at,revoked_at,decision_at,expires_at FROM review_grants WHERE tenant_id=$1 AND id=$2 FOR UPDATE`, grant.TenantID, grant.ID).Scan(&verifiedAt, &revokedAt, &decisionAt, &expiresAt); err != nil { + return dbError(err) + } + if verifiedAt == nil || revokedAt != nil || decisionAt != nil || !decision.CreatedAt.Before(expiresAt) { + return domain.Conflict("REVIEW_GRANT_STATE_INVALID", "客户审批授权未验证、已撤销、已完成或已过期") + } + result, err := tx.Exec(ctx, `UPDATE script_versions SET status=$3 WHERE tenant_id=$1 AND id=$2 AND content_hash=$4 AND status='client_review'`, script.TenantID, script.ID, script.Status, script.ContentHash) + if err != nil { + return dbError(err) + } + if result.RowsAffected() == 0 { + return domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + } + if err := insertApprovalDecision(ctx, tx, decision); err != nil { + return err + } + if comment != nil { + if _, err := tx.Exec(ctx, `INSERT INTO review_comments(id,tenant_id,project_id,review_cycle_id,subject_type,subject_id,carried_from_comment_id,shot_id,json_pointer,body,visibility,author_id,resolved_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, comment.ID, comment.TenantID, comment.ProjectID, nullable(comment.ReviewCycleID), comment.SubjectType, comment.SubjectID, nullable(comment.CarriedFromID), comment.ShotID, comment.JSONPointer, comment.Body, comment.Visibility, comment.AuthorID, comment.ResolvedAt, comment.CreatedAt); err != nil { + return dbError(err) + } + } + if _, err := tx.Exec(ctx, `UPDATE review_grants SET decision_at=$3 WHERE tenant_id=$1 AND id=$2`, grant.TenantID, grant.ID, decision.CreatedAt); err != nil { + return dbError(err) } return nil }) @@ -220,18 +266,22 @@ func (s *Store) SaveReviewGrant(ctx context.Context, v domain.ReviewGrant) error func (s *Store) CreateArtifact(ctx context.Context, v domain.Artifact) error { return s.withTenant(ctx, v.TenantID, func(tx pgx.Tx) error { - var envelope any - if v.Envelope != nil { - envelope = jsonValue(v.Envelope) - } - _, err := tx.Exec(ctx, `INSERT INTO artifacts(id,tenant_id,project_id,script_version_id,kind,capability_id,capability_version,capability_digest,schema_id,media_type,file_name,sha256,byte_size,object_key,visibility,retention_class,derived_from_artifact_id,purpose,source_device_id,validation_status,validation_error,artifact_envelope,presentation_tier,metadata,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)`, v.ID, v.TenantID, v.ProjectID, v.ScriptVersionID, v.Kind, v.CapabilityID, v.CapabilityVersion, v.CapabilityDigest, v.SchemaID, v.MediaType, v.FileName, v.SHA256, v.ByteSize, v.ObjectKey, v.Visibility, v.RetentionClass, nullable(v.DerivedFromArtifactID), v.Purpose, nullable(v.SourceDeviceID), v.ValidationStatus, v.ValidationError, envelope, v.PresentationTier, jsonValue(v.Metadata), v.CreatedAt) - return dbError(err) + return insertArtifact(ctx, tx, v) }) } + +func insertArtifact(ctx context.Context, tx pgx.Tx, value domain.Artifact) error { + var envelope any + if value.Envelope != nil { + envelope = jsonValue(value.Envelope) + } + _, err := tx.Exec(ctx, `INSERT INTO artifacts(id,tenant_id,project_id,script_version_id,approved_snapshot_id,kind,capability_id,capability_version,capability_digest,schema_id,media_type,file_name,sha256,byte_size,object_key,visibility,retention_class,derived_from_artifact_id,purpose,source_device_id,validation_status,validation_error,artifact_envelope,presentation_tier,metadata,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)`, value.ID, value.TenantID, value.ProjectID, nullable(value.ScriptVersionID), nullable(value.ApprovedSnapshotID), value.Kind, value.CapabilityID, value.CapabilityVersion, value.CapabilityDigest, value.SchemaID, value.MediaType, value.FileName, value.SHA256, value.ByteSize, value.ObjectKey, value.Visibility, value.RetentionClass, nullable(value.DerivedFromArtifactID), value.Purpose, nullable(value.SourceDeviceID), value.ValidationStatus, value.ValidationError, envelope, value.PresentationTier, jsonValue(value.Metadata), value.CreatedAt) + return dbError(err) +} func scanArtifact(row pgx.Row) (domain.Artifact, error) { var v domain.Artifact var envelope, metadata []byte - err := row.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.ScriptVersionID, &v.Kind, &v.CapabilityID, &v.CapabilityVersion, &v.CapabilityDigest, &v.SchemaID, &v.MediaType, &v.FileName, &v.SHA256, &v.ByteSize, &v.ObjectKey, &v.Visibility, &v.RetentionClass, &v.DerivedFromArtifactID, &v.Purpose, &v.SourceDeviceID, &v.ValidationStatus, &v.ValidationError, &envelope, &v.PresentationTier, &metadata, &v.CreatedAt) + err := row.Scan(&v.ID, &v.TenantID, &v.ProjectID, &v.ScriptVersionID, &v.ApprovedSnapshotID, &v.Kind, &v.CapabilityID, &v.CapabilityVersion, &v.CapabilityDigest, &v.SchemaID, &v.MediaType, &v.FileName, &v.SHA256, &v.ByteSize, &v.ObjectKey, &v.Visibility, &v.RetentionClass, &v.DerivedFromArtifactID, &v.Purpose, &v.SourceDeviceID, &v.ValidationStatus, &v.ValidationError, &envelope, &v.PresentationTier, &metadata, &v.CreatedAt) if err == nil && len(envelope) > 0 { var value domain.ExtensionArtifactEnvelopeV1 if decodeErr := json.Unmarshal(envelope, &value); decodeErr != nil { @@ -245,7 +295,7 @@ func scanArtifact(row pgx.Row) (domain.Artifact, error) { return v, err } -const artifactSelect = `SELECT id,tenant_id,project_id,script_version_id,kind,capability_id,capability_version,capability_digest,schema_id,media_type,file_name,sha256,byte_size,object_key,visibility,retention_class,COALESCE(derived_from_artifact_id::text,''),purpose,COALESCE(source_device_id::text,''),validation_status,validation_error,artifact_envelope,presentation_tier,metadata,created_at FROM artifacts` +const artifactSelect = `SELECT id,tenant_id,project_id,COALESCE(script_version_id::text,''),COALESCE(approved_snapshot_id::text,''),kind,capability_id,capability_version,capability_digest,schema_id,media_type,file_name,sha256,byte_size,object_key,visibility,retention_class,COALESCE(derived_from_artifact_id::text,''),purpose,COALESCE(source_device_id::text,''),validation_status,validation_error,artifact_envelope,presentation_tier,metadata,created_at FROM artifacts` func (s *Store) Artifacts(ctx context.Context, tenantID, scriptVersionID string) ([]domain.Artifact, error) { out := []domain.Artifact{} @@ -273,6 +323,26 @@ func (s *Store) Artifacts(ctx context.Context, tenantID, scriptVersionID string) }) return out, err } + +func (s *Store) ArtifactsByApprovedSnapshot(ctx context.Context, tenantID, snapshotID string) ([]domain.Artifact, error) { + out := []domain.Artifact{} + err := s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, artifactSelect+` WHERE tenant_id=$1 AND approved_snapshot_id=$2 ORDER BY created_at DESC`, tenantID, snapshotID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + value, err := scanArtifact(rows) + if err != nil { + return err + } + out = append(out, value) + } + return rows.Err() + }) + return out, err +} func (s *Store) Artifact(ctx context.Context, tenantID, id string) (domain.Artifact, error) { var result domain.Artifact err := s.withTenant(ctx, tenantID, func(tx pgx.Tx) error { diff --git a/internal/store/postgres/submissions.go b/internal/store/postgres/submissions.go index 74a60ca..707c1b2 100644 --- a/internal/store/postgres/submissions.go +++ b/internal/store/postgres/submissions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" @@ -103,6 +104,11 @@ func (s *Store) CreateSubmissionRevision(ctx context.Context, submission domain. if result.RowsAffected() == 0 { return domain.NotFound("Submission") } + if _, err := tx.Exec(ctx, `UPDATE review_grants SET revoked_at=$3 + WHERE tenant_id=$1 AND subject_type='submission_revision' AND revoked_at IS NULL AND decision_at IS NULL + AND subject_id IN (SELECT id FROM submission_revisions WHERE tenant_id=$1 AND submission_id=$2 AND id<>$4)`, submission.TenantID, submission.ID, revision.CreatedAt, revision.ID); err != nil { + return dbError(err) + } return nil }) } @@ -258,7 +264,7 @@ func sourceDisclosures(ctx context.Context, tx pgx.Tx, tenantID, revisionID stri func scanApprovedSnapshot(row pgx.Row) (domain.ApprovedSnapshot, error) { var value domain.ApprovedSnapshot var canonical, eligible, artifacts []byte - err := row.Scan(&value.ID, &value.TenantID, &value.ProjectID, &value.WorkspaceID, &value.SubmissionID, &value.SubmissionRevisionID, &value.SubmissionType, &value.SchemaVersion, &value.ContentHash, &value.SubjectHash, &canonical, &eligible, &artifacts, &value.DecisionID, &value.CreatedBy, &value.CreatedAt) + err := row.Scan(&value.ID, &value.TenantID, &value.ProjectID, &value.WorkspaceID, &value.SubmissionID, &value.SubmissionRevisionID, &value.SubmissionType, &value.SchemaVersion, &value.ContentHash, &value.SubjectHash, &canonical, &eligible, &artifacts, &value.DecisionID, &value.CreatedBy, &value.CreatedAt, &value.Origin, &value.ExternalRef) if err != nil { return value, err } @@ -272,7 +278,7 @@ func scanApprovedSnapshot(row pgx.Row) (domain.ApprovedSnapshot, error) { return value, nil } -const approvedSnapshotSelect = `SELECT id,tenant_id,project_id,workspace_id,submission_id,submission_revision_id,submission_type,schema_version,content_hash,subject_hash,canonical_content,eligible_ids,artifacts,decision_id,created_by,created_at FROM approved_snapshots` +const approvedSnapshotSelect = `SELECT id,tenant_id,project_id,COALESCE(workspace_id::text,''),COALESCE(submission_id::text,''),COALESCE(submission_revision_id::text,''),submission_type,schema_version,content_hash,subject_hash,canonical_content,eligible_ids,artifacts,COALESCE(decision_id::text,''),created_by,created_at,origin,COALESCE(external_ref,'') FROM approved_snapshots` func (s *Store) ApprovedSnapshots(ctx context.Context, tenantID, projectID, submissionType string) ([]domain.ApprovedSnapshot, error) { values := []domain.ApprovedSnapshot{} @@ -320,11 +326,11 @@ func (s *Store) ApprovedSnapshot(ctx context.Context, tenantID, id string) (doma func (s *Store) ApproveSubmissionRevision(ctx context.Context, submission domain.Submission, snapshot domain.ApprovedSnapshot, decision domain.ApprovalDecision) error { return s.withTenant(ctx, submission.TenantID, func(tx pgx.Tx) error { - if _, err := tx.Exec(ctx, `INSERT INTO approval_decisions(id,tenant_id,project_id,subject_type,subject_id,subject_hash,actor_id,decision,reason,previous_state,resulting_state,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, decision.ID, decision.TenantID, decision.ProjectID, decision.SubjectType, decision.SubjectID, decision.SubjectHash, decision.ActorID, decision.Decision, decision.Reason, decision.PreviousState, decision.ResultingState, decision.CreatedAt); err != nil { - return dbError(err) + if err := insertApprovalDecision(ctx, tx, decision); err != nil { + return err } - if _, err := tx.Exec(ctx, `INSERT INTO approved_snapshots(id,tenant_id,project_id,workspace_id,submission_id,submission_revision_id,submission_type,schema_version,content_hash,subject_hash,canonical_content,eligible_ids,artifacts,decision_id,created_by,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, snapshot.ID, snapshot.TenantID, snapshot.ProjectID, snapshot.WorkspaceID, snapshot.SubmissionID, snapshot.SubmissionRevisionID, snapshot.SubmissionType, snapshot.SchemaVersion, snapshot.ContentHash, snapshot.SubjectHash, []byte(snapshot.CanonicalContent), jsonValue(snapshot.EligibleIDs), jsonValue(snapshot.Artifacts), snapshot.DecisionID, snapshot.CreatedBy, snapshot.CreatedAt); err != nil { - return dbError(err) + if err := insertApprovedSnapshot(ctx, tx, snapshot); err != nil { + return err } result, err := tx.Exec(ctx, `UPDATE submissions SET status=$3,current_revision_id=$4,updated_at=$5 WHERE tenant_id=$1 AND id=$2`, submission.TenantID, submission.ID, submission.Status, submission.CurrentRevisionID, submission.UpdatedAt) if err != nil { @@ -333,15 +339,34 @@ func (s *Store) ApproveSubmissionRevision(ctx context.Context, submission domain if result.RowsAffected() == 0 { return domain.NotFound("Submission") } + if _, err := tx.Exec(ctx, `UPDATE review_grants SET revoked_at=$3 WHERE tenant_id=$1 AND subject_type='submission_revision' AND subject_id=$2 AND revoked_at IS NULL AND decision_at IS NULL`, submission.TenantID, submission.CurrentRevisionID, decision.CreatedAt); err != nil { + return dbError(err) + } return nil }) } -func (s *Store) RequestSubmissionChanges(ctx context.Context, submission domain.Submission, decision domain.ApprovalDecision, comment domain.ReviewComment) error { +func (s *Store) RecordSubmissionApproval(ctx context.Context, submission domain.Submission, decision domain.ApprovalDecision) error { return s.withTenant(ctx, submission.TenantID, func(tx pgx.Tx) error { - if _, err := tx.Exec(ctx, `INSERT INTO approval_decisions(id,tenant_id,project_id,subject_type,subject_id,subject_hash,actor_id,decision,reason,previous_state,resulting_state,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, decision.ID, decision.TenantID, decision.ProjectID, decision.SubjectType, decision.SubjectID, decision.SubjectHash, decision.ActorID, decision.Decision, decision.Reason, decision.PreviousState, decision.ResultingState, decision.CreatedAt); err != nil { + if err := insertApprovalDecision(ctx, tx, decision); err != nil { + return err + } + result, err := tx.Exec(ctx, `UPDATE submissions SET status=$3,current_revision_id=$4,updated_at=$5 WHERE tenant_id=$1 AND id=$2 AND current_revision_id=$4`, submission.TenantID, submission.ID, submission.Status, submission.CurrentRevisionID, submission.UpdatedAt) + if err != nil { return dbError(err) } + if result.RowsAffected() == 0 { + return domain.Conflict("SUBMISSION_STATE_INVALID", "Submission 当前版本已变化") + } + return nil + }) +} + +func (s *Store) RequestSubmissionChanges(ctx context.Context, submission domain.Submission, decision domain.ApprovalDecision, comment domain.ReviewComment) error { + return s.withTenant(ctx, submission.TenantID, func(tx pgx.Tx) error { + if err := insertApprovalDecision(ctx, tx, decision); err != nil { + return err + } if _, err := tx.Exec(ctx, `INSERT INTO review_comments(id,tenant_id,project_id,review_cycle_id,subject_type,subject_id,carried_from_comment_id,shot_id,json_pointer,body,visibility,author_id,resolved_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, comment.ID, comment.TenantID, comment.ProjectID, comment.ReviewCycleID, comment.SubjectType, comment.SubjectID, nullable(comment.CarriedFromID), comment.ShotID, comment.JSONPointer, comment.Body, comment.Visibility, comment.AuthorID, comment.ResolvedAt, comment.CreatedAt); err != nil { return dbError(err) } @@ -352,10 +377,80 @@ func (s *Store) RequestSubmissionChanges(ctx context.Context, submission domain. if result.RowsAffected() == 0 { return domain.NotFound("Submission") } + if _, err := tx.Exec(ctx, `UPDATE review_grants SET revoked_at=$3 WHERE tenant_id=$1 AND subject_type='submission_revision' AND subject_id=$2 AND revoked_at IS NULL AND decision_at IS NULL`, submission.TenantID, submission.CurrentRevisionID, decision.CreatedAt); err != nil { + return dbError(err) + } + return nil + }) +} + +func (s *Store) CreateSubmissionReviewGrant(ctx context.Context, submission domain.Submission, grant domain.ReviewGrant) error { + return s.withTenant(ctx, submission.TenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `INSERT INTO review_grants(id,tenant_id,project_id,subject_type,subject_id,subject_hash,reviewer_email,token_hash,otp_hash,expires_at,verified_at,revoked_at,decision_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, grant.ID, grant.TenantID, grant.ProjectID, grant.SubjectType, grant.SubjectID, grant.SubjectHash, grant.ReviewerEmail, grant.TokenHash, grant.OTPHash, grant.ExpiresAt, grant.VerifiedAt, grant.RevokedAt, grant.DecisionAt, grant.CreatedAt); err != nil { + return dbError(err) + } + result, err := tx.Exec(ctx, `UPDATE submissions SET status=$3,updated_at=$4 WHERE tenant_id=$1 AND id=$2 AND current_revision_id=$5 AND status IN ('internally_approved','client_review')`, submission.TenantID, submission.ID, submission.Status, submission.UpdatedAt, submission.CurrentRevisionID) + if err != nil { + return dbError(err) + } + if result.RowsAffected() == 0 { + return domain.Conflict("SUBMISSION_STATE_INVALID", "Submission 当前版本或状态已变化") + } + return nil + }) +} + +func (s *Store) CompleteSubmissionClientReview(ctx context.Context, submission domain.Submission, grant domain.ReviewGrant, decision domain.ApprovalDecision, comment *domain.ReviewComment, snapshot *domain.ApprovedSnapshot) error { + return s.withTenant(ctx, submission.TenantID, func(tx pgx.Tx) error { + var revokedAt, decisionAt *time.Time + var expiresAt time.Time + if err := tx.QueryRow(ctx, `SELECT revoked_at,decision_at,expires_at FROM review_grants WHERE tenant_id=$1 AND id=$2 FOR UPDATE`, grant.TenantID, grant.ID).Scan(&revokedAt, &decisionAt, &expiresAt); err != nil { + return dbError(err) + } + if revokedAt != nil || decisionAt != nil || !grant.DecisionAt.Before(expiresAt) { + return domain.Conflict("REVIEW_ALREADY_DECIDED", "该审批链接已失效或已完成最终决策") + } + if err := insertApprovalDecision(ctx, tx, decision); err != nil { + return err + } + if comment != nil { + if _, err := tx.Exec(ctx, `INSERT INTO review_comments(id,tenant_id,project_id,review_cycle_id,subject_type,subject_id,carried_from_comment_id,shot_id,json_pointer,body,visibility,author_id,resolved_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, comment.ID, comment.TenantID, comment.ProjectID, nullable(comment.ReviewCycleID), comment.SubjectType, comment.SubjectID, nullable(comment.CarriedFromID), comment.ShotID, comment.JSONPointer, comment.Body, comment.Visibility, comment.AuthorID, comment.ResolvedAt, comment.CreatedAt); err != nil { + return dbError(err) + } + } + if snapshot != nil { + if err := insertApprovedSnapshot(ctx, tx, *snapshot); err != nil { + return err + } + } + if _, err := tx.Exec(ctx, `UPDATE review_grants SET verified_at=$3,decision_at=$4 WHERE tenant_id=$1 AND id=$2`, grant.TenantID, grant.ID, grant.VerifiedAt, grant.DecisionAt); err != nil { + return dbError(err) + } + result, err := tx.Exec(ctx, `UPDATE submissions SET status=$3,updated_at=$4 WHERE tenant_id=$1 AND id=$2 AND current_revision_id=$5 AND status='client_review'`, submission.TenantID, submission.ID, submission.Status, submission.UpdatedAt, submission.CurrentRevisionID) + if err != nil { + return dbError(err) + } + if result.RowsAffected() == 0 { + return domain.Conflict("REVIEW_SUBJECT_CHANGED", "审批对象已失效或状态已变化") + } return nil }) } +func insertApprovalDecision(ctx context.Context, tx pgx.Tx, decision domain.ApprovalDecision) error { + _, err := tx.Exec(ctx, `INSERT INTO approval_decisions(id,tenant_id,project_id,subject_type,subject_id,subject_hash,decision_stage,actor_id,decision,reason,previous_state,resulting_state,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, decision.ID, decision.TenantID, decision.ProjectID, decision.SubjectType, decision.SubjectID, decision.SubjectHash, defaultDecisionStage(decision.DecisionStage), decision.ActorID, decision.Decision, decision.Reason, decision.PreviousState, decision.ResultingState, decision.CreatedAt) + return dbError(err) +} + +func insertApprovedSnapshot(ctx context.Context, tx pgx.Tx, snapshot domain.ApprovedSnapshot) error { + origin := snapshot.Origin + if origin == "" { + origin = "current" + } + _, err := tx.Exec(ctx, `INSERT INTO approved_snapshots(id,tenant_id,project_id,workspace_id,submission_id,submission_revision_id,submission_type,schema_version,content_hash,subject_hash,canonical_content,eligible_ids,artifacts,decision_id,created_by,created_at,origin,external_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, snapshot.ID, snapshot.TenantID, snapshot.ProjectID, nullable(snapshot.WorkspaceID), nullable(snapshot.SubmissionID), nullable(snapshot.SubmissionRevisionID), snapshot.SubmissionType, snapshot.SchemaVersion, snapshot.ContentHash, snapshot.SubjectHash, []byte(snapshot.CanonicalContent), jsonValue(snapshot.EligibleIDs), jsonValue(snapshot.Artifacts), nullable(snapshot.DecisionID), snapshot.CreatedBy, snapshot.CreatedAt, origin, nullable(snapshot.ExternalRef)) + return dbError(err) +} + func nullableJSON(value json.RawMessage) any { if len(value) == 0 { return nil diff --git a/internal/store/postgres/submissions_integration_test.go b/internal/store/postgres/submissions_integration_test.go index 8d6fd85..063c525 100644 --- a/internal/store/postgres/submissions_integration_test.go +++ b/internal/store/postgres/submissions_integration_test.go @@ -9,11 +9,13 @@ import ( "reflect" "strings" "testing" + "time" "github.com/jackc/pgx/v5" "github.com/limecloud/contentcloud/internal/app" "github.com/limecloud/contentcloud/internal/domain" + "github.com/limecloud/contentcloud/internal/localworkspace" storepg "github.com/limecloud/contentcloud/internal/store/postgres" ) @@ -114,10 +116,14 @@ func TestV2WorkspaceSubmissionGovernanceWithPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - snapshot, err := service.ApproveSubmission(ctx, admin, secondRevision.ID, "事实与来源披露已审核", "req-v2-approve") + approval, err := service.ApproveSubmission(ctx, admin, secondRevision.ID, "事实与来源披露已审核", "req-v2-approve") if err != nil { t.Fatal(err) } + if approval.ApprovedSnapshot == nil { + t.Fatal("knowledge approval must create an ApprovedSnapshot") + } + snapshot := *approval.ApprovedSnapshot persistedSnapshot, err := store.ApprovedSnapshot(ctx, admin.TenantID, snapshot.ID) if err != nil || persistedSnapshot.SubmissionRevisionID != secondRevision.ID || persistedSnapshot.ContentHash != secondRevision.ContentHash { t.Fatalf("approval snapshot transaction failed: snapshot=%#v err=%v", persistedSnapshot, err) @@ -127,6 +133,44 @@ func TestV2WorkspaceSubmissionGovernanceWithPostgres(t *testing.T) { t.Fatalf("approved submission state was not committed atomically: submission=%#v err=%v", persistedSubmission, err) } + scriptBundle := postgresScriptSubmissionBundle(t, project.ID, binding.ID, "postgres-script-v1", "postgres-script") + scriptRevision, err := service.CreateSubmission(ctx, workspaceActor, binding, scriptBundle, "req-script-publish") + if err != nil { + t.Fatal(err) + } + internalApproval, err := service.ApproveSubmission(ctx, admin, scriptRevision.ID, "script internal approval", "req-script-internal") + if err != nil || internalApproval.ApprovedSnapshot != nil || internalApproval.Submission.Status != "internally_approved" { + t.Fatalf("script internal approval did not stop before snapshot creation: %#v err=%v", internalApproval, err) + } + grant, err := service.CreateReviewGrant(ctx, admin, scriptRevision.ID, "postgres-client@example.com", "req-script-grant") + if err != nil { + t.Fatal(err) + } + if _, err := service.VerifyReviewGrant(ctx, grant.PlaintextToken, grant.PlaintextOTP); err != nil { + t.Fatal(err) + } + clientApproval, err := service.DecideReviewGrant(ctx, grant.PlaintextToken, "approve", "client approved", "", "req-script-client") + if err != nil || clientApproval.ApprovedSnapshot == nil { + t.Fatalf("client approval did not atomically create snapshot: %#v err=%v", clientApproval, err) + } + scriptSnapshot := *clientApproval.ApprovedSnapshot + persistedScriptSnapshot, err := store.ApprovedSnapshot(ctx, admin.TenantID, scriptSnapshot.ID) + if err != nil || persistedScriptSnapshot.CreatedBy != "client:postgres-client@example.com" || persistedScriptSnapshot.Origin != "current" { + t.Fatalf("client snapshot was not persisted with current lineage: %#v err=%v", persistedScriptSnapshot, err) + } + delivery, err := service.CreateDeliveryPackage(ctx, admin, scriptSnapshot.ID, "postgres-script", "req-delivery") + if err != nil || len(delivery.Manifest) != 3 { + t.Fatalf("delivery package transaction failed: %#v err=%v", delivery, err) + } + persistedDelivery, err := store.DeliveryPackage(ctx, admin.TenantID, delivery.ID) + if err != nil || len(persistedDelivery.Manifest) != 3 || persistedDelivery.ApprovedSnapshotIDs[0] != scriptSnapshot.ID { + t.Fatalf("delivery relations were not persisted: %#v err=%v", persistedDelivery, err) + } + performance, err := service.ImportPerformanceObservations(ctx, admin, app.ImportPerformanceInput{ProjectID: project.ID, SourceName: "postgres-results.csv", SourceFormat: "csv", Observations: []app.CreateObservationInput{{RowNumber: 2, ApprovedSnapshotID: scriptSnapshot.ID, Platform: "douyin", AccountAlias: "brand-main", PublishedAt: time.Now().UTC().Add(-24 * time.Hour), WindowHours: 24, SampleStatus: "seed_candidate", Metrics: map[string]float64{"impressions": 1000}, Currency: "CNY", Spend: 100, GMV: 300, IssueCategory: "creative"}}}, "req-results") + if err != nil || performance.Observations[0].ApprovedSnapshotID != scriptSnapshot.ID { + t.Fatalf("performance observation did not bind the snapshot: %#v err=%v", performance, err) + } + otherSession, err := service.Register(ctx, fmt.Sprintf("submission-other-%s@example.com", suffix), "long-enough-password", "Other Owner", "Other Tenant "+suffix) if err != nil { t.Fatal(err) @@ -164,6 +208,20 @@ func TestV2WorkspaceSubmissionGovernanceWithPostgres(t *testing.T) { } } +func postgresScriptSubmissionBundle(t *testing.T, projectID, workspaceID, idempotencyKey, scriptID string) domain.SubmissionBundle { + t.Helper() + pkg := localworkspace.ScriptPackageV2{ID: scriptID, Kind: "script_package", Status: "review_ready", SchemaVersion: "2.0", Deliverability: "review_ready", ProjectID: projectID, ScriptID: scriptID, Title: "PostgreSQL script", Channel: "douyin", DurationMS: 15000, AspectRatio: "9:16", Shots: []localworkspace.ScriptShotV2{}, Citations: []localworkspace.ScriptCitationV2{}, AssetRequirements: []localworkspace.ScriptAssetRequirement{}, BlockedReasons: []localworkspace.ScriptBlockedReason{}, MissingInputs: []string{}} + objects, err := json.Marshal([]localworkspace.ScriptPackageV2{pkg}) + if err != nil { + t.Fatal(err) + } + bundle := domain.SubmissionBundle{BundleVersion: "1.0", SchemaVersion: localworkspace.ScriptPackageV2Schema, SubmissionType: "script", ProjectID: projectID, WorkspaceID: workspaceID, Objects: objects, SourceDisclosures: []domain.SourceDisclosure{}, Artifacts: []domain.SubmissionArtifact{}, LocalRunSummary: domain.LocalRunSummary{Checks: []domain.LocalRunCheck{{Name: "script-lint", Status: "passed"}}}, IdempotencyKey: idempotencyKey} + if err := bundle.SetComputedHash(); err != nil { + t.Fatal(err) + } + return bundle +} + func postgresSubmissionBundle(t *testing.T, projectID, workspaceID, idempotencyKey, factID string) domain.SubmissionBundle { t.Helper() bundle := domain.SubmissionBundle{ diff --git a/internal/store/store.go b/internal/store/store.go index 3feedf5..58dfdd7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -151,7 +151,9 @@ type Store interface { ReviewGrant(context.Context, string, string) (domain.ReviewGrant, error) ReviewGrants(context.Context, string, string) ([]domain.ReviewGrant, error) ReviewGrantByTokenHash(context.Context, string) (domain.ReviewGrant, error) - SaveReviewGrant(context.Context, domain.ReviewGrant) error + MarkReviewGrantVerified(context.Context, string, string, time.Time) error + RevokeReviewGrant(context.Context, string, string, time.Time) error + CompleteLegacyClientReview(context.Context, domain.ScriptVersion, domain.ReviewGrant, domain.ApprovalDecision, *domain.ReviewComment) error CreateSubmissionRevision(context.Context, domain.Submission, domain.SubmissionRevision, []domain.SourceDisclosure, domain.ReviewCycle) error SubmissionByWorkspaceType(context.Context, string, string, string, string) (domain.Submission, error) @@ -161,12 +163,19 @@ type Store interface { SubmissionRevisions(context.Context, string, string) ([]domain.SubmissionRevision, error) ApprovedSnapshots(context.Context, string, string, string) ([]domain.ApprovedSnapshot, error) ApprovedSnapshot(context.Context, string, string) (domain.ApprovedSnapshot, error) + RecordSubmissionApproval(context.Context, domain.Submission, domain.ApprovalDecision) error ApproveSubmissionRevision(context.Context, domain.Submission, domain.ApprovedSnapshot, domain.ApprovalDecision) error RequestSubmissionChanges(context.Context, domain.Submission, domain.ApprovalDecision, domain.ReviewComment) error + CreateSubmissionReviewGrant(context.Context, domain.Submission, domain.ReviewGrant) error + CompleteSubmissionClientReview(context.Context, domain.Submission, domain.ReviewGrant, domain.ApprovalDecision, *domain.ReviewComment, *domain.ApprovedSnapshot) error CreateArtifact(context.Context, domain.Artifact) error Artifacts(context.Context, string, string) ([]domain.Artifact, error) + ArtifactsByApprovedSnapshot(context.Context, string, string) ([]domain.Artifact, error) Artifact(context.Context, string, string) (domain.Artifact, error) + CreateDeliveryPackage(context.Context, domain.DeliveryPackage, []domain.Artifact) error + DeliveryPackages(context.Context, string, string) ([]domain.DeliveryPackage, error) + DeliveryPackage(context.Context, string, string) (domain.DeliveryPackage, error) CreateArtifactOpenRequest(context.Context, domain.ArtifactOpenRequest) error ArtifactOpenRequest(context.Context, string, string) (domain.ArtifactOpenRequest, error) PendingArtifactOpenRequests(context.Context, string, string, time.Time, int) ([]domain.ArtifactOpenRequest, error) diff --git a/migrations/00014_v2_single_track_delivery.sql b/migrations/00014_v2_single_track_delivery.sql new file mode 100644 index 0000000..16a559f --- /dev/null +++ b/migrations/00014_v2_single_track_delivery.sql @@ -0,0 +1,175 @@ +-- +goose Up +ALTER TABLE approval_decisions + ADD COLUMN decision_stage text NOT NULL DEFAULT 'legacy' + CHECK (decision_stage IN ('internal','client','legacy')); + +ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_status_check; +ALTER TABLE submissions ADD CONSTRAINT submissions_status_check + CHECK (status IN ('preparing','submitted','in_review','internally_approved','client_review','changes_requested','approved','rejected','withdrawn','superseded')); + +ALTER TABLE approved_snapshots + ADD COLUMN origin text NOT NULL DEFAULT 'current' CHECK (origin IN ('current','v1_import')), + ADD COLUMN external_ref text; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_submission_revision_id_key; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_workspace_id_fkey; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_submission_id_fkey; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_submission_revision_id_fkey; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_decision_id_fkey; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_created_by_fkey; +ALTER TABLE approved_snapshots + ALTER COLUMN workspace_id DROP NOT NULL, + ALTER COLUMN submission_id DROP NOT NULL, + ALTER COLUMN submission_revision_id DROP NOT NULL, + ALTER COLUMN decision_id DROP NOT NULL, + ALTER COLUMN created_by TYPE text USING created_by::text; +ALTER TABLE approved_snapshots + ADD CONSTRAINT approved_snapshots_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspace_bindings(id), + ADD CONSTRAINT approved_snapshots_submission_id_fkey FOREIGN KEY (submission_id) REFERENCES submissions(id), + ADD CONSTRAINT approved_snapshots_submission_revision_id_fkey FOREIGN KEY (submission_revision_id) REFERENCES submission_revisions(id), + ADD CONSTRAINT approved_snapshots_decision_id_fkey FOREIGN KEY (decision_id) REFERENCES approval_decisions(id), + ADD CONSTRAINT approved_snapshots_origin_shape_check CHECK ( + (origin='current' AND workspace_id IS NOT NULL AND submission_id IS NOT NULL AND submission_revision_id IS NOT NULL AND decision_id IS NOT NULL AND external_ref IS NULL) + OR (origin='v1_import' AND workspace_id IS NULL AND submission_id IS NULL AND submission_revision_id IS NULL AND external_ref IS NOT NULL) + ); +CREATE UNIQUE INDEX approved_snapshots_current_revision_unique + ON approved_snapshots(submission_revision_id) WHERE origin='current'; +CREATE UNIQUE INDEX approved_snapshots_v1_external_ref_unique + ON approved_snapshots(tenant_id,external_ref) WHERE origin='v1_import'; + +ALTER TABLE artifacts + ADD COLUMN approved_snapshot_id uuid REFERENCES approved_snapshots(id), + ALTER COLUMN script_version_id DROP NOT NULL; +ALTER TABLE artifacts ADD CONSTRAINT artifacts_version_source_check + CHECK ((script_version_id IS NOT NULL) <> (approved_snapshot_id IS NOT NULL)); +CREATE INDEX artifacts_approved_snapshot_idx ON artifacts(tenant_id,approved_snapshot_id,created_at DESC); + +ALTER TABLE performance_observations + ADD COLUMN approved_snapshot_id uuid REFERENCES approved_snapshots(id), + ALTER COLUMN script_version_id DROP NOT NULL; +ALTER TABLE performance_observations ADD CONSTRAINT performance_observations_version_source_check + CHECK ((script_version_id IS NOT NULL) <> (approved_snapshot_id IS NOT NULL)); +CREATE INDEX performance_observations_snapshot_idx ON performance_observations(tenant_id,approved_snapshot_id,published_at DESC); + +ALTER TABLE rating_decisions DROP CONSTRAINT IF EXISTS rating_decisions_subject_type_check; +ALTER TABLE rating_decisions ADD CONSTRAINT rating_decisions_subject_type_check + CHECK (subject_type IN ('approved_snapshot','script_version','content_framework','shot_pattern')); + +CREATE TABLE delivery_packages ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenants(id), + project_id uuid NOT NULL REFERENCES brand_projects(id), + script_id text NOT NULL CHECK (char_length(btrim(script_id)) > 0), + status text NOT NULL CHECK (status = 'ready'), + created_by text NOT NULL, + created_at timestamptz NOT NULL +); + +CREATE TABLE delivery_package_snapshots ( + tenant_id uuid NOT NULL REFERENCES tenants(id), + delivery_package_id uuid NOT NULL REFERENCES delivery_packages(id), + approved_snapshot_id uuid NOT NULL REFERENCES approved_snapshots(id), + position integer NOT NULL CHECK (position >= 0), + PRIMARY KEY (delivery_package_id,approved_snapshot_id) +); + +CREATE TABLE delivery_package_artifacts ( + tenant_id uuid NOT NULL REFERENCES tenants(id), + delivery_package_id uuid NOT NULL REFERENCES delivery_packages(id), + artifact_id uuid NOT NULL REFERENCES artifacts(id), + position integer NOT NULL CHECK (position >= 0), + PRIMARY KEY (delivery_package_id,artifact_id) +); + +CREATE INDEX delivery_packages_project_idx ON delivery_packages(tenant_id,project_id,created_at DESC); +CREATE INDEX delivery_package_snapshots_snapshot_idx ON delivery_package_snapshots(tenant_id,approved_snapshot_id); + +ALTER TABLE delivery_packages ENABLE ROW LEVEL SECURITY; +ALTER TABLE delivery_package_snapshots ENABLE ROW LEVEL SECURITY; +ALTER TABLE delivery_package_artifacts ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_delivery_packages ON delivery_packages + USING (tenant_id=current_setting('app.tenant_id',true)::uuid) + WITH CHECK (tenant_id=current_setting('app.tenant_id',true)::uuid); +CREATE POLICY tenant_delivery_package_snapshots ON delivery_package_snapshots + USING (tenant_id=current_setting('app.tenant_id',true)::uuid) + WITH CHECK (tenant_id=current_setting('app.tenant_id',true)::uuid); +CREATE POLICY tenant_delivery_package_artifacts ON delivery_package_artifacts + USING (tenant_id=current_setting('app.tenant_id',true)::uuid) + WITH CHECK (tenant_id=current_setting('app.tenant_id',true)::uuid); + +CREATE TRIGGER delivery_packages_immutable BEFORE UPDATE OR DELETE ON delivery_packages + FOR EACH ROW EXECUTE FUNCTION contentcloud_reject_v2_immutable_mutation(); +CREATE TRIGGER delivery_package_snapshots_immutable BEFORE UPDATE OR DELETE ON delivery_package_snapshots + FOR EACH ROW EXECUTE FUNCTION contentcloud_reject_v2_immutable_mutation(); +CREATE TRIGGER delivery_package_artifacts_immutable BEFORE UPDATE OR DELETE ON delivery_package_artifacts + FOR EACH ROW EXECUTE FUNCTION contentcloud_reject_v2_immutable_mutation(); + +INSERT INTO approved_snapshots( + id,tenant_id,project_id,workspace_id,submission_id,submission_revision_id, + submission_type,schema_version,content_hash,subject_hash,canonical_content, + eligible_ids,artifacts,decision_id,created_by,created_at,origin,external_ref +) +SELECT + (substr(md5('v1:'||sv.id::text),1,8)||'-'||substr(md5('v1:'||sv.id::text),9,4)||'-'||substr(md5('v1:'||sv.id::text),13,4)||'-'||substr(md5('v1:'||sv.id::text),17,4)||'-'||substr(md5('v1:'||sv.id::text),21,12))::uuid, + sv.tenant_id,sv.project_id,NULL,NULL,NULL,'script','1.x',sv.content_hash,sv.content_hash, + jsonb_build_object('schema_version','1.x','submission_type','script','objects',jsonb_build_array(sv.canonical_json),'source_disclosures','[]'::jsonb,'artifacts','[]'::jsonb,'local_run_summary','{}'::jsonb), + jsonb_build_array(sv.id::text),'[]'::jsonb, + (SELECT ad.id FROM approval_decisions ad WHERE ad.tenant_id=sv.tenant_id AND ad.subject_id=sv.id AND ad.decision='approve' ORDER BY ad.created_at DESC LIMIT 1), + COALESCE((SELECT ad.actor_id FROM approval_decisions ad WHERE ad.tenant_id=sv.tenant_id AND ad.subject_id=sv.id AND ad.decision='approve' ORDER BY ad.created_at DESC LIMIT 1),'v1_import'), + sv.created_at,'v1_import',sv.id::text +FROM script_versions sv +WHERE sv.status IN ('approved','superseded') + AND sv.content_hash ~ '^[0-9a-f]{64}$' +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TRIGGER IF EXISTS delivery_package_artifacts_immutable ON delivery_package_artifacts; +DROP TRIGGER IF EXISTS delivery_package_snapshots_immutable ON delivery_package_snapshots; +DROP TRIGGER IF EXISTS delivery_packages_immutable ON delivery_packages; +DROP TABLE IF EXISTS delivery_package_artifacts; +DROP TABLE IF EXISTS delivery_package_snapshots; +DROP TABLE IF EXISTS delivery_packages; + +ALTER TABLE rating_decisions DROP CONSTRAINT IF EXISTS rating_decisions_subject_type_check; +ALTER TABLE rating_decisions ADD CONSTRAINT rating_decisions_subject_type_check + CHECK (subject_type IN ('script_version','content_framework','shot_pattern')); + +DROP TRIGGER IF EXISTS performance_observations_immutable ON performance_observations; +DELETE FROM performance_observations WHERE script_version_id IS NULL; +ALTER TABLE performance_observations DROP CONSTRAINT IF EXISTS performance_observations_version_source_check; +DROP INDEX IF EXISTS performance_observations_snapshot_idx; +ALTER TABLE performance_observations DROP COLUMN IF EXISTS approved_snapshot_id; +ALTER TABLE performance_observations ALTER COLUMN script_version_id SET NOT NULL; +CREATE TRIGGER performance_observations_immutable BEFORE UPDATE OR DELETE ON performance_observations + FOR EACH ROW EXECUTE FUNCTION contentcloud_reject_performance_mutation(); + +DELETE FROM artifacts WHERE script_version_id IS NULL; +ALTER TABLE artifacts DROP CONSTRAINT IF EXISTS artifacts_version_source_check; +DROP INDEX IF EXISTS artifacts_approved_snapshot_idx; +ALTER TABLE artifacts DROP COLUMN IF EXISTS approved_snapshot_id; +ALTER TABLE artifacts ALTER COLUMN script_version_id SET NOT NULL; + +DROP TRIGGER IF EXISTS approved_snapshots_immutable ON approved_snapshots; +DELETE FROM approved_snapshots WHERE origin='v1_import' OR created_by !~ '^[0-9a-fA-F-]{36}$'; +DROP INDEX IF EXISTS approved_snapshots_v1_external_ref_unique; +DROP INDEX IF EXISTS approved_snapshots_current_revision_unique; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_origin_shape_check; +ALTER TABLE approved_snapshots DROP CONSTRAINT IF EXISTS approved_snapshots_created_by_fkey; +ALTER TABLE approved_snapshots + ALTER COLUMN workspace_id SET NOT NULL, + ALTER COLUMN submission_id SET NOT NULL, + ALTER COLUMN submission_revision_id SET NOT NULL, + ALTER COLUMN decision_id SET NOT NULL, + ALTER COLUMN created_by TYPE uuid USING created_by::uuid; +ALTER TABLE approved_snapshots + ADD CONSTRAINT approved_snapshots_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id), + ADD CONSTRAINT approved_snapshots_submission_revision_id_key UNIQUE (submission_revision_id); +ALTER TABLE approved_snapshots DROP COLUMN IF EXISTS external_ref; +ALTER TABLE approved_snapshots DROP COLUMN IF EXISTS origin; +CREATE TRIGGER approved_snapshots_immutable BEFORE UPDATE OR DELETE ON approved_snapshots + FOR EACH ROW EXECUTE FUNCTION contentcloud_reject_v2_immutable_mutation(); + +UPDATE submissions SET status='submitted' WHERE status IN ('internally_approved','client_review'); +ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_status_check; +ALTER TABLE submissions ADD CONSTRAINT submissions_status_check + CHECK (status IN ('preparing','submitted','in_review','changes_requested','approved','rejected','withdrawn','superseded')); +ALTER TABLE approval_decisions DROP COLUMN IF EXISTS decision_stage; diff --git a/migrations/00015_v1_shadow_backfill_report.sql b/migrations/00015_v1_shadow_backfill_report.sql new file mode 100644 index 0000000..e19ddd2 --- /dev/null +++ b/migrations/00015_v1_shadow_backfill_report.sql @@ -0,0 +1,47 @@ +-- +goose Up +CREATE OR REPLACE FUNCTION contentcloud_backfill_v1_approved_snapshots(p_tenant_id uuid DEFAULT NULL) +RETURNS jsonb +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + inserted_count integer; + skipped_invalid_hash_count integer; +BEGIN + SELECT count(*) INTO skipped_invalid_hash_count + FROM script_versions sv + WHERE sv.status IN ('approved','superseded') + AND (p_tenant_id IS NULL OR sv.tenant_id=p_tenant_id) + AND sv.content_hash !~ '^[0-9a-f]{64}$'; + + INSERT INTO approved_snapshots( + id,tenant_id,project_id,workspace_id,submission_id,submission_revision_id, + submission_type,schema_version,content_hash,subject_hash,canonical_content, + eligible_ids,artifacts,decision_id,created_by,created_at,origin,external_ref + ) + SELECT + (substr(md5('v1:'||sv.id::text),1,8)||'-'||substr(md5('v1:'||sv.id::text),9,4)||'-'||substr(md5('v1:'||sv.id::text),13,4)||'-'||substr(md5('v1:'||sv.id::text),17,4)||'-'||substr(md5('v1:'||sv.id::text),21,12))::uuid, + sv.tenant_id,sv.project_id,NULL,NULL,NULL,'script','1.x',sv.content_hash,sv.content_hash, + jsonb_build_object('schema_version','1.x','submission_type','script','objects',jsonb_build_array(sv.canonical_json),'source_disclosures','[]'::jsonb,'artifacts','[]'::jsonb,'local_run_summary','{}'::jsonb), + jsonb_build_array(sv.id::text),'[]'::jsonb, + (SELECT ad.id FROM approval_decisions ad WHERE ad.tenant_id=sv.tenant_id AND ad.subject_id=sv.id AND ad.decision='approve' ORDER BY ad.created_at DESC LIMIT 1), + COALESCE((SELECT ad.actor_id FROM approval_decisions ad WHERE ad.tenant_id=sv.tenant_id AND ad.subject_id=sv.id AND ad.decision='approve' ORDER BY ad.created_at DESC LIMIT 1),'v1_import'), + sv.created_at,'v1_import',sv.id::text + FROM script_versions sv + WHERE sv.status IN ('approved','superseded') + AND (p_tenant_id IS NULL OR sv.tenant_id=p_tenant_id) + AND sv.content_hash ~ '^[0-9a-f]{64}$' + ON CONFLICT DO NOTHING; + + GET DIAGNOSTICS inserted_count = ROW_COUNT; + RETURN jsonb_build_object( + 'inserted', inserted_count, + 'skipped_invalid_hash', skipped_invalid_hash_count + ); +END; +$$; + +SELECT contentcloud_backfill_v1_approved_snapshots(); + +-- +goose Down +DROP FUNCTION IF EXISTS contentcloud_backfill_v1_approved_snapshots(uuid); diff --git a/package.json b/package.json index 3599143..9d422bf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@limecloud/contentcloud-workspace", "private": true, - "version": "0.3.0", + "version": "0.4.0", "packageManager": "pnpm@10.8.1", "scripts": { "dev:web": "pnpm --dir web dev", diff --git a/packages/contentcloud/package.json b/packages/contentcloud/package.json index e96f15b..8813ef3 100644 --- a/packages/contentcloud/package.json +++ b/packages/contentcloud/package.json @@ -1,7 +1,7 @@ { "name": "@limecloud/contentcloud", - "version": "0.3.0", - "contentcloudReleaseTag": "v0.3.0", + "version": "0.4.0", + "contentcloudReleaseTag": "v0.4.0", "description": "Verified installer and launcher for the ContentCloud Go CLI", "license": "Apache-2.0", "type": "module", diff --git a/scripts/append-refinements.mjs b/scripts/append-refinements.mjs new file mode 100644 index 0000000..c9f0da4 --- /dev/null +++ b/scripts/append-refinements.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/* + * 把视觉重构第 4 阶段(组件精修)追加到 styles.css 末尾。 + * + * 原文件是压缩过的单体 CSS,直接在里面改可读性很差;精修集中成一段 + * 带注释的普通 CSS 放在末尾,同特异性下后写的生效,意图也留得下来。 + * + * 幂等:重复执行会先删掉上一次追加的块再重写。 + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const CSS_PATH = new URL('../web/src/styles.css', import.meta.url); +const START = '/* === 设计体系精修 (redesign) === */'; +const END = '/* === 设计体系精修 end === */'; + +const BLOCK = ` +${START} + +/* + * 内容卡片统一一层极轻阴影,从冷灰底色上浮起来 —— 只给「读的容器」, + * 控件(按钮/下拉/搜索框)保持纯描边,避免整页都在发光。 + */ +.stat-grid, +.project-facts, +.gate-summary, +.brief-item, +.version-list, +.script-detail, +.public-panel, +.verify-panel, +.result-summary, +.lineage-summary, +.submission-summary, +.admin-stat-grid, +.compact-stats { + box-shadow: var(--shadow-card); +} + +/* 弹层用更强的一档,和内容层拉开距离 */ +.modal, +.tenant-menu, +.project-menu { + box-shadow: var(--shadow-pop); +} + +/* + * 蓝色(--accent)只留给可交互元素。原设计里它同时是装饰色, + * 满页蓝字会让真正能点的东西失去信号价值。 + */ +.nav-item.active, +.nav-item:hover { + color: #fff; +} + +/* 主按钮:hover 加深 + 极轻抬起,给出可点的物理感 */ +.button-primary { + box-shadow: var(--shadow-card); + transition: background-color .15s ease, box-shadow .15s ease; +} +.button-primary:hover:not(:disabled) { + background: var(--accent-dark); +} + +/* 次级按钮描边收细,和卡片描边同一套灰 */ +.button-secondary { + transition: background-color .15s ease, border-color .15s ease; +} +.button-secondary:hover:not(:disabled) { + background: var(--soft); + border-color: var(--line); +} + +/* + * 表格/列表行的 hover 反馈统一到 --soft,原来散落着好几种近似灰。 + */ +.project-row:hover, +.team-member-row:hover, +.brief-item:hover { + background: var(--soft); +} + +/* 焦点环统一:双层(底色留空 + 蓝环),键盘可达性一致 */ +:where(button, a, input, select, textarea, [tabindex]):focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--accent); +} + +${END} +`; + +let css = readFileSync(CSS_PATH, 'utf8'); +const s = css.indexOf(START); +if (s !== -1) { + const e = css.indexOf(END, s); + css = css.slice(0, s).trimEnd() + (e === -1 ? '' : css.slice(e + END.length)); +} +writeFileSync(CSS_PATH, css.trimEnd() + '\n' + BLOCK); +console.log('已追加设计体系精修块'); diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..5db47bf --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -f "${ROOT_DIR}/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "${ROOT_DIR}/.env" + set +a +fi + +BACKEND_HOST="${CONTENTCLOUD_DEV_BACKEND_HOST:-127.0.0.1}" +BACKEND_PORT="${CONTENTCLOUD_DEV_BACKEND_PORT:-8080}" +FRONTEND_HOST="${CONTENTCLOUD_DEV_FRONTEND_HOST:-0.0.0.0}" +FRONTEND_PORT="${CONTENTCLOUD_DEV_FRONTEND_PORT:-5173}" +KILL_OCCUPIED="${CONTENTCLOUD_DEV_KILL_OCCUPIED:-0}" +ISOLATED_MEMORY="${CONTENTCLOUD_DEV_ISOLATED_MEMORY:-1}" +PIDS=() + +usage() { + cat <<'EOF' +Usage: ./scripts/dev.sh [options] + +Options: + --backend-port PORT Set the Go API port (default: 8080). + --frontend-port PORT Set the Vite port (default: 5173). + --memory Use isolated in-memory data and local blob storage (default). + --with-database Use database and object-storage settings loaded from the environment. + --kill-occupied Gracefully stop listeners on the selected ports before starting. + --no-kill-occupied Refuse to start when a selected port is occupied (default). + -h, --help Show this help message. + +Environment: + CONTENTCLOUD_DEV_BACKEND_HOST=127.0.0.1 + CONTENTCLOUD_DEV_BACKEND_PORT=8080 + CONTENTCLOUD_DEV_FRONTEND_HOST=0.0.0.0 + CONTENTCLOUD_DEV_FRONTEND_PORT=5173 + CONTENTCLOUD_DEV_ISOLATED_MEMORY=1 + CONTENTCLOUD_DEV_KILL_OCCUPIED=0 + CONTENTCLOUD_DEV_DATA_DIR=./var/dev-data +EOF +} + +require_option_value() { + local option="$1" + local value="${2:-}" + if [ -z "${value}" ]; then + echo "Missing value for ${option}." >&2 + usage >&2 + exit 2 + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --backend-port) + require_option_value "$@" + BACKEND_PORT="$2" + shift 2 + ;; + --frontend-port) + require_option_value "$@" + FRONTEND_PORT="$2" + shift 2 + ;; + --memory) + ISOLATED_MEMORY=1 + shift + ;; + --with-database) + ISOLATED_MEMORY=0 + shift + ;; + --kill-occupied) + KILL_OCCUPIED=1 + shift + ;; + --no-kill-occupied) + KILL_OCCUPIED=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +normalize_boolean() { + local name="$1" + local value="$2" + case "${value}" in + 1|true|TRUE|yes|YES) + printf '1' + ;; + 0|false|FALSE|no|NO) + printf '0' + ;; + *) + echo "${name} must be a boolean value, got: ${value}" >&2 + exit 2 + ;; + esac +} + +validate_port() { + local name="$1" + local port="$2" + case "${port}" in + ''|*[!0-9]*) + echo "${name} must be an integer between 1 and 65535, got: ${port}" >&2 + exit 2 + ;; + esac + if [ "${port}" -lt 1 ] || [ "${port}" -gt 65535 ]; then + echo "${name} must be between 1 and 65535, got: ${port}" >&2 + exit 2 + fi +} + +KILL_OCCUPIED="$(normalize_boolean "CONTENTCLOUD_DEV_KILL_OCCUPIED" "${KILL_OCCUPIED}")" +ISOLATED_MEMORY="$(normalize_boolean "CONTENTCLOUD_DEV_ISOLATED_MEMORY" "${ISOLATED_MEMORY}")" +validate_port "Backend port" "${BACKEND_PORT}" +validate_port "Frontend port" "${FRONTEND_PORT}" + +if [ "${BACKEND_PORT}" = "${FRONTEND_PORT}" ]; then + echo "Backend and frontend ports must be different." >&2 + exit 2 +fi + +require_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 1 + fi +} + +require_command go +require_command pnpm + +if [ ! -d "${ROOT_DIR}/web/node_modules" ]; then + echo "Frontend dependencies are missing. Run 'pnpm install' from ${ROOT_DIR}." >&2 + exit 1 +fi + +port_in_use() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 + return $? + fi + if command -v nc >/dev/null 2>&1; then + nc -z 127.0.0.1 "${port}" >/dev/null 2>&1 + return $? + fi + return 1 +} + +require_free_port() { + local name="$1" + local port="$2" + if ! port_in_use "${port}"; then + return + fi + + if [ "${KILL_OCCUPIED}" != "1" ]; then + echo "${name} port ${port} is already in use." >&2 + echo "Stop that listener, choose another port, or explicitly pass --kill-occupied." >&2 + exit 1 + fi + + if ! command -v lsof >/dev/null 2>&1; then + echo "Cannot identify the listener on ${name} port ${port}: lsof is unavailable." >&2 + exit 1 + fi + + local listeners=() + local pid + while IFS= read -r pid; do + if [ -n "${pid}" ]; then + listeners+=("${pid}") + fi + done < <(lsof -nP -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u) + + if [ "${#listeners[@]}" -eq 0 ]; then + echo "Cannot identify the listener on ${name} port ${port}." >&2 + exit 1 + fi + + echo "${name} port ${port} is occupied; sending SIGTERM to:" + for pid in "${listeners[@]}"; do + ps -p "${pid}" -o pid=,command= 2>/dev/null || echo " PID ${pid}" + kill -TERM "${pid}" 2>/dev/null || true + done + + local attempt + for ((attempt = 0; attempt < 50; attempt++)); do + if ! port_in_use "${port}"; then + echo "${name} port ${port} is now available." + return + fi + sleep 0.1 + done + + echo "${name} port ${port} is still occupied after 5 seconds; refusing to send SIGKILL." >&2 + exit 1 +} + +terminate_tree() { + local parent_pid="$1" + local child_pid + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r child_pid; do + if [ -n "${child_pid}" ]; then + terminate_tree "${child_pid}" + fi + done < <(pgrep -P "${parent_pid}" 2>/dev/null || true) + fi + kill -TERM "${parent_pid}" >/dev/null 2>&1 || true +} + +cleanup() { + local status="$?" + local pid + local attempt + trap - EXIT INT TERM + set +e + + for pid in "${PIDS[@]}"; do + if kill -0 "${pid}" >/dev/null 2>&1; then + terminate_tree "${pid}" + fi + done + + for ((attempt = 0; attempt < 50; attempt++)); do + local running=0 + for pid in "${PIDS[@]}"; do + if kill -0 "${pid}" >/dev/null 2>&1; then + running=1 + fi + done + if [ "${running}" -eq 0 ]; then + break + fi + sleep 0.1 + done + + for pid in "${PIDS[@]}"; do + if kill -0 "${pid}" >/dev/null 2>&1; then + echo "Process ${pid} did not stop after SIGTERM; it was not force-killed." >&2 + else + wait "${pid}" >/dev/null 2>&1 || true + fi + done + + exit "${status}" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +require_free_port "Backend" "${BACKEND_PORT}" +require_free_port "Frontend" "${FRONTEND_PORT}" + +BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}" +FRONTEND_DISPLAY_HOST="${FRONTEND_HOST}" +if [ "${FRONTEND_DISPLAY_HOST}" = "0.0.0.0" ]; then + FRONTEND_DISPLAY_HOST="localhost" +fi + +if [ "${ISOLATED_MEMORY}" = "1" ]; then + export CONTENTCLOUD_DATABASE_URL="" + export CONTENTCLOUD_AUTO_MIGRATE=0 + export CONTENTCLOUD_S3_BUCKET="" + export CONTENTCLOUD_DATA_DIR="${CONTENTCLOUD_DEV_DATA_DIR:-${ROOT_DIR}/var/dev-data}" +fi + +echo "ContentCloud API: ${BACKEND_URL}" +echo "ContentCloud UI: http://${FRONTEND_DISPLAY_HOST}:${FRONTEND_PORT}" +if [ "${ISOLATED_MEMORY}" = "1" ]; then + echo "Storage: isolated memory + ${CONTENTCLOUD_DATA_DIR}" +else + echo "Storage: environment configuration" +fi + +( + cd "${ROOT_DIR}" + exec env \ + CONTENTCLOUD_ADDR="${BACKEND_HOST}:${BACKEND_PORT}" \ + CONTENTCLOUD_DEV_MODE=1 \ + CONTENTCLOUD_WEB_DIST="${CONTENTCLOUD_WEB_DIST:-web/dist}" \ + go run ./cmd/contentcloud-server +) & +PIDS+=("$!") + +( + cd "${ROOT_DIR}/web" + exec env \ + VITE_DEV_PROXY_TARGET="${BACKEND_URL}" \ + VITE_DEV_PORT="${FRONTEND_PORT}" \ + pnpm exec vite --config vite.config.ts --host "${FRONTEND_HOST}" --port "${FRONTEND_PORT}" --strictPort +) & +PIDS+=("$!") + +while true; do + for pid in "${PIDS[@]}"; do + if ! kill -0 "${pid}" >/dev/null 2>&1; then + if wait "${pid}"; then + exit 0 + else + exit "$?" + fi + fi + done + sleep 1 +done diff --git a/scripts/recolor.mjs b/scripts/recolor.mjs new file mode 100644 index 0000000..844aa03 --- /dev/null +++ b/scripts/recolor.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/* + * 一次性配色迁移脚本:把 web/src/styles.css 的暖绿调配色迁到 + * loopany-platform 的冷蓝灰 + Rubik 语义色体系。 + * + * 策略:在 HSL 空间做色相旋转,保留每个颜色的明度(L)不变 —— + * 明度关系就是原设计的层次结构,只换色相/饱和度不会动布局和可读性层级。 + * + * 用法:node scripts/recolor.mjs [--dry] + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const CSS_PATH = new URL('../web/src/styles.css', import.meta.url); + +// ---- 色彩空间转换 -------------------------------------------------------- +function hexToRgb(hex) { + let h = hex.slice(1); + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return [0, 0, l]; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + return [h, s, l]; +} + +function hslToHex(h, s, l) { + h = ((h % 360) + 360) % 360; + s = Math.min(1, Math.max(0, s)); + l = Math.min(1, Math.max(0, l)); + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let rgb; + if (h < 60) rgb = [c, x, 0]; + else if (h < 120) rgb = [x, c, 0]; + else if (h < 180) rgb = [0, c, x]; + else if (h < 240) rgb = [0, x, c]; + else if (h < 300) rgb = [x, 0, c]; + else rgb = [c, 0, x]; + return '#' + rgb + .map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')) + .join(''); +} + +// ---- 映射规则 ------------------------------------------------------------ +const NEUTRAL_HUE = 228; // 冷蓝灰 +const HUE_RED = 349; // Rubik red #b71234 +const HUE_ORANGE = 26; // Rubik orange 收敛后的琥珀 +const HUE_GREEN = 150; // Rubik green #009b48 +const HUE_BLUE = 216; // Rubik blue #0046ad + +/* + * 品牌/交互用途的砖红 -> 蓝。这些不是「错误」而是「可点击」, + * 语义修正必须显式列出,不能靠色相范围自动判断(它们和错误红同色相)。 + */ +const EXPLICIT = { + '#d84b3e': '#0046ad', // --accent 品牌/交互主色 + '#b93b30': '#00379a', // --accent-dark 主按钮 hover + '#f5efee': '#eceef4', // .auth-bg 渐变中段的暖粉 -> 冷灰 + '#fff4f2': '#eef2fb', // .relation-focus 聚焦底色 -> 蓝调 + '#f5ecea': '#eaeef7', // .run-icon 底色 -> 蓝调 +}; + +/* + * 语义软背景(status 胶囊、banner 底色等)。它们是被稀释过的语义色, + * 色度天然低于中性判定阈值,若不豁免就会被当成浅灰洗掉颜色 —— + * 而「成功绿底/错误红底」正是这套 UI 传达状态的主要手段。 + * 豁免后它们照常走色相映射,明度和饱和度不变。 + */ +const SEMANTIC_SOFT = new Set([ + '#e7f5ef', // --green-soft + '#e6f4f7', // --cyan-soft + '#fdebea', // status-failed / blocked / rejected + '#edf5f7', '#d5e8ec', // banner-info 底 + 描边 + '#fdeeed', // banner-error 底 + '#fff8e8', // impact-panel 琥珀底 + '#f8eeee', // evidence 危险按钮 hover + '#dbece5', // submission 选中态绿底 + '#e5f0f6', // status-submitted / in_review + '#eef5f8', // agent-bootstrap 进行中 + '#fdf0ef', // agent-bootstrap 错误 +]); + +function mapColor(hex) { + const lower = hex.toLowerCase(); + if (EXPLICIT[lower]) return EXPLICIT[lower]; + + const [r, g, b] = hexToRgb(lower); + const [h, s, l] = rgbToHsl(r, g, b); + + // 纯黑/纯白/无彩色:保持 + if (s === 0) return lower; + + /* + * 中性判定用 RGB 色度差(max-min),不用 HSL 饱和度:接近黑/白时 + * HSL 的 s 会被极端明度放大(#f3f7f7 只差 4/255 却算出 s=0.2), + * 用它分类会把浅灰误判成彩色。色度差是绝对量,不受明度影响。 + */ + const chroma = Math.max(r, g, b) - Math.min(r, g, b); + if (chroma < 26 && !SEMANTIC_SOFT.has(lower)) { + // 中性灰:整体转冷蓝调。饱和度给温和上限,避免灰发蓝过头。 + const ns = Math.min(Math.max(s * 1.15, 0.04), 0.12); + return hslToHex(NEUTRAL_HUE, ns, l); + } + + // 有彩色:按色相归入 Rubik 语义色 + let target; + if (h >= 330 || h < 22) target = HUE_RED; + else if (h < 70) target = HUE_ORANGE; + else if (h < 185) target = HUE_GREEN; + else if (h < 260) target = HUE_BLUE; + else target = HUE_BLUE; + + return hslToHex(target, s, l); +} + +// ---- 执行 ---------------------------------------------------------------- +const dry = process.argv.includes('--dry'); +/* + * hex 与 rgba 分开开关:hex 映射不是幂等的(中性灰的饱和度每跑一次都会 + * 被重新钳制一点),所以 hex 跑过之后只能用 --rgba-only 补做 rgba 部分。 + */ +const rgbaOnly = process.argv.includes('--rgba-only'); +const css = readFileSync(CSS_PATH, 'utf8'); + +const changes = new Map(); +let out = css; + +if (!rgbaOnly) { + out = out.replace(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g, (m) => { + const mapped = mapColor(m.toLowerCase()); + if (mapped !== m.toLowerCase()) changes.set(m.toLowerCase(), mapped); + return mapped; + }); +} + +// rgba(r,g,b,a) —— 阴影和描边大量用它,漏掉会留下暖调残影 +out = out.replace(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*([^)]*)\)/g, (m, r, g, b, rest) => { + const hex = '#' + [r, g, b].map((v) => Number(v).toString(16).padStart(2, '0')).join(''); + // 纯白/纯黑用于蒙层和高光,保持中性 + if (hex === '#ffffff' || hex === '#000000') return m; + const mapped = mapColor(hex); + if (mapped === hex) return m; + const [nr, ng, nb] = hexToRgb(mapped); + changes.set(m, `rgba(${nr},${ng},${nb}${rest})`); + return `rgba(${nr},${ng},${nb}${rest})`; +}); + +const sorted = [...changes.entries()].sort(); +console.log(`共映射 ${sorted.length} 个色值:`); +for (const [from, to] of sorted) console.log(` ${from} -> ${to}`); + +if (!dry) { + writeFileSync(CSS_PATH, out); + console.log('\n已写入 web/src/styles.css'); +} else { + console.log('\n(dry run,未写入)'); +} diff --git a/scripts/reradius.mjs b/scripts/reradius.mjs new file mode 100644 index 0000000..fd84837 --- /dev/null +++ b/scripts/reradius.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/* + * 一次性圆角迁移:8 档混用的圆角收敛成 4 档 token,向 loopany 的 + * control(10) / card(16) 尺度靠拢。 + * + * 按现有用途分组,而不是按数值均分: + * 5px = 控件(input/select/icon-button) + * 6-7px = 按钮、头像、小容器 —— 这一档是主力,对应 loopany 的 control + * 8-9px = 卡片、面板、模态 + * 14-16px = auth 大卡片,对应 sheet + * + * 用法:node scripts/reradius.mjs [--dry] + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const CSS_PATH = new URL('../web/src/styles.css', import.meta.url); + +const SCALE = { + 2: '--r-xs', // 6px 装饰小块(empty-mark / severity 条) + 4: '--r-sm', // 8px 行内小按钮、代码块 + 5: '--r-sm', + 6: '--r-ctl', // 10px 控件与按钮(主力档) + 7: '--r-ctl', + 8: '--r-card', // 12px 卡片 / 面板 / 模态 + 9: '--r-card', + 10: '--r-ctl', // 已是目标值,一并纳入 token 以免留下裸数值 + 12: '--r-card', + 14: '--r-sheet', // 16px auth 大卡片 + 16: '--r-sheet', +}; + +const dry = process.argv.includes('--dry'); +const css = readFileSync(CSS_PATH, 'utf8'); + +const counts = new Map(); +const out = css.replace(/border-radius:(\d+(?:\.\d+)?)px/g, (m, num) => { + const token = SCALE[Number(num)]; + if (!token) return m; + counts.set(num, (counts.get(num) || 0) + 1); + return `border-radius:var(${token})`; +}); + +console.log('圆角迁移统计:'); +for (const [px, n] of [...counts.entries()].sort((a, b) => a[0] - b[0])) { + console.log(` ${px}px × ${n} -> var(${SCALE[Number(px)]})`); +} +const left = [...out.matchAll(/border-radius:([^;}]+)/g)] + .map((m) => m[1]).filter((v) => !v.startsWith('var')); +console.log(`\n未迁移(胶囊/百分比等):${[...new Set(left)].join(', ') || '无'}`); + +if (!dry) { + writeFileSync(CSS_PATH, out); + console.log('\n已写入 web/src/styles.css'); +} else { + console.log('\n(dry run,未写入)'); +} diff --git a/scripts/retype.mjs b/scripts/retype.mjs new file mode 100644 index 0000000..d7918f6 --- /dev/null +++ b/scripts/retype.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/* + * 一次性字号迁移:把 21 档无序字号收敛成 5 档 token,并把正文级文字 + * 从 7-10px 抬到可读区间。 + * + * 大于 13px 的一律不动 —— 那些是标题,本来就够大;问题只在小字一端。 + * 抬升幅度随字号递减(8px +37%,12px +8%),既救回最小的一档, + * 又不至于把整版面撑开。 + * + * 用法:node scripts/retype.mjs [--dry] + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const CSS_PATH = new URL('../web/src/styles.css', import.meta.url); + +// 原字号 -> token 名 +const SCALE = { + 7: '--fs-micro', // 11px + 8: '--fs-micro', + 9: '--fs-caption', // 11.5px + 10: '--fs-label', // 12px + 11: '--fs-meta', // 12.5px + 12: '--fs-body', // 13px + 13: '--fs-body', +}; + +const dry = process.argv.includes('--dry'); +const css = readFileSync(CSS_PATH, 'utf8'); + +const counts = new Map(); +const out = css.replace(/font-size:(\d+(?:\.\d+)?)px/g, (m, num) => { + const token = SCALE[Number(num)]; + if (!token) return m; // >13px 的标题保持原样 + counts.set(num, (counts.get(num) || 0) + 1); + return `font-size:var(${token})`; +}); + +console.log('字号迁移统计:'); +for (const [px, n] of [...counts.entries()].sort((a, b) => a[0] - b[0])) { + console.log(` ${px}px × ${n} -> var(${SCALE[Number(px)]})`); +} +const remaining = [...out.matchAll(/font-size:(\d+(?:\.\d+)?)px/g)].map((m) => Number(m[1])); +console.log(`\n保持原样的字号(标题层):${[...new Set(remaining)].sort((a, b) => a - b).join(', ')}`); + +if (!dry) { + writeFileSync(CSS_PATH, out); + console.log('\n已写入 web/src/styles.css'); +} else { + console.log('\n(dry run,未写入)'); +} diff --git a/web/index.html b/web/index.html index 088befe..3c4ab08 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,8 @@ - + + ContentCloud diff --git a/web/package.json b/web/package.json index f2c61d6..a5de84e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,11 +1,11 @@ { "name": "@limecloud/contentcloud-web", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc -b && vite build", + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "tsc -b && vite build --config vite.config.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run --passWithNoTests" }, diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..64ec30c --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,6 @@ + + ContentCloud + + + + diff --git a/web/src/admin/routes.test.ts b/web/src/admin/routes.test.ts index 14d48ce..d03182b 100644 --- a/web/src/admin/routes.test.ts +++ b/web/src/admin/routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { matchRoutes } from 'react-router-dom'; import { appRoutes } from '../router'; +import { canonicalConsolePath, consolePath } from '../consoleRoutes'; import { adminPath } from './routes'; describe('admin routes',()=>{ @@ -15,12 +16,21 @@ describe('admin routes',()=>{ expect(tenants?.map(item=>item.route.path)).toEqual(['/admin',undefined,'tenants']); const unknown=matchRoutes(appRoutes,'/admin/unknown'); expect(unknown?.map(item=>item.route.path)).toEqual(['/admin',undefined,'*']); - expect(matchRoutes(appRoutes,'/administer')?.map(item=>item.route.path)).toEqual(['*']); + expect(matchRoutes(appRoutes,'/administer')?.map(item=>item.route.path)).toEqual(['/',undefined,'*']); }); - it('keeps workspace and public surfaces outside the admin route tree',()=>{ - expect(matchRoutes(appRoutes,'/workspace/projects/project-1/scripts')?.map(item=>item.route.path)).toEqual(['/workspace',undefined,'projects/:projectID','scripts']); + it('keeps the tenant console and public surfaces outside the admin route tree',()=>{ + expect(matchRoutes(appRoutes,'/projects/project-1/scripts')?.map(item=>item.route.path)).toEqual(['/',undefined,'projects/:projectID','scripts']); expect(matchRoutes(appRoutes,'/login')?.map(item=>item.route.path)).toEqual(['/login']); expect(matchRoutes(appRoutes,'/review/token-1')?.map(item=>item.route.path)).toEqual(['/review/:token']); }); + + it('uses root-level canonical console URLs and normalizes released workspace links',()=>{ + expect(consolePath.dashboard).toBe('/'); + expect(consolePath.team).toBe('/team'); + expect(consolePath.project('project-1','scripts')).toBe('/projects/project-1/scripts'); + expect(canonicalConsolePath('/workspace/dashboard')).toBe('/'); + expect(canonicalConsolePath('/workspace/team')).toBe('/team'); + expect(canonicalConsolePath('/workspace/projects/project-1/scripts')).toBe('/projects/project-1/scripts'); + }); }); diff --git a/web/src/components/InitializeWorkspaceModal.tsx b/web/src/components/InitializeWorkspaceModal.tsx index e1a9dd2..ddb0ef2 100644 --- a/web/src/components/InitializeWorkspaceModal.tsx +++ b/web/src/components/InitializeWorkspaceModal.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Check, CheckCircle2, Clipboard, Clock3, LoaderCircle, ShieldCheck, Terminal } from 'lucide-react'; +import { AlertTriangle, Check, CheckCircle2, Clipboard, Clock3, LoaderCircle, Terminal } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { buildBootstrapPrompt, buildManualInstallCommand, connectStateCopy, type ConnectSession } from '../connectBootstrap'; import { Banner, Button, IconButton, Modal } from './ui'; @@ -21,6 +21,7 @@ export function InitializeWorkspaceModal({session,projectName,serverURL,cancelin const prompt=useMemo(()=>buildBootstrapPrompt({serverURL,connectKey:session.connect_key||'',projectName}),[serverURL,session.connect_key,projectName]); const command=useMemo(()=>buildManualInstallCommand({serverURL,connectKey:session.connect_key||''}),[serverURL,session.connect_key]); const state=connectStateCopy(session.state,slow); + const [promptInstruction,promptValues]=prompt.split('\n\n',2); useEffect(()=>{ setSlow(false); @@ -47,26 +48,27 @@ export function InitializeWorkspaceModal({session,projectName,serverURL,cancelin const icon=state.tone==='success'?:state.tone==='error'?:state.tone==='progress'?:; return -
{projectName}项目级 Codex / Claude 环境
-
{icon}
{state.title}{state.detail}
- {session.state==='waiting_for_computer'&&<> -
  1. 1
    打开项目 Agent

    进入希望保存 ContentCloud 工作区的 Codex 或 Claude 会话。

  2. 2
    粘贴 Agent Prompt

    Agent 会读取安装协议,选择安全目录并完成项目级配置。

-
AGENT PROMPT连接码于 {formatExpiry(session.expires_at)} 失效
copy(prompt,'prompt')}>{copied==='prompt'?:}
{prompt}
+

{projectName} · 项目级 Codex / Claude 工作区

+
    +
  1. 1
    在项目 Coding Agent 中进行

    打开用于这个项目的 Codex 或 Claude 会话,安装会在本机完成。

  2. +
  3. 2
    粘贴 Prompt 开始初始化
    {promptInstruction}
    {promptValues}

    粘贴到同一个会话。Agent 会读取安装协议、检查目录并配置项目级 Skill 与 MCP。

  4. +
{slow&&Agent 暂未连接。无需刷新页面;确认 Prompt 已完整发送并允许执行本地命令。} {copyError&&setCopyError('')}>{copyError}} +

{state.title}{slow?'检查 Coding Agent 是否运行在正确的项目目录,然后再次粘贴。':`连接码于 ${formatExpiry(session.expires_at)} 失效`}

改用手动安装

仅在空目录中运行。CLI 会拒绝覆盖未知的非空目录。

{command}copy(command,'command')}>{copied==='command'?:}
} + {session.state!=='waiting_for_computer'&&<>

{projectName} · 项目级 Codex / Claude 工作区

{icon}
{state.title}{state.detail}
} {session.state==='verifying'&&
等待 `workspace.register`只有项目级 Skill、MCP 和 doctor 全部完成后,页面才会显示成功。
} {session.state==='connected'&&
本地负责创作,云端负责治理初始化没有上传已有文件,也没有自动开启 Daemon。
} -
- {session.state==='waiting_for_computer'&&<>} + {session.state!=='waiting_for_computer'&&
{session.state==='verifying'&&} {session.state==='connected'&&} {(session.state==='expired'||session.state==='canceled'||session.state==='failed')&&<>} -
+
}
; } diff --git a/web/src/connectBootstrap.test.ts b/web/src/connectBootstrap.test.ts index 8eacde3..a14bdbe 100644 --- a/web/src/connectBootstrap.test.ts +++ b/web/src/connectBootstrap.test.ts @@ -4,7 +4,7 @@ import { buildBootstrapPrompt, connectStateCopy, isActiveConnectState } from './ describe('ContentCloud Agent bootstrap',()=>{ it('builds one stable prompt for rendering and clipboard use',()=>{ expect(buildBootstrapPrompt({serverURL:'https://content.example.com/',connectKey:'cck_test',projectName:'金陵古都香 / 古法线香'})).toBe( - 'Fetch https://content.example.com/api/bootstrap and initialize this ContentCloud project.\n\nserver-url: https://content.example.com\nconnect-key: cck_test\nproject: "金陵古都香 / 古法线香"' + 'Fetch https://content.example.com/api/bootstrap and help me initialize this ContentCloud project on this machine.\n\nserver-url: https://content.example.com\nconnect-key: cck_test\nproject: "金陵古都香 / 古法线香"' ); }); diff --git a/web/src/connectBootstrap.ts b/web/src/connectBootstrap.ts index acfb126..a7a1dbe 100644 --- a/web/src/connectBootstrap.ts +++ b/web/src/connectBootstrap.ts @@ -22,7 +22,7 @@ export interface ConnectStateCopy { export function buildBootstrapPrompt({serverURL,connectKey,projectName}:BootstrapPromptInput):string { const origin=serverURL.replace(/\/+$/,''); const safeProject=singleLine(projectName)||'ContentCloud project'; - return `Fetch ${origin}/api/bootstrap and initialize this ContentCloud project.\n\nserver-url: ${origin}\nconnect-key: ${singleLine(connectKey)}\nproject: ${JSON.stringify(safeProject)}`; + return `Fetch ${origin}/api/bootstrap and help me initialize this ContentCloud project on this machine.\n\nserver-url: ${origin}\nconnect-key: ${singleLine(connectKey)}\nproject: ${JSON.stringify(safeProject)}`; } export function buildManualInstallCommand({serverURL,connectKey}:Omit):string { diff --git a/web/src/consoleRoutes.ts b/web/src/consoleRoutes.ts new file mode 100644 index 0000000..40cbbb8 --- /dev/null +++ b/web/src/consoleRoutes.ts @@ -0,0 +1,12 @@ +export const consolePath = { + dashboard: '/', + team: '/team', + project: (projectID:string,view:string='overview')=>`/projects/${projectID}/${view}` +} as const; + +export function canonicalConsolePath(pathname:string):string { + if(pathname==='/workspace'||pathname==='/workspace/'||pathname==='/workspace/dashboard')return consolePath.dashboard; + if(pathname==='/workspace/team')return consolePath.team; + if(pathname.startsWith('/workspace/projects/'))return pathname.slice('/workspace'.length); + return consolePath.dashboard; +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 2cfbbc9..f1eaf04 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,60 +1,64 @@ -import { Navigate, type RouteObject } from 'react-router-dom'; +import { Navigate, useLocation, type RouteObject } from 'react-router-dom'; +import { canonicalConsolePath } from './consoleRoutes'; export const appRoutes: RouteObject[] = [ - {path: '/', element: }, {path: '/login', lazy: async()=>({Component:(await import('./views/auth/AuthRoutes')).LoginRoute})}, {path: '/register', lazy: async()=>({Component:(await import('./views/auth/AuthRoutes')).RegisterRoute})}, {path: '/device-auth', lazy: async()=>({Component:(await import('./views/PublicRoutes')).DeviceAuthRoute})}, {path: '/review/:token', lazy: async()=>({Component:(await import('./views/PublicRoutes')).PublicReviewRoute})}, + {path: '/workspace/*', Component: LegacyWorkspaceRedirect}, { - path: '/workspace', - lazy: async()=>({Component:(await import('./App')).App}), + path: '/admin', + lazy: async()=>({Component:(await import('./admin/AdminRoute')).AdminRoute}), children: [ { - lazy: async()=>({Component:(await import('./workspace/WorkspaceShell')).WorkspaceShell}), + lazy: async()=>({Component:(await import('./admin/AdminShell')).AdminShell}), children: [ {index: true, element: }, - {path: 'dashboard', lazy: async()=>({Component:(await import('./workspace/pages')).WorkspaceDashboardPage})}, - {path: 'team', lazy: async()=>({Component:(await import('./workspace/pages')).WorkspaceTeamPage})}, - { - path: 'projects/:projectID', - children: [ - {index: true, element: }, - ...projectRoutes(), - {path: '*', element: } - ] - }, + {path: 'dashboard', lazy: async()=>({Component:(await import('./admin/views/AdminDashboardPage')).AdminDashboardPage})}, + {path: 'tenants', lazy: async()=>({Component:(await import('./admin/views/AdminTenantsPage')).AdminTenantsPage})}, + {path: 'users', lazy: async()=>({Component:(await import('./admin/views/AdminUsersPage')).AdminUsersPage})}, {path: '*', element: } ] } ] }, { - path: '/admin', - lazy: async()=>({Component:(await import('./admin/AdminRoute')).AdminRoute}), + path: '/', + lazy: async()=>({Component:(await import('./App')).App}), children: [ { - lazy: async()=>({Component:(await import('./admin/AdminShell')).AdminShell}), + lazy: async()=>({Component:(await import('./workspace/WorkspaceShell')).ConsoleShell}), children: [ - {index: true, element: }, - {path: 'dashboard', lazy: async()=>({Component:(await import('./admin/views/AdminDashboardPage')).AdminDashboardPage})}, - {path: 'tenants', lazy: async()=>({Component:(await import('./admin/views/AdminTenantsPage')).AdminTenantsPage})}, - {path: 'users', lazy: async()=>({Component:(await import('./admin/views/AdminUsersPage')).AdminUsersPage})}, - {path: '*', element: } + {index: true, lazy: async()=>({Component:(await import('./workspace/pages')).ConsoleDashboardPage})}, + {path: 'team', lazy: async()=>({Component:(await import('./workspace/pages')).ConsoleTeamPage})}, + { + path: 'projects/:projectID', + children: [ + {index: true, element: }, + ...projectRoutes(), + {path: '*', element: } + ] + }, + {path: '*', element: } ] } ] - }, - {path: '*', element: } + } ]; +function LegacyWorkspaceRedirect(){ + const location=useLocation(); + return ; +} + function projectRoutes():RouteObject[] { const views=['overview','sources','assets','knowledge','strategy','briefs','scripts','submissions','results','lineage','audit'] as const; return views.map(view=>({ path:view, lazy:async()=>{ - const {WorkspaceProjectPage}=await import('./workspace/pages'); - return {Component:()=> }; + const {ConsoleProjectPage}=await import('./workspace/pages'); + return {Component:()=> }; } })); } diff --git a/web/src/styles.css b/web/src/styles.css index 0c693ed..e8aa354 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,43 +1,47 @@ -:root{font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:#202524;background:#f7f8f8;font-synthesis:none;letter-spacing:0;--ink:#202524;--muted:#6f7774;--line:#dfe3e1;--line-soft:#ecefed;--panel:#fff;--soft:#f2f4f3;--accent:#d84b3e;--accent-dark:#b93b30;--green:#17855f;--green-soft:#e7f5ef;--amber:#a6650b;--amber-soft:#fff4dc;--cyan:#16758a;--cyan-soft:#e6f4f7;--sidebar:#1d2422}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:#f7f8f8}button,input,select,textarea{font:inherit;letter-spacing:0}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.5}.app-shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:238px;background:var(--sidebar);color:#fff;padding:18px 14px;display:flex;flex-direction:column;z-index:30}.brand{display:flex;align-items:center;gap:10px;padding:0 7px 18px}.brand-mark{width:34px;height:34px;display:grid;place-items:center;background:var(--accent);color:#fff;border-radius:7px;font-size:12px;font-weight:800}.brand>div:nth-child(2){display:flex;flex-direction:column;min-width:0}.brand strong{font-size:14px}.brand span{font-size:11px;color:#9ba5a1;margin-top:2px}.mobile-close{display:none;margin-left:auto}.tenant-switcher{display:flex;align-items:center;gap:9px;padding:10px;background:#28312e;border:1px solid #35403c;border-radius:7px;margin-bottom:20px}.tenant-avatar{width:30px;height:30px;border-radius:6px;background:#e7eee9;color:#27312d;display:grid;place-items:center;font-weight:700}.tenant-switcher>div:nth-child(2){min-width:0;flex:1;display:flex;flex-direction:column}.tenant-switcher strong{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tenant-switcher span{font-size:10px;color:#98a49f;margin-top:2px}.sidebar nav{display:flex;flex-direction:column;gap:2px}.nav-item,.nav-create{border:0;width:100%;height:39px;padding:0 11px;border-radius:6px;color:#b9c3bf;background:transparent;display:flex;align-items:center;gap:10px;text-align:left;font-size:13px}.nav-item:hover,.nav-item.active{background:#303a36;color:#fff}.nav-item.active{box-shadow:inset 2px 0 var(--accent)}.nav-label{height:36px;display:flex;align-items:end;justify-content:space-between;padding:0 10px 7px;margin-top:13px;color:#7f8c87;font-size:10px;text-transform:uppercase}.nav-label .status{font-size:9px}.nav-create{border:1px dashed #46524d;justify-content:center}.sidebar-footer{margin-top:auto;border-top:1px solid #333e3a;padding:16px 8px 2px;display:flex;align-items:center;gap:10px;color:#9aa5a0}.sidebar-footer>div{display:flex;flex-direction:column;min-width:0}.sidebar-footer strong{color:#e8ecea;font-size:12px}.sidebar-footer span{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px}.main{margin-left:238px;min-height:100vh}.topbar{height:62px;padding:0 32px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.92);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:15}.project-select-wrap{display:flex;align-items:center;gap:9px}.project-select-wrap>span{font-size:11px;color:var(--muted)}.project-select-wrap select{min-width:245px;border:0;background:transparent;font-size:13px;font-weight:650;color:var(--ink);outline:none}.new-project{border:1px solid var(--line);background:#fff;border-radius:6px;padding:7px 12px;color:var(--ink);font-size:12px}.mobile-header{display:none}.page{max-width:1440px;margin:0 auto;padding:34px 36px 60px}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:28px}.page-heading h1{font-size:26px;line-height:1.25;margin:4px 0 6px;font-weight:720}.page-heading p{margin:0;color:var(--muted);font-size:13px}.eyebrow,.section-kicker{display:block;color:var(--accent);font-size:10px;font-weight:750;text-transform:uppercase}.project-heading h1{font-size:24px}.button{min-height:36px;border-radius:6px;border:1px solid transparent;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;gap:7px;font-weight:650;font-size:12px}.button-primary{background:var(--accent);color:#fff}.button-primary:hover{background:var(--accent-dark)}.button-secondary{background:#fff;border-color:var(--line);color:var(--ink)}.button-ghost{background:transparent;color:var(--muted)}.button-danger{background:#bb3434;color:#fff}.icon-button{width:32px;height:32px;border:0;background:transparent;color:inherit;display:grid;place-items:center;border-radius:5px;padding:0}.icon-button:hover{background:rgba(127,139,134,.12)}.status{display:inline-flex;align-items:center;justify-content:center;min-height:21px;border-radius:999px;padding:2px 8px;font-size:10px;white-space:nowrap;background:#edf0ef;color:#5e6864}.status-active,.status-approved,.status-succeeded,.status-review_ready,.status-internally_approved,.status-connected{background:var(--green-soft);color:var(--green)}.status-blocked,.status-failed,.status-rejected,.status-revision_requested{background:#fdebea;color:#b23d34}.status-needs_review,.status-internal_review,.status-queued,.status-leased,.status-running,.status-client_review{background:var(--amber-soft);color:var(--amber)}.status-conflicted,.status-review_required{background:#fff0e4;color:#a85218}.status-hook,.status-product_solution,.status-proof,.status-cta,.status-context,.status-payoff{background:var(--cyan-soft);color:var(--cyan)}.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);background:#fff;border-radius:8px;margin-bottom:22px}.stat{min-height:96px;display:flex;align-items:center;gap:13px;padding:20px;border-right:1px solid var(--line-soft)}.stat:last-child{border-right:0}.stat-icon{width:38px;height:38px;border-radius:7px;display:grid;place-items:center}.tone-ink{background:#edf0ef;color:#303735}.tone-green{background:var(--green-soft);color:var(--green)}.tone-cyan{background:var(--cyan-soft);color:var(--cyan)}.tone-amber{background:var(--amber-soft);color:var(--amber)}.stat>div:last-child{display:flex;flex-direction:column}.stat strong{font-size:22px;line-height:1.1}.stat span{font-size:11px;color:var(--muted);margin-top:4px}.pipeline-band{background:var(--sidebar);color:#fff;border-radius:8px;padding:21px 23px;margin-bottom:22px}.pipeline-band>header,.section-header{display:flex;align-items:flex-start;justify-content:space-between}.pipeline-band h2,.section h2{font-size:16px;margin:4px 0 0}.updated{color:#9da7a3;font-size:11px;display:flex;align-items:center;gap:5px}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);margin-top:20px}.pipeline-stage{position:relative;border-left:1px solid #3b4642;padding:4px 20px;display:flex;flex-direction:column;min-width:0}.pipeline-stage:first-child{border-left:0;padding-left:0}.pipeline-value{font-size:21px;font-weight:720}.pipeline-stage strong{font-size:11px;margin:4px 0}.pipeline-stage>span{font-size:10px;color:#88948f}.pipeline-stage .pipeline-blocked{color:#f1b66e}.pipeline-arrow{position:absolute;right:3px;top:23px;color:#5e6a65}.dashboard-columns{display:grid;grid-template-columns:1.4fr 1fr;gap:22px}.section{background:#fff;border:1px solid var(--line);border-radius:8px}.section-header{padding:18px 20px;border-bottom:1px solid var(--line-soft)}.project-list,.run-list{display:flex;flex-direction:column}.project-row{display:grid;grid-template-columns:38px minmax(150px,1fr) auto 54px 54px 20px;gap:12px;align-items:center;padding:13px 18px;border:0;border-bottom:1px solid var(--line-soft);background:#fff;text-align:left;color:var(--ink)}.project-row:last-child{border-bottom:0}.project-row:hover{background:#fafbfa}.project-glyph{width:34px;height:34px;display:grid;place-items:center;background:#eef1ef;border-radius:6px;font-weight:700}.project-main,.project-metric{display:flex;flex-direction:column}.project-main strong{font-size:12px}.project-main span,.project-metric span{font-size:10px;color:var(--muted);margin-top:3px}.project-metric{text-align:center}.project-metric strong{font-size:13px}.run-row{display:grid;grid-template-columns:34px 1fr auto;gap:10px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--line-soft)}.run-row:last-child{border-bottom:0}.run-icon{width:31px;height:31px;border-radius:6px;background:#f5ecea;color:var(--accent);display:grid;place-items:center}.run-row>div:nth-child(2){display:flex;flex-direction:column}.run-row strong{font-size:11px}.run-row span{font-size:10px;color:var(--muted);margin-top:3px}.empty{min-height:210px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:25px}.empty-mark{width:36px;height:4px;background:var(--line);border-radius:2px;margin-bottom:16px}.empty h3{font-size:14px;margin:0 0 7px}.empty p{font-size:11px;color:var(--muted);margin:0 0 15px;max-width:360px}.loading{display:flex;gap:4px}.loading span{width:6px;height:6px;border-radius:50%;background:#8d9793;animation:pulse 1.1s infinite}.loading span:nth-child(2){animation-delay:.15s}.loading span:nth-child(3){animation-delay:.3s}@keyframes pulse{0%,80%,100%{opacity:.25}40%{opacity:1}}.splash{height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px}.banner{min-height:42px;border-radius:6px;padding:10px 13px;display:flex;align-items:center;gap:9px;margin-bottom:16px;font-size:12px;border:1px solid transparent}.banner-info{background:#edf5f7;color:#245f6a;border-color:#d5e8ec}.banner-success{background:var(--green-soft);color:var(--green);border-color:#cbe9dd}.banner-warning{background:var(--amber-soft);color:#805114;border-color:#f1dfbb}.banner-error{background:#fdeeed;color:#9e342d;border-color:#f4d4d1}.banner>div{flex:1}.global-banner{padding:18px 36px 0;max-width:1440px;margin:auto}.overview-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:20px;margin-bottom:20px}.project-facts,.gate-summary{background:#fff;border:1px solid var(--line);border-radius:8px;padding:20px}.project-facts>header,.gate-summary>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.project-facts h2,.gate-summary h2{font-size:15px;margin:0}.gate-summary>header>span{font-size:10px;color:var(--muted)}.project-facts dl{display:grid;grid-template-columns:repeat(2,1fr);gap:0;margin:0}.project-facts dl>div{padding:13px 0;border-top:1px solid var(--line-soft)}.project-facts dl>div:nth-child(odd){padding-right:18px}.project-facts dt{font-size:10px;color:var(--muted)}.project-facts dd{font-size:12px;font-weight:600;margin:5px 0 0}.gate{display:grid;grid-template-columns:30px 1fr auto;gap:9px;align-items:center;padding:10px 0;border-top:1px solid var(--line-soft)}.gate>div:first-child{width:28px;height:28px;border-radius:6px;background:#f1f2f2;display:grid;place-items:center;color:var(--muted)}.gate>div:first-child>span{width:7px;height:7px;border-radius:50%;background:#b5bcb9}.gate-ok>div:first-child{background:var(--green-soft);color:var(--green)}.gate>div:nth-child(2){display:flex;flex-direction:column}.gate strong{font-size:11px}.gate span{font-size:10px;color:var(--muted)}.connection-section{overflow:hidden}.connect-panel{background:#f6f8f7;padding:18px 20px;border-bottom:1px solid var(--line)}.connect-step{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:10px;margin-bottom:12px}.step-number{width:25px;height:25px;border-radius:50%;display:grid;place-items:center;background:var(--ink);color:#fff;font-size:11px}.connect-step>div:nth-child(2){display:flex;flex-direction:column}.connect-step strong{font-size:11px}.connect-step span{font-size:10px;color:var(--muted)}.command-box{display:flex;align-items:center;gap:10px;background:#18201e;color:#dce3e0;border-radius:6px;padding:10px 10px 10px 13px}.command-box code{font-size:11px;flex:1;overflow:auto;white-space:nowrap}.waiting{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:7px;margin-top:9px}.waiting svg{animation:spin 1.4s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.device-row{display:grid;grid-template-columns:38px 1fr auto minmax(150px,auto);gap:12px;align-items:center;padding:14px 20px;border-top:1px solid var(--line-soft);font-size:10px;color:var(--muted)}.device-icon{width:34px;height:34px;background:#edf2f0;border-radius:6px;color:var(--green);display:grid;place-items:center}.device-row>div:nth-child(2){display:flex;flex-direction:column}.device-row strong{font-size:11px;color:var(--ink)}.online-dot{color:var(--green)}.compact-stats{display:flex;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:7px;overflow:hidden;margin-bottom:18px;width:max-content}.compact-stats>div{height:44px;display:flex;align-items:center;gap:7px;background:#fff;padding:0 14px;color:var(--muted)}.compact-stats strong{font-size:15px;color:var(--ink)}.compact-stats span{font-size:10px}.table-section{overflow:hidden}.data-table{display:flex;flex-direction:column}.table-head,.table-row{display:grid;grid-template-columns:minmax(260px,2fr) 80px 65px 65px 85px 70px;gap:12px;align-items:center;padding:0 18px}.table-head{height:39px;background:#f6f8f7;color:var(--muted);font-size:9px;text-transform:uppercase}.table-row{min-height:64px;border-top:1px solid var(--line-soft);font-size:11px}.knowledge-cell{display:flex;align-items:center;gap:10px;min-width:0}.knowledge-icon{width:30px;height:30px;border-radius:6px;background:#edf3f1;color:var(--green);display:grid;place-items:center;flex:0 0 auto}.knowledge-cell>div:last-child{display:flex;flex-direction:column;min-width:0}.knowledge-cell strong{font-size:11px}.knowledge-cell span{color:var(--muted);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}.risk{font-size:10px}.risk-high{color:#b23d34}.risk-medium{color:var(--amber)}.risk-low{color:var(--green)}.row-actions{display:flex;justify-content:flex-end}.row-actions button{border:0;background:transparent;color:var(--muted);width:28px;height:28px;border-radius:4px}.row-actions button:hover{background:var(--soft);color:var(--ink)}.modal-backdrop{position:fixed;inset:0;background:rgba(16,22,20,.55);display:flex;align-items:center;justify-content:center;padding:18px;z-index:80}.modal{width:min(680px,100%);max-height:90vh;background:#fff;border-radius:8px;box-shadow:0 24px 70px rgba(0,0,0,.25);overflow:hidden}.modal>header{height:55px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid var(--line)}.modal h2{font-size:16px;margin:0}.modal-body{padding:20px;overflow:auto;max-height:calc(90vh - 55px)}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.form-grid.two{grid-template-columns:repeat(2,1fr)}.field{display:flex;flex-direction:column;gap:6px}.field>span,.knowledge-picker>span{font-size:10px;color:#4e5854;font-weight:650}.field input,.field select,.field textarea{width:100%;border:1px solid #cfd5d2;border-radius:5px;background:#fff;padding:9px 10px;color:var(--ink);outline:none;font-size:12px;resize:vertical}.field input:focus,.field select:focus,.field textarea:focus{border-color:#7b8a84;box-shadow:0 0 0 3px rgba(44,79,67,.08)}.field:has(textarea),.form-grid .field:nth-last-child(1):nth-child(odd){grid-column:1/-1}.field small{font-size:9px;color:var(--muted)}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px;padding-top:16px;border-top:1px solid var(--line-soft)}.form-error{color:#ac3e35;font-size:11px;background:#fdeeed;padding:8px 10px;border-radius:5px}.knowledge-picker{margin-top:18px;border-top:1px solid var(--line);padding-top:16px}.knowledge-picker>label{display:flex;gap:9px;padding:9px 0;border-bottom:1px solid var(--line-soft)}.knowledge-picker input{margin-top:3px}.knowledge-picker label>div{display:flex;flex-direction:column}.knowledge-picker strong{font-size:11px}.knowledge-picker label span{font-size:10px;color:var(--muted);margin-top:3px}.brief-list{display:flex;flex-direction:column;gap:14px}.brief-item{background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden}.brief-item>header{display:grid;grid-template-columns:42px 1fr auto;gap:12px;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line-soft)}.brief-version{width:38px;height:32px;border-radius:6px;background:#eef1ef;display:grid;place-items:center;font-weight:750;font-size:11px}.brief-item>header>div:nth-child(2){display:flex;flex-direction:column}.brief-item>header strong{font-size:12px}.brief-item>header span{font-size:10px;color:var(--muted);margin-top:3px}.brief-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:5px 18px}.brief-grid>div{padding:12px 14px 12px 0}.brief-grid span{font-size:9px;color:var(--muted);text-transform:uppercase}.brief-grid p{font-size:11px;margin:5px 0 0;line-height:1.55}.brief-item>footer{display:flex;justify-content:flex-end;gap:7px;padding:12px 18px;background:#fafbfa;border-top:1px solid var(--line-soft)}.heading-actions{display:flex;gap:7px}.script-workspace{display:grid;grid-template-columns:190px minmax(0,1fr);gap:16px}.version-list{background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden;height:max-content}.version-list>header{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;background:#f6f8f7;border-bottom:1px solid var(--line);font-size:10px;color:var(--muted)}.version-list>button{width:100%;border:0;border-bottom:1px solid var(--line-soft);background:#fff;display:grid;grid-template-columns:1fr auto 14px;align-items:center;gap:6px;padding:11px 12px;text-align:left;color:var(--ink)}.version-list>button.active{background:#f0f4f2;box-shadow:inset 2px 0 var(--accent)}.version-list button>div{display:flex;flex-direction:column}.version-list strong{font-size:11px}.version-list button span{font-size:9px;color:var(--muted);margin-top:2px}.script-detail{background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden}.script-title{display:flex;align-items:flex-start;justify-content:space-between;padding:21px 22px;border-bottom:1px solid var(--line)}.script-title>div:first-child>span{font-size:9px;color:var(--accent);font-weight:700}.script-title h2{font-size:19px;margin:4px 0}.script-title p{font-size:11px;color:var(--muted);margin:0}.script-title>div:last-child{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.script-title>div:last-child>span{font-size:10px;color:var(--muted)}.strategy-strip{display:grid;grid-template-columns:repeat(4,1fr);border-bottom:1px solid var(--line)}.strategy-strip>div{padding:13px 15px;border-right:1px solid var(--line-soft);display:flex;flex-direction:column}.strategy-strip>div:last-child{border-right:0}.strategy-strip span,.bible-band span,.shot-main span,.frame-grid span{font-size:9px;color:var(--muted);text-transform:uppercase}.strategy-strip strong{font-size:10px;margin-top:5px;line-height:1.45}.bible-band{background:#f6f8f7;display:grid;grid-template-columns:1.4fr 1.4fr .7fr;padding:12px 16px;gap:15px;border-bottom:1px solid var(--line)}.bible-band p{font-size:10px;margin:4px 0 0;line-height:1.45}.shot-row{display:grid;grid-template-columns:83px 1fr;border-bottom:1px solid var(--line)}.shot-number{padding:16px;border-right:1px solid var(--line-soft);display:flex;flex-direction:column}.shot-number>span{font-size:20px;font-weight:750}.shot-number small{font-size:9px;color:var(--muted);margin-top:4px}.shot-body{padding:15px 17px}.shot-body>header{display:flex;align-items:center;gap:9px}.shot-body>header>strong{font-size:11px}.shot-main{display:grid;grid-template-columns:1.25fr 1.25fr 1fr;gap:14px;margin-top:13px}.shot-main p{font-size:10px;line-height:1.5;margin:4px 0}.shot-body details{border-top:1px solid var(--line-soft);margin-top:12px;padding-top:9px}.shot-body summary{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer}.frame-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-top:9px}.frame-grid>div{background:#f7f8f8;border-radius:5px;padding:9px}.frame-grid p{font-size:9px;line-height:1.5;margin:4px 0}.review-bar{min-height:58px;padding:10px 17px;background:#f6f8f7;display:flex;align-items:center;justify-content:space-between}.review-bar>div{display:flex;align-items:center;gap:8px}.review-bar span{font-size:9px;color:var(--muted)}.review-bar code{font-size:9px}.blocked-list{padding:15px}.blocked-list>div{display:flex;gap:10px;background:#fff2e4;color:#8a5010;padding:12px;border-radius:6px}.blocked-list>div>div{display:flex;flex-direction:column}.blocked-list strong{font-size:11px}.blocked-list span{font-size:10px;margin-top:3px}.audit-list{display:flex;flex-direction:column}.audit-row{display:grid;grid-template-columns:36px 1fr auto;gap:10px;align-items:center;padding:13px 17px;border-bottom:1px solid var(--line-soft)}.audit-icon{width:31px;height:31px;border-radius:6px;background:#eef1ef;display:grid;place-items:center;color:#59635f}.audit-row>div:nth-child(2){display:flex;flex-direction:column}.audit-row strong{font-size:11px}.audit-row span{font-size:9px;color:var(--muted);margin-top:3px}.audit-row time{font-size:9px;color:var(--muted);display:flex;gap:5px;align-items:center}.placeholder-columns{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.placeholder-columns .section{padding:22px}.placeholder-columns h2{font-size:14px}.placeholder-columns p{font-size:11px;color:var(--muted)}.placeholder-icon{width:40px;height:40px;border-radius:7px;background:var(--cyan-soft);color:var(--cyan);display:grid;place-items:center}.auth-shell{position:relative;min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px}.auth-bg{position:absolute;inset:0;background:linear-gradient(135deg,#f7f8f8 0%,#f5efee 45%,#edf0ef 100%)}.auth-decor{position:absolute;inset:0;overflow:hidden;pointer-events:none}.auth-orb{position:absolute;border-radius:50%;filter:blur(64px)}.auth-orb-1{top:-160px;right:-160px;width:320px;height:320px;background:rgba(216,75,62,.2)}.auth-orb-2{bottom:-160px;left:-160px;width:320px;height:320px;background:rgba(185,59,48,.15)}.auth-orb-3{top:50%;left:50%;width:384px;height:384px;transform:translate(-50%,-50%);background:rgba(216,75,62,.1)}.auth-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(216,75,62,.03) 1px,transparent 1px),linear-gradient(90deg,rgba(216,75,62,.03) 1px,transparent 1px);background-size:64px 64px}.auth-content{position:relative;z-index:10;width:min(430px,100%)}.auth-head{text-align:center;margin-bottom:28px}.auth-logo{width:56px;height:56px;margin:0 auto 15px;display:grid;place-items:center;background:var(--accent);color:#fff;border-radius:14px;font-size:17px;font-weight:800;box-shadow:0 10px 30px rgba(216,75,62,.3)}.auth-title{font-size:27px;font-weight:750;margin:0 0 7px;background:linear-gradient(90deg,var(--accent),var(--accent-dark));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}.auth-subtitle{font-size:12px;color:var(--muted);margin:0}.auth-card{background:rgba(255,255,255,.82);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.7);border-radius:16px;padding:30px;box-shadow:0 20px 50px rgba(20,30,26,.1)}.auth-card h2{font-size:21px;font-weight:700;margin:0 0 6px;text-align:center}.auth-card>p{font-size:12px;color:var(--muted);margin:0 0 24px;text-align:center}.auth-form{display:flex;flex-direction:column;gap:17px}.auth-field{display:flex;flex-direction:column;gap:7px}.auth-field>label{font-size:11px;color:#4e5854;font-weight:650}.auth-input-wrap{position:relative;display:flex;align-items:center}.auth-input-icon{position:absolute;left:13px;display:grid;place-items:center;color:#9aa4a0;pointer-events:none}.auth-input{width:100%;height:42px;border:1px solid #cfd5d2;border-radius:9px;background:rgba(255,255,255,.9);padding:0 13px 0 40px;color:var(--ink);outline:none;font-size:13px}.auth-input.has-suffix{padding-right:40px}.auth-input:focus{border-color:#7b8a84;box-shadow:0 0 0 3px rgba(44,79,67,.1)}.auth-input:disabled{background:#f4f6f5;color:var(--muted)}.auth-input.auth-input-error{border-color:#c9483d}.auth-input.auth-input-error:focus{box-shadow:0 0 0 3px rgba(201,72,61,.12)}.auth-input.auth-input-ok{border-color:var(--green)}.auth-input.auth-input-ok:focus{box-shadow:0 0 0 3px rgba(23,133,95,.12)}.auth-suffix{position:absolute;right:5px;width:32px;height:32px;border:0;background:transparent;color:#9aa4a0;display:grid;place-items:center;border-radius:6px;padding:0}.auth-suffix:hover:not(:disabled){color:var(--ink);background:rgba(127,139,134,.1)}.auth-suffix-static{position:absolute;right:13px;display:grid;place-items:center;pointer-events:none}.auth-field-error{font-size:11px;color:#ac3e35}.auth-field-hint{font-size:10px;color:var(--muted)}.auth-note{display:flex;gap:9px;align-items:flex-start;font-size:11px;line-height:1.55;border-radius:9px;padding:11px 13px}.auth-note-ok{background:var(--green-soft);color:var(--green)}.auth-note-info{background:var(--cyan-soft);color:var(--cyan)}.auth-submit{width:100%;height:42px;border:0;border-radius:9px;background:var(--accent);color:#fff;font-size:13px;font-weight:650;display:inline-flex;align-items:center;justify-content:center;gap:8px}.auth-submit:hover:not(:disabled){background:var(--accent-dark)}.auth-spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite}.auth-modes{display:flex;gap:5px;padding:4px;background:rgba(237,240,239,.85);border-radius:10px;margin-bottom:22px}.auth-mode{flex:1;height:33px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:12px;font-weight:600}.auth-mode.active{background:#fff;color:var(--ink);box-shadow:0 1px 4px rgba(20,30,26,.1)}.auth-footer{margin-top:22px;text-align:center;font-size:12px;color:var(--muted)}.auth-link{border:0;background:transparent;padding:0;color:var(--accent);font-size:12px;font-weight:650;text-decoration:none}.auth-link:hover{color:var(--accent-dark);text-decoration:underline}.auth-copyright{margin-top:26px;text-align:center;font-size:10px;color:#a3ada9}.fatal{max-width:500px;margin:100px auto;padding:20px}.sidebar-scrim{display:none} +:root{font-family:ui-sans-serif,"Avenir Next","Segoe UI Variable","PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;letter-spacing:0;--white:#fff;--page:#f2f3ef;--ink:#1b1d1a;--muted:#5b6258;--text-soft:#6f766b;--line:#d9dcd3;--line-soft:#e8eae4;--line-strong:#c6cac0;--disabled:#9da498;--panel:var(--white);--surface-subtle:#fafbf7;--surface-muted:#f5f6f2;--surface-raised:#eef0ea;--neutral:#e7eae3;--soft:#f0f2ec;--brand:#d5f34a;--brand-ink:#202810;--action:#1c1f1b;--action-hover:#30342e;--link:#4c6500;--focus:#6e8f00;--info:#3567d8;--info-soft:#e9f0ff;--success:#117a61;--success-soft:#e5f5ef;--warning:#a46200;--warning-soft:#fbf0db;--danger:#b8273d;--danger-soft:#fbe8ec;--source:#3567d8;--source-soft:#e9f0ff;--knowledge:#117a61;--knowledge-soft:#e5f5ef;--strategy:#a46200;--strategy-soft:#fbf0db;--production:#c74632;--production-soft:#fbeae6;--review:#b33c62;--review-soft:#f8e9ef;--source-bright:#7fa4ff;--knowledge-bright:#62c5a8;--strategy-bright:#f0bc5f;--production-bright:#ff8d74;--review-bright:#ef7d9d;--sidebar:#1a1c19;--sidebar-panel:#252823;--sidebar-hover:#30342d;--sidebar-line:#3d4139;--sidebar-text:#c4c9be;--sidebar-strong:#f5f6f2;--focus-ring:rgba(110,143,0,.2);--success-ring:rgba(17,122,97,.16);--danger-ring:rgba(184,39,61,.16);--fs-micro:11px;--fs-caption:11.5px;--fs-label:12px;--fs-meta:13px;--fs-body:14px;--r-sm:6px;--r-ctl:8px;--r-card:8px;--r-sheet:12px;--shadow-card:0 1px 2px rgb(17 18 16/.06);--shadow-pop:0 10px 26px -10px rgba(17,18,16,.18),0 26px 54px -22px rgba(17,18,16,.14)}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:var(--page);font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;letter-spacing:0}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.5}:where(a,button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stat-grid,.project-facts,.gate-summary,.brief-item,.version-list,.script-detail,.lineage-summary,.lineage-board,.admin-stat-grid{box-shadow:var(--shadow-card)}.app-shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:238px;background:var(--sidebar);color:var(--white);padding:18px 14px;display:flex;flex-direction:column;z-index:30}.brand{display:flex;align-items:center;gap:10px;padding:0 7px 18px}.brand-mark{width:34px;height:34px;display:grid;place-items:center;background:var(--brand);color:var(--brand-ink);border-radius:var(--r-ctl);font-size:var(--fs-body);font-weight:800}.brand>div:nth-child(2){display:flex;flex-direction:column;min-width:0}.brand strong{font-size:14px}.brand span{font-size:var(--fs-meta);color:var(--text-soft);margin-top:2px}.mobile-close{display:none;margin-left:auto}.tenant-switcher{display:flex;align-items:center;gap:9px;padding:10px;background:var(--sidebar-panel);border:1px solid var(--sidebar-line);border-radius:var(--r-ctl);margin-bottom:20px}.tenant-avatar{width:30px;height:30px;border-radius:var(--r-ctl);background:var(--neutral);color:var(--ink);display:grid;place-items:center;font-weight:700}.tenant-switcher>div:nth-child(2){min-width:0;flex:1;display:flex;flex-direction:column}.tenant-switcher strong{font-size:var(--fs-body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tenant-switcher span{font-size:var(--fs-label);color:var(--text-soft);margin-top:2px}.sidebar nav{display:flex;flex-direction:column;gap:2px}.nav-item,.nav-create{border:0;width:100%;height:39px;padding:0 11px;border-radius:var(--r-ctl);color:var(--sidebar-text);background:transparent;display:flex;align-items:center;gap:10px;text-align:left;font-size:var(--fs-body)}.nav-item:hover,.nav-item.active{background:var(--sidebar-hover);color:var(--white)}.nav-item{--nav-accent:var(--brand)}.nav-item[data-view="sources"],.nav-item[data-view="assets"]{--nav-accent:var(--source-bright)}.nav-item[data-view="knowledge"]{--nav-accent:var(--knowledge-bright)}.nav-item[data-view="strategy"],.nav-item[data-view="briefs"]{--nav-accent:var(--strategy-bright)}.nav-item[data-view="scripts"]{--nav-accent:var(--production-bright)}.nav-item[data-view="submissions"],.nav-item[data-view="lineage"]{--nav-accent:var(--review-bright)}.nav-item[data-view="results"]{--nav-accent:var(--knowledge-bright)}.nav-item.active{box-shadow:inset 3px 0 var(--nav-accent)}.nav-item.active>svg{color:var(--nav-accent)}.nav-label{height:36px;display:flex;align-items:end;justify-content:space-between;padding:0 10px 7px;margin-top:13px;color:var(--text-soft);font-size:var(--fs-label);text-transform:uppercase}.nav-label .status{font-size:var(--fs-caption)}.nav-create{border:1px dashed var(--sidebar-line);justify-content:center}.sidebar-footer{margin-top:auto;border-top:1px solid var(--sidebar-line);padding:16px 8px 2px;display:flex;align-items:center;gap:10px;color:var(--text-soft)}.sidebar-footer>div{display:flex;flex-direction:column;min-width:0;flex:1}.sidebar-footer strong{color:var(--sidebar-strong);font-size:var(--fs-body)}.sidebar-footer span{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.main{margin-left:238px;min-height:100vh}.topbar{height:62px;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);padding:0 32px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.92);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:15}.project-select-wrap{display:flex;align-items:center;gap:9px}.project-select-wrap>span{font-size:var(--fs-meta);color:var(--muted)}.project-select-wrap select{min-width:245px;border:0;background:transparent;font-size:var(--fs-body);font-weight:650;color:var(--ink);outline:none}.new-project{border:1px solid var(--line);background:var(--white);border-radius:var(--r-ctl);padding:7px 12px;color:var(--ink);font-size:var(--fs-body)}.mobile-header{display:none}.page{max-width:1440px;margin:0 auto;padding:34px 36px 60px}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:28px}.page-heading h1{font-size:26px;line-height:1.25;margin:4px 0 6px;font-weight:720}.page-heading p{margin:0;color:var(--muted);font-size:var(--fs-body)}.eyebrow,.section-kicker{display:block;font-size:var(--fs-micro);font-weight:700;text-transform:uppercase;letter-spacing:0}.eyebrow{color:var(--link)}.section-kicker{color:var(--muted)}.project-heading h1{font-size:24px}.button{min-height:36px;border-radius:var(--r-ctl);border:1px solid transparent;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;gap:7px;font-weight:650;font-size:var(--fs-body);transition:background-color .15s ease,border-color .15s ease,color .15s ease}.button-primary{background:var(--action);color:var(--white)}.button-primary:hover{background:var(--action-hover)}.button-secondary:hover,.new-project:hover{border-color:var(--disabled);background:var(--surface-subtle)}.button-secondary{background:var(--white);border-color:var(--line);color:var(--ink)}.button-ghost{background:transparent;color:var(--muted)}.button-danger{background:var(--danger);color:var(--white)}.icon-button{width:32px;height:32px;border:0;background:transparent;color:inherit;display:grid;place-items:center;border-radius:var(--r-sm);padding:0}.icon-button:hover{background:rgba(126,129,140,.12)}.status{display:inline-flex;align-items:center;justify-content:center;min-height:21px;border-radius:999px;padding:2px 8px;font-size:var(--fs-label);white-space:nowrap;background:var(--neutral);color:var(--muted)}.status-active,.status-approved,.status-succeeded,.status-review_ready,.status-internally_approved,.status-connected{background:var(--success-soft);color:var(--success)}.status-blocked,.status-failed,.status-rejected,.status-revision_requested{background:var(--danger-soft);color:var(--danger)}.status-needs_review,.status-internal_review,.status-queued,.status-leased,.status-running,.status-client_review{background:var(--warning-soft);color:var(--warning)}.status-conflicted,.status-review_required{background:var(--warning-soft);color:var(--warning)}.status-hook,.status-product_solution,.status-proof,.status-cta,.status-context,.status-payoff{background:var(--production-soft);color:var(--production)}.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);background:var(--white);border-radius:var(--r-card);margin-bottom:22px}.stat{min-height:96px;display:flex;align-items:center;gap:13px;padding:20px;border-right:1px solid var(--line-soft)}.stat:last-child{border-right:0}.stat-icon{width:38px;height:38px;border-radius:var(--r-ctl);display:grid;place-items:center}.tone-ink{background:var(--neutral);color:var(--ink)}.tone-green{background:var(--knowledge-soft);color:var(--knowledge)}.tone-cyan{background:var(--source-soft);color:var(--source)}.tone-amber{background:var(--strategy-soft);color:var(--strategy)}.stat>div:last-child{display:flex;flex-direction:column}.stat strong{font-size:22px;line-height:1.1}.stat span{font-size:var(--fs-meta);color:var(--muted);margin-top:4px}.pipeline-band{background:var(--action);color:var(--white);border-radius:var(--r-card);padding:21px 23px;margin-bottom:22px;box-shadow:inset 0 3px var(--brand)}.pipeline-band>header,.section-header{display:flex;align-items:flex-start;justify-content:space-between}.pipeline-band h2,.section h2{font-size:16px;margin:4px 0 0}.updated{color:var(--text-soft);font-size:var(--fs-meta);display:flex;align-items:center;gap:5px}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);margin-top:20px}.pipeline-stage{position:relative;border-left:1px solid var(--sidebar-line);padding:4px 20px;display:flex;flex-direction:column;min-width:0}.pipeline-stage:first-child{border-left:0;padding-left:0}.pipeline-stage:nth-child(1){--stage-color:var(--source-bright)}.pipeline-stage:nth-child(2){--stage-color:var(--knowledge-bright)}.pipeline-stage:nth-child(3){--stage-color:var(--strategy-bright)}.pipeline-stage:nth-child(4){--stage-color:var(--production-bright)}.pipeline-stage:nth-child(5){--stage-color:var(--review-bright)}.pipeline-value{font-size:21px;font-weight:720;color:var(--stage-color,var(--white))}.pipeline-stage strong{font-size:var(--fs-meta);margin:4px 0}.pipeline-stage>span{font-size:var(--fs-label);color:var(--text-soft)}.pipeline-stage .pipeline-blocked{color:var(--warning)}.pipeline-arrow{position:absolute;right:3px;top:23px;color:var(--muted)}.dashboard-columns{display:grid;grid-template-columns:1.4fr 1fr;gap:22px}.section{background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);box-shadow:var(--shadow-card)}.section-header{padding:18px 20px;border-bottom:1px solid var(--line-soft)}.project-list,.run-list{display:flex;flex-direction:column}.project-row{display:grid;grid-template-columns:38px minmax(150px,1fr) auto 54px 54px 20px;gap:12px;align-items:center;padding:13px 18px;border:0;border-bottom:1px solid var(--line-soft);background:var(--white);text-align:left;color:var(--ink)}.project-row:last-child{border-bottom:0}.project-row:hover{background:var(--surface-subtle)}.project-glyph{width:34px;height:34px;display:grid;place-items:center;background:var(--neutral);border-radius:var(--r-ctl);font-weight:700}.project-main,.project-metric{display:flex;flex-direction:column}.project-main strong{font-size:var(--fs-body)}.project-main span,.project-metric span{font-size:var(--fs-label);color:var(--muted);margin-top:3px}.project-metric{text-align:center}.project-metric strong{font-size:var(--fs-body)}.run-row{display:grid;grid-template-columns:34px 1fr auto;gap:10px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--line-soft)}.run-row:last-child{border-bottom:0}.run-icon{width:31px;height:31px;border-radius:var(--r-ctl);background:var(--production-soft);color:var(--production);display:grid;place-items:center}.run-row>div:nth-child(2){display:flex;flex-direction:column}.run-row strong{font-size:var(--fs-meta)}.run-row span{font-size:var(--fs-label);color:var(--muted);margin-top:3px}.empty{min-height:210px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:25px}.empty-mark{width:36px;height:4px;background:var(--line);border-radius:var(--r-sm);margin-bottom:16px}.empty h3{font-size:14px;margin:0 0 7px}.empty p{font-size:var(--fs-meta);color:var(--muted);margin:0 0 15px;max-width:360px}.loading{display:flex;gap:4px}.loading span{width:6px;height:6px;border-radius:50%;background:var(--text-soft);animation:pulse 1.1s infinite}.loading span:nth-child(2){animation-delay:.15s}.loading span:nth-child(3){animation-delay:.3s}@keyframes pulse{0%,80%,100%{opacity:.25}40%{opacity:1}}.splash{height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px}.banner{min-height:42px;border-radius:var(--r-ctl);padding:10px 13px;display:flex;align-items:center;gap:9px;margin-bottom:16px;font-size:var(--fs-body);border:1px solid transparent}.banner-info{background:var(--info-soft);color:var(--info);border-color:var(--info-soft)}.banner-success{background:var(--success-soft);color:var(--success);border-color:var(--success-soft)}.banner-warning{background:var(--warning-soft);color:var(--warning);border-color:var(--warning-soft)}.banner-error{background:var(--danger-soft);color:var(--danger);border-color:var(--danger-soft)}.banner>div{flex:1}.global-banner{padding:18px 36px 0;max-width:1440px;margin:auto}.overview-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:20px;margin-bottom:20px}.project-facts,.gate-summary{background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);padding:20px}.project-facts>header,.gate-summary>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.project-facts h2,.gate-summary h2{font-size:15px;margin:0}.gate-summary>header>span{font-size:var(--fs-label);color:var(--muted)}.project-facts dl{display:grid;grid-template-columns:repeat(2,1fr);gap:0;margin:0}.project-facts dl>div{padding:13px 0;border-top:1px solid var(--line-soft)}.project-facts dl>div:nth-child(odd){padding-right:18px}.project-facts dt{font-size:var(--fs-label);color:var(--muted)}.project-facts dd{font-size:var(--fs-body);font-weight:600;margin:5px 0 0}.gate{display:grid;grid-template-columns:30px 1fr auto;gap:9px;align-items:center;padding:10px 0;border-top:1px solid var(--line-soft)}.gate>div:first-child{width:28px;height:28px;border-radius:var(--r-ctl);background:var(--neutral);display:grid;place-items:center;color:var(--muted)}.gate>div:first-child>span{width:7px;height:7px;border-radius:50%;background:var(--disabled)}.gate-ok>div:first-child{background:var(--success-soft);color:var(--success)}.gate>div:nth-child(2){display:flex;flex-direction:column}.gate strong{font-size:var(--fs-meta)}.gate span{font-size:var(--fs-label);color:var(--muted)}.connection-section{overflow:hidden}.connect-panel{background:var(--surface-muted);padding:18px 20px;border-bottom:1px solid var(--line)}.connect-step{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:10px;margin-bottom:12px}.step-number{width:25px;height:25px;border-radius:50%;display:grid;place-items:center;background:var(--ink);color:var(--white);font-size:var(--fs-meta)}.connect-step>div:nth-child(2){display:flex;flex-direction:column}.connect-step strong{font-size:var(--fs-meta)}.connect-step span{font-size:var(--fs-label);color:var(--muted)}.command-box{display:flex;align-items:center;gap:10px;background:var(--sidebar);color:var(--sidebar-strong);border-radius:var(--r-ctl);padding:10px 10px 10px 13px}.command-box code{font-size:var(--fs-meta);flex:1;overflow:auto;white-space:nowrap}.waiting{font-size:var(--fs-label);color:var(--muted);display:flex;align-items:center;gap:7px;margin-top:9px}.waiting svg{animation:spin 1.4s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.device-row{display:grid;grid-template-columns:38px 1fr auto minmax(150px,auto);gap:12px;align-items:center;padding:14px 20px;border-top:1px solid var(--line-soft);font-size:var(--fs-label);color:var(--muted)}.device-icon{width:34px;height:34px;background:var(--neutral);border-radius:var(--r-ctl);color:var(--success);display:grid;place-items:center}.device-row>div:nth-child(2){display:flex;flex-direction:column}.device-row strong{font-size:var(--fs-meta);color:var(--ink)}.online-dot{color:var(--success)}.compact-stats{display:flex;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:var(--r-ctl);overflow:hidden;margin-bottom:18px;width:max-content}.compact-stats>div{height:44px;display:flex;align-items:center;gap:7px;background:var(--white);padding:0 14px;color:var(--muted)}.compact-stats strong{font-size:15px;color:var(--ink)}.compact-stats span{font-size:var(--fs-label)}.table-section{overflow:hidden}.data-table{display:flex;flex-direction:column}.table-head,.table-row{display:grid;grid-template-columns:minmax(260px,2fr) 80px 65px 65px 85px 70px;gap:12px;align-items:center;padding:0 18px}.table-head{height:39px;background:var(--surface-muted);color:var(--muted);font-size:var(--fs-caption);text-transform:uppercase}.table-row{min-height:64px;border-top:1px solid var(--line-soft);font-size:var(--fs-meta)}.knowledge-cell{display:flex;align-items:center;gap:10px;min-width:0}.knowledge-icon{width:30px;height:30px;border-radius:var(--r-ctl);background:var(--knowledge-soft);color:var(--knowledge);display:grid;place-items:center;flex:0 0 auto}.knowledge-cell>div:last-child{display:flex;flex-direction:column;min-width:0}.knowledge-cell strong{font-size:var(--fs-meta)}.knowledge-cell span{color:var(--muted);font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}.risk{font-size:var(--fs-label)}.risk-high{color:var(--danger)}.risk-medium{color:var(--warning)}.risk-low{color:var(--success)}.row-actions{display:flex;justify-content:flex-end}.row-actions button{border:0;background:transparent;color:var(--muted);width:28px;height:28px;border-radius:var(--r-sm)}.row-actions button:hover{background:var(--soft);color:var(--ink)}.modal-backdrop{position:fixed;inset:0;background:rgba(17,18,21,.55);display:flex;align-items:center;justify-content:center;padding:18px;z-index:80}.modal{width:min(680px,100%);max-height:90vh;background:var(--white);border-radius:var(--r-card);box-shadow:var(--shadow-pop);overflow:hidden}.modal>header{height:55px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid var(--line)}.modal h2{font-size:16px;margin:0}.modal-body{padding:20px;overflow:auto;max-height:calc(90vh - 55px)}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.form-grid.two{grid-template-columns:repeat(2,1fr)}.field{display:flex;flex-direction:column;gap:6px}.field>span,.knowledge-picker>span{font-size:var(--fs-label);color:var(--muted);font-weight:650}.field input,.field select,.field textarea{width:100%;border:1px solid var(--line-strong);border-radius:var(--r-sm);background:var(--white);padding:9px 10px;color:var(--ink);outline:none;font-size:var(--fs-body);resize:vertical}.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--disabled);box-shadow:0 0 0 3px var(--focus-ring)}.field:has(textarea),.form-grid .field:nth-last-child(1):nth-child(odd){grid-column:1/-1}.field small{font-size:var(--fs-caption);color:var(--muted)}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px;padding-top:16px;border-top:1px solid var(--line-soft)}.form-error{color:var(--danger);font-size:var(--fs-meta);background:var(--danger-soft);padding:8px 10px;border-radius:var(--r-sm)}.knowledge-picker{margin-top:18px;border-top:1px solid var(--line);padding-top:16px}.knowledge-picker>label{display:flex;gap:9px;padding:9px 0;border-bottom:1px solid var(--line-soft)}.knowledge-picker input{margin-top:3px}.knowledge-picker label>div{display:flex;flex-direction:column}.knowledge-picker strong{font-size:var(--fs-meta)}.knowledge-picker label span{font-size:var(--fs-label);color:var(--muted);margin-top:3px}.brief-list{display:flex;flex-direction:column;gap:14px}.brief-item{background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);overflow:hidden}.brief-item>header{display:grid;grid-template-columns:42px 1fr auto;gap:12px;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line-soft)}.brief-version{width:38px;height:32px;border-radius:var(--r-ctl);background:var(--neutral);display:grid;place-items:center;font-weight:750;font-size:var(--fs-meta)}.brief-item>header>div:nth-child(2){display:flex;flex-direction:column}.brief-item>header strong{font-size:var(--fs-body)}.brief-item>header span{font-size:var(--fs-label);color:var(--muted);margin-top:3px}.brief-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:5px 18px}.brief-grid>div{padding:12px 14px 12px 0}.brief-grid span{font-size:var(--fs-caption);color:var(--muted);text-transform:uppercase}.brief-grid p{font-size:var(--fs-meta);margin:5px 0 0;line-height:1.55}.brief-item>footer{display:flex;justify-content:flex-end;gap:7px;padding:12px 18px;background:var(--surface-subtle);border-top:1px solid var(--line-soft)}.heading-actions{display:flex;gap:7px}.script-workspace{display:grid;grid-template-columns:190px minmax(0,1fr);gap:16px}.version-list{background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);overflow:hidden;height:max-content}.version-list>header{height:45px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;background:var(--surface-muted);border-bottom:1px solid var(--line);font-size:var(--fs-label);color:var(--muted)}.version-list>button{width:100%;border:0;border-bottom:1px solid var(--line-soft);background:var(--white);display:grid;grid-template-columns:1fr auto 14px;align-items:center;gap:6px;padding:11px 12px;text-align:left;color:var(--ink)}.version-list>button.active{background:var(--source-soft);box-shadow:inset 2px 0 var(--source)}.version-list button>div{display:flex;flex-direction:column}.version-list strong{font-size:var(--fs-meta)}.version-list button span{font-size:var(--fs-caption);color:var(--muted);margin-top:2px}.script-detail{background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);overflow:hidden}.script-title{display:flex;align-items:flex-start;justify-content:space-between;padding:21px 22px;border-bottom:1px solid var(--line)}.script-title>div:first-child>span{font-size:var(--fs-caption);color:var(--production);font-weight:700}.script-title h2{font-size:19px;margin:4px 0}.script-title p{font-size:var(--fs-meta);color:var(--muted);margin:0}.script-title>div:last-child{display:flex;align-items:flex-end;flex-direction:column;gap:6px}.script-title>div:last-child>span{font-size:var(--fs-label);color:var(--muted)}.strategy-strip{display:grid;grid-template-columns:repeat(4,1fr);border-bottom:1px solid var(--line)}.strategy-strip>div{padding:13px 15px;border-right:1px solid var(--line-soft);display:flex;flex-direction:column}.strategy-strip>div:last-child{border-right:0}.strategy-strip span,.bible-band span,.shot-main span,.frame-grid span{font-size:var(--fs-caption);color:var(--muted);text-transform:uppercase}.strategy-strip strong{font-size:var(--fs-label);margin-top:5px;line-height:1.45}.bible-band{background:var(--surface-muted);display:grid;grid-template-columns:1.4fr 1.4fr .7fr;padding:12px 16px;gap:15px;border-bottom:1px solid var(--line)}.bible-band p{font-size:var(--fs-label);margin:4px 0 0;line-height:1.45}.shot-row{display:grid;grid-template-columns:83px 1fr;border-bottom:1px solid var(--line)}.shot-number{padding:16px;border-right:1px solid var(--line-soft);display:flex;flex-direction:column}.shot-number>span{font-size:20px;font-weight:750}.shot-number small{font-size:var(--fs-caption);color:var(--muted);margin-top:4px}.shot-body{padding:15px 17px}.shot-body>header{display:flex;align-items:center;gap:9px}.shot-body>header>strong{font-size:var(--fs-meta)}.shot-main{display:grid;grid-template-columns:1.25fr 1.25fr 1fr;gap:14px;margin-top:13px}.shot-main p{font-size:var(--fs-label);line-height:1.5;margin:4px 0}.shot-body details{border-top:1px solid var(--line-soft);margin-top:12px;padding-top:9px}.shot-body summary{font-size:var(--fs-label);color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer}.frame-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-top:9px}.frame-grid>div{background:var(--surface-muted);border-radius:var(--r-sm);padding:9px}.frame-grid p{font-size:var(--fs-caption);line-height:1.5;margin:4px 0}.review-bar{min-height:58px;padding:10px 17px;background:var(--surface-muted);display:flex;align-items:center;justify-content:space-between}.review-bar>div{display:flex;align-items:center;gap:8px}.review-bar span{font-size:var(--fs-caption);color:var(--muted)}.review-bar code{font-size:var(--fs-caption)}.blocked-list{padding:15px}.blocked-list>div{display:flex;gap:10px;background:var(--warning-soft);color:var(--warning);padding:12px;border-radius:var(--r-ctl)}.blocked-list>div>div{display:flex;flex-direction:column}.blocked-list strong{font-size:var(--fs-meta)}.blocked-list span{font-size:var(--fs-label);margin-top:3px}.audit-list{display:flex;flex-direction:column}.audit-row{display:grid;grid-template-columns:36px 1fr auto;gap:10px;align-items:center;padding:13px 17px;border-bottom:1px solid var(--line-soft)}.audit-icon{width:31px;height:31px;border-radius:var(--r-ctl);background:var(--neutral);display:grid;place-items:center;color:var(--muted)}.audit-row>div:nth-child(2){display:flex;flex-direction:column}.audit-row strong{font-size:var(--fs-meta)}.audit-row span{font-size:var(--fs-caption);color:var(--muted);margin-top:3px}.audit-row time{font-size:var(--fs-caption);color:var(--muted);display:flex;gap:5px;align-items:center}.placeholder-columns{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.placeholder-columns .section{padding:22px}.placeholder-columns h2{font-size:14px}.placeholder-columns p{font-size:var(--fs-meta);color:var(--muted)}.placeholder-icon{width:40px;height:40px;border-radius:var(--r-ctl);background:var(--info-soft);color:var(--info);display:grid;place-items:center}.auth-shell{position:relative;min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:16px}.auth-bg{position:absolute;inset:0;background:var(--page)}.auth-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(27,29,26,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(27,29,26,.035) 1px,transparent 1px);background-size:64px 64px}.auth-content{position:relative;z-index:10;width:min(430px,100%)}.auth-head{text-align:center;margin-bottom:28px}.auth-logo{width:56px;height:56px;margin:0 auto 15px;display:grid;place-items:center;background:var(--brand);color:var(--brand-ink);border-radius:var(--r-card);font-size:17px;font-weight:800;box-shadow:var(--shadow-pop)}.auth-title{font-size:27px;font-weight:750;margin:0 0 7px;color:var(--ink)}.auth-subtitle{font-size:var(--fs-body);color:var(--muted);margin:0}.auth-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-card);padding:30px;box-shadow:var(--shadow-pop)}.auth-card h2{font-size:21px;font-weight:700;margin:0 0 6px;text-align:center}.auth-card>p{font-size:var(--fs-body);color:var(--muted);margin:0 0 24px;text-align:center}.auth-form{display:flex;flex-direction:column;gap:17px}.auth-field{display:flex;flex-direction:column;gap:7px}.auth-field>label{font-size:var(--fs-meta);color:var(--muted);font-weight:650}.auth-input-wrap{position:relative;display:flex;align-items:center}.auth-input-icon{position:absolute;left:13px;display:grid;place-items:center;color:var(--text-soft);pointer-events:none}.auth-input{width:100%;height:42px;border:1px solid var(--line-strong);border-radius:var(--r-card);background:rgba(255,255,255,.9);padding:0 13px 0 40px;color:var(--ink);outline:none;font-size:var(--fs-body)}.auth-input.has-suffix{padding-right:40px}.auth-input:focus{border-color:var(--disabled);box-shadow:0 0 0 3px var(--focus-ring)}.auth-input:disabled{background:var(--surface-raised);color:var(--muted)}.auth-input.auth-input-error{border-color:var(--danger)}.auth-input.auth-input-error:focus{box-shadow:0 0 0 3px var(--danger-ring)}.auth-input.auth-input-ok{border-color:var(--success)}.auth-input.auth-input-ok:focus{box-shadow:0 0 0 3px var(--success-ring)}.auth-suffix{position:absolute;right:5px;width:32px;height:32px;border:0;background:transparent;color:var(--text-soft);display:grid;place-items:center;border-radius:var(--r-ctl);padding:0}.auth-suffix:hover:not(:disabled){color:var(--ink);background:rgba(126,129,140,.1)}.auth-suffix-static{position:absolute;right:13px;display:grid;place-items:center;pointer-events:none}.auth-field-error{font-size:var(--fs-meta);color:var(--danger)}.auth-field-hint{font-size:var(--fs-label);color:var(--muted)}.auth-note{display:flex;gap:9px;align-items:flex-start;font-size:var(--fs-meta);line-height:1.55;border-radius:var(--r-card);padding:11px 13px}.auth-note-ok{background:var(--success-soft);color:var(--success)}.auth-note-info{background:var(--info-soft);color:var(--info)}.auth-submit{width:100%;height:42px;border:0;border-radius:var(--r-card);background:var(--action);color:var(--white);font-size:var(--fs-body);font-weight:650;display:inline-flex;align-items:center;justify-content:center;gap:8px}.auth-submit:hover:not(:disabled){background:var(--action-hover)}.auth-spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.35);border-top-color:var(--white);border-radius:50%;animation:spin .8s linear infinite}.auth-modes{display:flex;gap:5px;padding:4px;background:rgba(237,237,240,.85);border-radius:var(--r-ctl);margin-bottom:22px}.auth-mode{flex:1;height:33px;border:0;border-radius:var(--r-ctl);background:transparent;color:var(--muted);font-size:var(--fs-body);font-weight:600}.auth-mode.active{background:var(--white);color:var(--ink);box-shadow:var(--shadow-card)}.auth-footer{margin-top:22px;text-align:center;font-size:var(--fs-body);color:var(--muted)}.auth-link{border:0;background:transparent;padding:0;color:var(--link);font-size:var(--fs-body);font-weight:650;text-decoration:none}.auth-link:hover{color:var(--action);text-decoration:underline}.auth-copyright{margin-top:26px;text-align:center;font-size:var(--fs-label);color:var(--text-soft)}.fatal{max-width:500px;margin:100px auto;padding:20px}.sidebar-scrim{display:none} @media(max-width:1000px){.stat-grid{grid-template-columns:repeat(2,1fr)}.stat:nth-child(2){border-right:0}.stat:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}.dashboard-columns,.overview-grid{grid-template-columns:1fr}.strategy-strip{grid-template-columns:repeat(2,1fr)}.strategy-strip>div:nth-child(2){border-right:0}.strategy-strip>div:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}.brief-grid{grid-template-columns:repeat(2,1fr)}} -@media(max-width:760px){.mobile-header{display:flex;position:sticky;top:0;z-index:20;height:56px;background:#fff;border-bottom:1px solid var(--line);align-items:center;padding:0 14px;gap:9px}.mobile-header strong{font-size:13px;flex:1}.mobile-header .brand-mark{width:30px;height:30px}.sidebar{transform:translateX(-100%);transition:transform .2s ease}.sidebar-open{transform:translateX(0)}.mobile-close{display:grid}.sidebar-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(12,18,16,.45);z-index:25}.main{margin-left:0}.topbar{height:53px;padding:0 14px;top:56px}.project-select-wrap>span{display:none}.project-select-wrap select{min-width:0;max-width:210px}.page{padding:24px 15px 45px}.page-heading{align-items:flex-end}.page-heading h1{font-size:21px}.page-heading p{max-width:220px}.stat-grid{grid-template-columns:repeat(2,1fr)}.stat{padding:14px;min-height:80px}.stat-icon{width:32px;height:32px}.pipeline{grid-template-columns:1fr;gap:0}.pipeline-stage{border-left:0;border-top:1px solid #3b4642;padding:10px 0;display:grid;grid-template-columns:34px 1fr auto;align-items:center}.pipeline-stage:first-child{border-top:0}.pipeline-value{font-size:16px}.pipeline-stage strong{margin:0}.pipeline-arrow{display:none}.project-row{grid-template-columns:34px minmax(0,1fr) auto 16px}.project-row .project-metric{display:none}.dashboard-columns{grid-template-columns:1fr}.project-facts dl{grid-template-columns:1fr}.project-facts dl>div:nth-child(odd){padding-right:0}.device-row{grid-template-columns:34px 1fr auto}.device-row>span:last-child{display:none}.compact-stats{width:100%;overflow:auto}.compact-stats>div{padding:0 10px;flex:1;white-space:nowrap}.data-table{min-width:740px}.table-section{overflow:auto}.form-grid,.form-grid.two{grid-template-columns:1fr}.form-grid .field{grid-column:1}.brief-grid{grid-template-columns:1fr}.script-workspace{grid-template-columns:1fr}.version-list{display:flex;overflow:auto}.version-list>header{display:none}.version-list>button{min-width:135px;border-right:1px solid var(--line-soft)}.strategy-strip{grid-template-columns:1fr}.strategy-strip>div{border-right:0;border-bottom:1px solid var(--line-soft)}.bible-band{grid-template-columns:1fr}.shot-row{grid-template-columns:58px 1fr}.shot-number{padding:12px}.shot-main,.frame-grid{grid-template-columns:1fr}.review-bar{align-items:flex-start;flex-direction:column}.placeholder-columns{grid-template-columns:1fr}.modal{max-height:94vh}.modal-body{max-height:calc(94vh - 55px)}.auth-orb-3{display:none}.auth-logo{width:48px;height:48px;border-radius:12px;font-size:15px}.auth-title{font-size:23px}.auth-card{padding:22px 18px;border-radius:14px}.auth-head{margin-bottom:22px}} +@media(max-width:760px){.mobile-header{display:flex;position:sticky;top:0;z-index:20;height:56px;background:var(--white);border-bottom:1px solid var(--line);align-items:center;padding:0 14px;gap:9px}.mobile-header strong{font-size:var(--fs-body);flex:1}.mobile-header .brand-mark{width:30px;height:30px}.sidebar{transform:translateX(-100%);transition:transform .2s ease}.sidebar-open{transform:translateX(0)}.mobile-close{display:grid}.sidebar-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(13,14,17,.45);z-index:25}.main{margin-left:0}.topbar{height:53px;padding:0 14px;top:56px}.project-select-wrap>span{display:none}.project-select-wrap select{min-width:0;max-width:210px}.page{padding:24px 15px 45px}.page-heading{align-items:flex-end}.page-heading h1{font-size:21px}.page-heading p{max-width:220px}.stat-grid{grid-template-columns:repeat(2,1fr)}.stat{padding:14px;min-height:80px}.stat-icon{width:32px;height:32px}.pipeline{grid-template-columns:1fr;gap:0}.pipeline-stage{border-left:0;border-top:1px solid var(--sidebar-line);padding:10px 0;display:grid;grid-template-columns:34px 1fr auto;align-items:center}.pipeline-stage:first-child{border-top:0}.pipeline-value{font-size:16px}.pipeline-stage strong{margin:0}.pipeline-arrow{display:none}.project-row{grid-template-columns:34px minmax(0,1fr) auto 16px}.project-row .project-metric{display:none}.dashboard-columns{grid-template-columns:1fr}.project-facts dl{grid-template-columns:1fr}.project-facts dl>div:nth-child(odd){padding-right:0}.device-row{grid-template-columns:34px 1fr auto}.device-row>span:last-child{display:none}.compact-stats{width:100%;overflow:auto}.compact-stats>div{padding:0 10px;flex:1;white-space:nowrap}.data-table{min-width:740px}.table-section{overflow:auto}.form-grid,.form-grid.two{grid-template-columns:1fr}.form-grid .field{grid-column:1}.brief-grid{grid-template-columns:1fr}.script-workspace{grid-template-columns:1fr}.version-list{display:flex;overflow:auto}.version-list>header{display:none}.version-list>button{min-width:135px;border-right:1px solid var(--line-soft)}.strategy-strip{grid-template-columns:1fr}.strategy-strip>div{border-right:0;border-bottom:1px solid var(--line-soft)}.bible-band{grid-template-columns:1fr}.shot-row{grid-template-columns:58px 1fr}.shot-number{padding:12px}.shot-main,.frame-grid{grid-template-columns:1fr}.review-bar{align-items:flex-start;flex-direction:column}.placeholder-columns{grid-template-columns:1fr}.modal{max-height:94vh}.modal-body{max-height:calc(94vh - 55px)}.auth-logo{width:48px;height:48px;border-radius:var(--r-card);font-size:15px}.auth-title{font-size:23px}.auth-card{padding:22px 18px;border-radius:var(--r-sheet)}.auth-head{margin-bottom:22px}} -.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.asset-workspace{display:grid;grid-template-columns:minmax(270px,.75fr) minmax(0,1.6fr);gap:16px}.source-list,.evidence-panel,.strategy-card{overflow:hidden}.source-row{width:100%;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:13px 16px;border:0;border-top:1px solid var(--line-soft);background:#fff;color:var(--ink);text-align:left}.source-row:hover,.source-row.active{background:#f1f5f3}.source-row.active{box-shadow:inset 2px 0 var(--accent)}.source-row>span{display:flex;flex-direction:column;min-width:0}.source-row strong{font-size:11px}.source-row small{font-size:9px;color:var(--muted);margin-top:3px}.revision-meta{display:flex;gap:12px;align-items:center;padding:10px 18px;background:#f6f8f7;border-bottom:1px solid var(--line-soft);font-size:10px;color:var(--muted)}.revision-meta code{margin-left:auto}.evidence-list article{padding:15px 18px;border-bottom:1px solid var(--line-soft)}.evidence-list article header{display:flex;align-items:center;gap:8px;font-size:9px;color:var(--muted)}.evidence-list article header code{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.evidence-list p{font-size:11px;line-height:1.65;margin:9px 0 0}.strategy-board{display:grid;grid-template-columns:1fr 1fr;gap:16px}.strategy-items>article,.framework-item{padding:14px 18px;border-top:1px solid var(--line-soft);display:flex;flex-direction:column;gap:5px}.strategy-items article>div,.framework-item>div,.plan-item>div{display:flex;align-items:center;justify-content:space-between}.strategy-items strong,.framework-item strong{font-size:11px}.strategy-items span,.framework-item span,.framework-item small,.plan-item span{font-size:9px;color:var(--muted);line-height:1.5}.plan-item{margin-top:10px;padding:11px;background:#f6f8f7;border-left:2px solid var(--cyan)}.plan-item .row-actions{margin-top:5px}.form-hint,.modal-copy{font-size:10px;line-height:1.6;color:var(--muted);grid-column:1/-1}.result-table .table-head,.result-table .table-row{grid-template-columns:110px 1fr 70px 110px 1.4fr}.shot-body>header{justify-content:flex-start}.shot-comment-button{margin-left:auto;border:0;background:transparent;color:var(--muted);display:flex;align-items:center;gap:4px}.shot-comment{margin:10px 0 0;padding:9px 11px;border-left:2px solid var(--cyan);background:#f3f7f7;font-size:10px;line-height:1.5}.shot-comment span{display:block;color:var(--muted);font-size:8px;margin-bottom:3px}.export-actions{display:flex;gap:6px}.artifact-strip{padding:10px 17px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap}.artifact-strip a{display:flex;align-items:center;gap:6px;font-size:9px;color:var(--ink);text-decoration:none;border:1px solid var(--line);padding:6px 8px;border-radius:5px}.artifact-strip a span{color:var(--muted)}.grant-result{display:flex;flex-direction:column;gap:12px}.grant-result .banner{margin:0}.public-page{min-height:100vh;background:#edf0ef;display:grid;place-items:center;padding:20px}.public-panel{width:min(460px,100%);background:#fff;border:1px solid var(--line);border-radius:8px;padding:28px;display:flex;flex-direction:column;gap:15px}.public-brand{display:flex;align-items:center;gap:9px}.public-brand>span{width:32px;height:32px;background:var(--sidebar);color:#fff;display:grid;place-items:center;border-radius:6px;font-size:10px;font-weight:750}.public-brand strong{font-size:12px}.public-icon,.public-success{width:46px;height:46px;display:grid;place-items:center;border-radius:7px;background:#eef1ef;color:var(--ink);margin-top:16px}.public-success{background:var(--green-soft);color:var(--green)}.public-panel h1{font-size:21px;margin:0}.public-panel p{font-size:11px;line-height:1.7;color:var(--muted);margin:0}.review-page{min-height:100vh;background:#f1f3f2}.review-public-header{height:66px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 max(18px,calc((100% - 880px)/2))}.review-public-header>div:last-child{display:flex;flex-direction:column;text-align:right}.review-public-header>div:last-child strong{font-size:11px}.review-public-header>div:last-child span{font-size:9px;color:var(--muted)}.review-public-content{width:min(880px,calc(100% - 30px));margin:28px auto 60px}.verify-panel{width:min(440px,100%);margin:70px auto;background:#fff;border:1px solid var(--line);border-radius:8px;padding:28px;display:flex;flex-direction:column;gap:14px}.verify-panel h1{font-size:20px;margin:8px 0 0}.verify-panel p{font-size:11px;color:var(--muted);margin:0}.review-summary{background:#18201e;color:#fff;border-radius:8px;padding:24px;display:flex;justify-content:space-between;gap:20px}.review-summary>div:first-child>span{font-size:9px;color:#93a09b}.review-summary h1{font-size:22px;margin:7px 0}.review-summary p{font-size:11px;color:#bec7c3;margin:0}.review-summary>div:last-child{display:flex;flex-direction:column;align-items:flex-end;gap:10px}.review-summary code{font-size:9px;color:#aab5b0}.review-shot-list{display:flex;flex-direction:column;gap:10px;margin-top:14px}.review-shot-list article{background:#fff;border:1px solid var(--line);border-radius:7px;padding:17px}.review-shot-list article>header{display:flex;justify-content:space-between;align-items:center;font-size:9px;color:var(--muted)}.review-shot-list h2{font-size:13px;margin:9px 0 13px}.review-shot-list dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0}.review-shot-list dt{font-size:8px;color:var(--muted)}.review-shot-list dd{font-size:10px;line-height:1.55;margin:4px 0 0}.review-shot-list blockquote{margin:12px 0 0;padding:8px 10px;background:#edf5f7;border-left:2px solid var(--cyan);font-size:10px}.review-decision{position:sticky;bottom:12px;margin-top:16px;background:#fff;border:1px solid var(--line);box-shadow:0 8px 28px rgba(17,27,23,.12);border-radius:8px;padding:16px}.review-decision>div:last-child{display:flex;justify-content:flex-end;gap:8px;margin-top:12px} -.revision-selector{display:flex;align-items:center;gap:9px;padding:10px 18px;border-top:1px solid var(--line-soft);background:#fff}.revision-selector select{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;padding:7px 9px;background:#fff;color:var(--ink);font-size:10px}.revision-selector>span{font-size:9px;color:var(--muted)}.impact-panel{padding:13px 18px;background:#fff8e8;border-bottom:1px solid #ead9a8}.impact-panel>header{display:flex;align-items:center;gap:7px;color:#755618;font-size:10px}.impact-panel>div{display:grid;grid-template-columns:1fr auto;gap:4px 10px;margin-top:10px}.impact-panel>div>span{font-size:9px;color:#705b2c}.impact-panel p{grid-column:1/-1;margin:0;font-size:9px;line-height:1.5;color:#75643d}.evidence-list article>small{display:block;margin-top:8px;font-size:9px;color:var(--muted)}.evidence-list article>footer{display:flex;justify-content:flex-end;gap:5px;margin-top:10px}.evidence-list article>footer button{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--ink)}.evidence-list article>footer button:hover{background:#eef3f1}.evidence-list article>footer button:last-child:hover{background:#f8eeee;color:#9b3434} -.rights-workspace{display:grid;grid-template-columns:minmax(270px,.8fr) minmax(0,1.5fr);gap:16px}.rights-assets,.rights-detail{overflow:hidden}.rights-asset-row{width:100%;display:grid;grid-template-columns:34px 1fr auto;gap:10px;align-items:center;padding:12px 16px;border:0;border-top:1px solid var(--line-soft);background:#fff;text-align:left;color:var(--ink)}.rights-asset-row:hover,.rights-asset-row.active{background:#f1f5f3}.rights-asset-row.active{box-shadow:inset 2px 0 var(--accent)}.rights-asset-icon{width:30px;height:30px;display:grid;place-items:center;border-radius:5px;background:#edf3f1;color:var(--green)}.rights-asset-row>span:nth-child(2){display:flex;flex-direction:column;min-width:0}.rights-asset-row strong{font-size:11px}.rights-asset-row small{font-size:9px;color:var(--muted);margin-top:3px}.rights-gate{display:flex;gap:10px;align-items:center;padding:12px 18px;border-bottom:1px solid var(--line-soft)}.rights-gate-pass{background:var(--green-soft);color:var(--green)}.rights-gate-blocked{background:var(--amber-soft);color:var(--amber)}.rights-gate>div{display:flex;flex-direction:column}.rights-gate strong{font-size:10px}.rights-gate span{font-size:9px;margin-top:3px}.rights-list article{padding:15px 18px;border-bottom:1px solid var(--line-soft)}.rights-list article>header{display:flex;align-items:center;justify-content:space-between}.rights-list article>header>div{display:flex;flex-direction:column}.rights-list article>header strong{font-size:11px}.rights-list article>header span{font-size:9px;color:var(--muted);margin-top:3px}.rights-list dl{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:13px 0 0}.rights-list dt{font-size:8px;color:var(--muted)}.rights-list dd{font-size:10px;margin:4px 0 0}.rights-list article>p{font-size:9px;color:var(--muted);padding:8px 10px;background:#f6f8f7;margin:11px 0 0}.rights-list article>footer{display:flex;justify-content:flex-end;gap:5px;margin-top:10px}.rights-list article>footer button{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--ink)} +.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.asset-workspace{display:grid;grid-template-columns:minmax(270px,.75fr) minmax(0,1.6fr);gap:16px}.source-list,.evidence-panel,.strategy-card{overflow:hidden}.source-row{width:100%;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:13px 16px;border:0;border-top:1px solid var(--line-soft);background:var(--white);color:var(--ink);text-align:left}.source-row:hover,.source-row.active{background:var(--neutral)}.source-row.active{box-shadow:inset 2px 0 var(--source)}.source-row>span{display:flex;flex-direction:column;min-width:0}.source-row strong{font-size:var(--fs-meta)}.source-row small{font-size:var(--fs-caption);color:var(--muted);margin-top:3px}.revision-meta{display:flex;gap:12px;align-items:center;padding:10px 18px;background:var(--surface-muted);border-bottom:1px solid var(--line-soft);font-size:var(--fs-label);color:var(--muted)}.revision-meta code{margin-left:auto}.evidence-list article{padding:15px 18px;border-bottom:1px solid var(--line-soft)}.evidence-list article header{display:flex;align-items:center;gap:8px;font-size:var(--fs-caption);color:var(--muted)}.evidence-list article header code{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.evidence-list p{font-size:var(--fs-meta);line-height:1.65;margin:9px 0 0}.strategy-board{display:grid;grid-template-columns:1fr 1fr;gap:16px}.strategy-items>article,.framework-item{padding:14px 18px;border-top:1px solid var(--line-soft);display:flex;flex-direction:column;gap:5px}.strategy-items article>div,.framework-item>div,.plan-item>div{display:flex;align-items:center;justify-content:space-between}.strategy-items strong,.framework-item strong{font-size:var(--fs-meta)}.strategy-items span,.framework-item span,.framework-item small,.plan-item span{font-size:var(--fs-caption);color:var(--muted);line-height:1.5}.plan-item{margin-top:10px;padding:11px;background:var(--strategy-soft);border-left:2px solid var(--strategy)}.plan-item .row-actions{margin-top:5px}.form-hint,.modal-copy{font-size:var(--fs-label);line-height:1.6;color:var(--muted);grid-column:1/-1}.result-table .table-head,.result-table .table-row{grid-template-columns:110px 1fr 70px 110px 1.4fr}.shot-body>header{justify-content:flex-start}.shot-comment-button{margin-left:auto;border:0;background:transparent;color:var(--muted);display:flex;align-items:center;gap:4px}.shot-comment{margin:10px 0 0;padding:9px 11px;border-left:2px solid var(--production);background:var(--production-soft);font-size:var(--fs-label);line-height:1.5}.shot-comment span{display:block;color:var(--muted);font-size:var(--fs-micro);margin-bottom:3px}.export-actions{display:flex;gap:6px}.artifact-strip{padding:10px 17px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap}.artifact-strip a{display:flex;align-items:center;gap:6px;font-size:var(--fs-caption);color:var(--ink);text-decoration:none;border:1px solid var(--line);padding:6px 8px;border-radius:var(--r-sm)}.artifact-strip a span{color:var(--muted)}.grant-result{display:flex;flex-direction:column;gap:12px}.grant-result .banner{margin:0}.public-page{min-height:100vh;background:var(--neutral);display:grid;place-items:center;padding:20px}.public-panel{width:min(460px,100%);background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);padding:28px;display:flex;flex-direction:column;gap:15px}.public-brand{display:flex;align-items:center;gap:9px}.public-brand>span{width:32px;height:32px;background:var(--sidebar);color:var(--white);display:grid;place-items:center;border-radius:var(--r-ctl);font-size:var(--fs-label);font-weight:750}.public-brand strong{font-size:var(--fs-body)}.public-icon,.public-success{width:46px;height:46px;display:grid;place-items:center;border-radius:var(--r-ctl);background:var(--neutral);color:var(--ink);margin-top:16px}.public-success{background:var(--success-soft);color:var(--success)}.public-panel h1{font-size:21px;margin:0}.public-panel p{font-size:var(--fs-meta);line-height:1.7;color:var(--muted);margin:0}.review-page{min-height:100vh;background:var(--neutral)}.review-public-header{height:66px;background:var(--white);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 max(18px,calc((100% - 880px)/2))}.review-public-header>div:last-child{display:flex;flex-direction:column;text-align:right}.review-public-header>div:last-child strong{font-size:var(--fs-meta)}.review-public-header>div:last-child span{font-size:var(--fs-caption);color:var(--muted)}.review-public-content{width:min(880px,calc(100% - 30px));margin:28px auto 60px}.verify-panel{width:min(440px,100%);margin:70px auto;background:var(--white);border:1px solid var(--line);border-radius:var(--r-card);padding:28px;display:flex;flex-direction:column;gap:14px}.verify-panel h1{font-size:20px;margin:8px 0 0}.verify-panel p{font-size:var(--fs-meta);color:var(--muted);margin:0}.review-summary{background:var(--sidebar);color:var(--white);border-radius:var(--r-card);padding:24px;display:flex;justify-content:space-between;gap:20px}.review-summary>div:first-child>span{font-size:var(--fs-caption);color:var(--text-soft)}.review-summary h1{font-size:22px;margin:7px 0}.review-summary p{font-size:var(--fs-meta);color:var(--disabled);margin:0}.review-summary>div:last-child{display:flex;flex-direction:column;align-items:flex-end;gap:10px}.review-summary code{font-size:var(--fs-caption);color:var(--disabled)}.review-shot-list{display:flex;flex-direction:column;gap:10px;margin-top:14px}.review-shot-list article{background:var(--white);border:1px solid var(--line);border-radius:var(--r-ctl);padding:17px}.review-shot-list article>header{display:flex;justify-content:space-between;align-items:center;font-size:var(--fs-caption);color:var(--muted)}.review-shot-list h2{font-size:var(--fs-body);margin:9px 0 13px}.review-shot-list dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0}.review-shot-list dt{font-size:var(--fs-micro);color:var(--muted)}.review-shot-list dd{font-size:var(--fs-label);line-height:1.55;margin:4px 0 0}.review-shot-list blockquote{margin:12px 0 0;padding:8px 10px;background:var(--review-soft);border-left:2px solid var(--review);font-size:var(--fs-label)}.review-decision{position:sticky;bottom:12px;margin-top:16px;background:var(--white);border:1px solid var(--line);box-shadow:var(--shadow-pop);border-radius:var(--r-card);padding:16px}.review-decision>div:last-child{display:flex;justify-content:flex-end;gap:8px;margin-top:12px} +.revision-selector{display:flex;align-items:center;gap:9px;padding:10px 18px;border-top:1px solid var(--line-soft);background:var(--white)}.revision-selector select{min-width:0;flex:1;border:1px solid var(--line);border-radius:var(--r-sm);padding:7px 9px;background:var(--white);color:var(--ink);font-size:var(--fs-label)}.revision-selector>span{font-size:var(--fs-caption);color:var(--muted)}.impact-panel{padding:13px 18px;background:var(--warning-soft);border-bottom:1px solid var(--warning-soft)}.impact-panel>header{display:flex;align-items:center;gap:7px;color:var(--warning);font-size:var(--fs-label)}.impact-panel>div{display:grid;grid-template-columns:1fr auto;gap:4px 10px;margin-top:10px}.impact-panel>div>span{font-size:var(--fs-caption);color:var(--warning)}.impact-panel p{grid-column:1/-1;margin:0;font-size:var(--fs-caption);line-height:1.5;color:var(--warning)}.evidence-list article>small{display:block;margin-top:8px;font-size:var(--fs-caption);color:var(--muted)}.evidence-list article>footer{display:flex;justify-content:flex-end;gap:5px;margin-top:10px}.evidence-list article>footer button{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--white);color:var(--ink)}.evidence-list article>footer button:hover{background:var(--neutral)}.evidence-list article>footer button:last-child:hover{background:var(--danger-soft);color:var(--danger)} +.rights-workspace{display:grid;grid-template-columns:minmax(270px,.8fr) minmax(0,1.5fr);gap:16px}.rights-assets,.rights-detail{overflow:hidden}.rights-asset-row{width:100%;display:grid;grid-template-columns:34px 1fr auto;gap:10px;align-items:center;padding:12px 16px;border:0;border-top:1px solid var(--line-soft);background:var(--white);text-align:left;color:var(--ink)}.rights-asset-row:hover,.rights-asset-row.active{background:var(--neutral)}.rights-asset-row.active{box-shadow:inset 2px 0 var(--source)}.rights-asset-icon{width:30px;height:30px;display:grid;place-items:center;border-radius:var(--r-sm);background:var(--neutral);color:var(--success)}.rights-asset-row>span:nth-child(2){display:flex;flex-direction:column;min-width:0}.rights-asset-row strong{font-size:var(--fs-meta)}.rights-asset-row small{font-size:var(--fs-caption);color:var(--muted);margin-top:3px}.rights-gate{display:flex;gap:10px;align-items:center;padding:12px 18px;border-bottom:1px solid var(--line-soft)}.rights-gate-pass{background:var(--success-soft);color:var(--success)}.rights-gate-blocked{background:var(--warning-soft);color:var(--warning)}.rights-gate>div{display:flex;flex-direction:column}.rights-gate strong{font-size:var(--fs-label)}.rights-gate span{font-size:var(--fs-caption);margin-top:3px}.rights-list article{padding:15px 18px;border-bottom:1px solid var(--line-soft)}.rights-list article>header{display:flex;align-items:center;justify-content:space-between}.rights-list article>header>div{display:flex;flex-direction:column}.rights-list article>header strong{font-size:var(--fs-meta)}.rights-list article>header span{font-size:var(--fs-caption);color:var(--muted);margin-top:3px}.rights-list dl{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:13px 0 0}.rights-list dt{font-size:var(--fs-micro);color:var(--muted)}.rights-list dd{font-size:var(--fs-label);margin:4px 0 0}.rights-list article>p{font-size:var(--fs-caption);color:var(--muted);padding:8px 10px;background:var(--surface-muted);margin:11px 0 0}.rights-list article>footer{display:flex;justify-content:flex-end;gap:5px;margin-top:10px}.rights-list article>footer button{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--white);color:var(--ink)} @media(max-width:900px){.rights-workspace{grid-template-columns:1fr}}@media(max-width:600px){.rights-list dl{grid-template-columns:repeat(2,1fr)}} -.decision-section{margin-bottom:16px;overflow:hidden}.decision-section>article{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:10px;padding:13px 18px;border-top:1px solid var(--line-soft)}.decision-section>article>svg{color:var(--amber)}.decision-section>article>div{display:flex;flex-direction:column;min-width:0}.decision-section>article strong{font-size:11px}.decision-section>article span{font-size:9px;color:var(--muted);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.decision-options{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:6px;margin:14px 0}.decision-options label{display:flex;align-items:flex-start;gap:10px;padding:12px;border-top:1px solid var(--line-soft)}.decision-options label:first-child{border-top:0}.decision-options input{margin-top:3px}.decision-options label>div{display:flex;flex-direction:column}.decision-options strong{font-size:11px}.decision-options span{font-size:10px;color:var(--muted);margin-top:3px}.decision-options small{font-size:9px;color:var(--muted);margin-top:5px} -.brief-revision-note{padding:9px 18px;background:#fff8e8;border-top:1px solid #ead9a8;color:#75643d;font-size:9px} +.decision-section{margin-bottom:16px;overflow:hidden}.decision-section>article{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:10px;padding:13px 18px;border-top:1px solid var(--line-soft)}.decision-section>article>svg{color:var(--warning)}.decision-section>article>div{display:flex;flex-direction:column;min-width:0}.decision-section>article strong{font-size:var(--fs-meta)}.decision-section>article span{font-size:var(--fs-caption);color:var(--muted);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.decision-options{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:var(--r-ctl);margin:14px 0}.decision-options label{display:flex;align-items:flex-start;gap:10px;padding:12px;border-top:1px solid var(--line-soft)}.decision-options label:first-child{border-top:0}.decision-options input{margin-top:3px}.decision-options label>div{display:flex;flex-direction:column}.decision-options strong{font-size:var(--fs-meta)}.decision-options span{font-size:var(--fs-label);color:var(--muted);margin-top:3px}.decision-options small{font-size:var(--fs-caption);color:var(--muted);margin-top:5px} +.brief-revision-note{padding:9px 18px;background:var(--warning-soft);border-top:1px solid var(--warning-soft);color:var(--warning);font-size:var(--fs-caption)} @media(max-width:900px){.asset-workspace,.strategy-board{grid-template-columns:1fr}}@media(max-width:600px){.review-public-header{padding:0 14px}.review-public-content{width:calc(100% - 20px);margin-top:12px}.review-summary{flex-direction:column}.review-summary>div:last-child{align-items:flex-start}.review-shot-list dl{grid-template-columns:1fr}.review-decision{position:static}.review-decision>div:last-child{flex-direction:column}.export-actions{flex-wrap:wrap}.asset-workspace{grid-template-columns:1fr}} -.script-lineage{min-height:38px;flex-wrap:wrap}.script-lineage span{display:flex;align-items:center;gap:5px}.script-lineage code{white-space:normal;text-align:right}.review-summary-band{display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--line)}.review-summary-band>div{min-height:54px;padding:11px 16px;display:grid;grid-template-columns:18px 1fr;align-items:center;column-gap:7px;border-right:1px solid var(--line-soft)}.review-summary-band>div:last-child{border-right:0}.review-summary-band svg{grid-row:1/3;color:var(--muted)}.review-summary-band span{font-size:9px;color:var(--muted)}.review-summary-band strong{font-size:11px}.danger-text{color:#ac3e35}.review-conclusion{display:flex;gap:10px;padding:12px 18px;background:#f4f7f5;border-bottom:1px solid var(--line-soft)}.review-conclusion>svg{color:var(--green);margin-top:2px}.review-conclusion span,.review-conclusion small{font-size:9px;color:var(--muted)}.review-conclusion p{font-size:11px;line-height:1.55;margin:3px 0}.review-history{border-top:1px solid var(--line)}.review-history>header{display:flex;align-items:center;gap:7px;padding:11px 17px;background:#f6f8f7}.review-history>header strong{font-size:10px}.review-history>div{display:grid;grid-template-columns:60px 90px minmax(0,1fr) auto;align-items:center;gap:10px;padding:9px 17px;border-top:1px solid var(--line-soft)}.review-history>div>span,.review-history>div>small{font-size:9px;color:var(--muted)}.review-history p{font-size:10px;margin:0}.shot-comment>div{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}.shot-comment>div span{margin:0}.shot-comment button{border:0;background:transparent;color:var(--green);display:flex;align-items:center;gap:4px;font-size:9px}.shot-comment.resolved{opacity:.58;border-left-color:var(--line)}.change-field-picker{margin:14px 0}.change-field-picker>span{display:block;font-size:10px;font-weight:650;margin-bottom:6px}.change-field-picker>label{display:flex;align-items:center;gap:9px;padding:8px 4px;border-bottom:1px solid var(--line-soft)}.change-field-picker label>div{display:flex;flex-direction:column;gap:2px}.change-field-picker strong{font-size:10px}.change-field-picker code{font-size:9px;color:var(--muted)}.grant-create-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}.grant-create-row .button{height:34px}.grant-history{margin-top:18px;border-top:1px solid var(--line)}.grant-history>header{display:flex;align-items:center;gap:7px;padding:12px 0}.grant-history>header strong{font-size:10px}.grant-history>header span{margin-left:auto;font-size:9px;color:var(--muted)}.grant-history>p{font-size:10px;color:var(--muted)}.grant-history>div{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line-soft)}.grant-history>div>div{display:flex;flex-direction:column;gap:3px;min-width:0}.grant-history strong{font-size:10px}.grant-history span{font-size:9px;color:var(--muted)} +.script-lineage{min-height:38px;flex-wrap:wrap}.script-lineage span{display:flex;align-items:center;gap:5px}.script-lineage code{white-space:normal;text-align:right}.review-summary-band{display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--line)}.review-summary-band>div{min-height:54px;padding:11px 16px;display:grid;grid-template-columns:18px 1fr;align-items:center;column-gap:7px;border-right:1px solid var(--line-soft)}.review-summary-band>div:last-child{border-right:0}.review-summary-band svg{grid-row:1/3;color:var(--muted)}.review-summary-band span{font-size:var(--fs-caption);color:var(--muted)}.review-summary-band strong{font-size:var(--fs-meta)}.danger-text{color:var(--danger)}.review-conclusion{display:flex;gap:10px;padding:12px 18px;background:var(--surface-raised);border-bottom:1px solid var(--line-soft)}.review-conclusion>svg{color:var(--success);margin-top:2px}.review-conclusion span,.review-conclusion small{font-size:var(--fs-caption);color:var(--muted)}.review-conclusion p{font-size:var(--fs-meta);line-height:1.55;margin:3px 0}.review-history{border-top:1px solid var(--line)}.review-history>header{display:flex;align-items:center;gap:7px;padding:11px 17px;background:var(--surface-muted)}.review-history>header strong{font-size:var(--fs-label)}.review-history>div{display:grid;grid-template-columns:60px 90px minmax(0,1fr) auto;align-items:center;gap:10px;padding:9px 17px;border-top:1px solid var(--line-soft)}.review-history>div>span,.review-history>div>small{font-size:var(--fs-caption);color:var(--muted)}.review-history p{font-size:var(--fs-label);margin:0}.shot-comment>div{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}.shot-comment>div span{margin:0}.shot-comment button{border:0;background:transparent;color:var(--success);display:flex;align-items:center;gap:4px;font-size:var(--fs-caption)}.shot-comment.resolved{opacity:.58;border-left-color:var(--line)}.change-field-picker{margin:14px 0}.change-field-picker>span{display:block;font-size:var(--fs-label);font-weight:650;margin-bottom:6px}.change-field-picker>label{display:flex;align-items:center;gap:9px;padding:8px 4px;border-bottom:1px solid var(--line-soft)}.change-field-picker label>div{display:flex;flex-direction:column;gap:2px}.change-field-picker strong{font-size:var(--fs-label)}.change-field-picker code{font-size:var(--fs-caption);color:var(--muted)}.grant-create-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}.grant-create-row .button{height:34px}.grant-history{margin-top:18px;border-top:1px solid var(--line)}.grant-history>header{display:flex;align-items:center;gap:7px;padding:12px 0}.grant-history>header strong{font-size:var(--fs-label)}.grant-history>header span{margin-left:auto;font-size:var(--fs-caption);color:var(--muted)}.grant-history>p{font-size:var(--fs-label);color:var(--muted)}.grant-history>div{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line-soft)}.grant-history>div>div{display:flex;flex-direction:column;gap:3px;min-width:0}.grant-history strong{font-size:var(--fs-label)}.grant-history span{font-size:var(--fs-caption);color:var(--muted)} @media(max-width:600px){.review-summary-band{grid-template-columns:1fr}.review-summary-band>div{border-right:0;border-bottom:1px solid var(--line-soft)}.review-history>div{grid-template-columns:55px 75px minmax(0,1fr)}.review-history>div>small{grid-column:3}.grant-create-row{grid-template-columns:1fr}.script-lineage code{margin-left:0;text-align:left}.review-bar>div:last-child{flex-wrap:wrap}} -.artifact-item{min-width:210px;max-width:100%;display:grid;grid-template-columns:16px minmax(0,1fr) 32px 32px;align-items:center;gap:7px;border:1px solid var(--line);padding:7px 8px;border-radius:5px}.artifact-item>div{display:flex;flex-direction:column;min-width:0}.artifact-item strong{font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.artifact-item span{font-size:8px;color:var(--muted);margin-top:2px}.artifact-item .icon-button{width:32px;height:32px;display:grid;border:0;padding:0;color:var(--muted);text-decoration:none}.artifact-item .icon-button:hover{color:var(--ink)}.artifact-item .icon-button:disabled{opacity:.45}@media(max-width:600px){.artifact-item{min-width:100%;grid-template-columns:16px minmax(0,1fr) 32px 32px}} -.result-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:#fff;border:1px solid var(--line);border-radius:7px;margin-bottom:12px}.result-summary>div{min-height:72px;padding:13px 16px;display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;column-gap:8px;border-right:1px solid var(--line-soft)}.result-summary>div:last-child{border-right:0}.result-summary svg{grid-row:1/3;color:var(--muted)}.result-summary span{font-size:9px;color:var(--muted)}.result-summary strong{font-size:18px;line-height:1}.section-count{font-size:9px;color:var(--muted)}.result-observations{margin-top:12px}.result-table{min-width:870px}.result-table .table-head,.result-table .table-row{grid-template-columns:150px 130px 110px 125px 64px minmax(190px,1fr)}.result-table .table-row>span{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;min-width:0}.result-table .table-row>span>strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.result-table .table-row>span>small{font-size:8px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.result-table .result-roi{font-size:15px;font-weight:720;color:var(--green)}.result-table .metric-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px 9px}.result-table .metric-list small{display:flex;justify-content:space-between;gap:6px}.results-columns{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.result-history{overflow:hidden}.batch-list article{display:grid;grid-template-columns:24px minmax(0,1fr) auto;align-items:center;gap:10px;padding:13px 17px;border-top:1px solid var(--line-soft)}.batch-list article>svg{color:var(--muted)}.batch-list article>div{display:flex;flex-direction:column;gap:3px;min-width:0}.batch-list strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.batch-list span,.batch-list code{font-size:8px;color:var(--muted)}.rating-list article{padding:14px 17px;border-top:1px solid var(--line-soft)}.rating-list header{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.rating-list header>div{display:flex;flex-direction:column;min-width:0}.rating-list strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rating-list span{font-size:8px;color:var(--muted);margin-top:3px}.rating-list p{font-size:10px;line-height:1.55;margin:10px 0}.rating-list footer{padding:8px 10px;background:#f4f7f5;border-left:2px solid var(--green);font-size:9px;color:var(--muted)}.observation-picker{grid-column:1/-1;border:1px solid var(--line);border-radius:6px;max-height:190px;overflow:auto}.observation-picker>span{display:block;padding:9px 11px;font-size:9px;color:var(--muted);background:#f6f8f7}.observation-picker label{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border-top:1px solid var(--line-soft)}.observation-picker input{margin-top:2px}.observation-picker label>div{display:flex;flex-direction:column;gap:2px}.observation-picker strong{font-size:10px}.observation-picker label span{font-size:8px;color:var(--muted)} +.artifact-item{min-width:210px;max-width:100%;display:grid;grid-template-columns:16px minmax(0,1fr) 32px 32px;align-items:center;gap:7px;border:1px solid var(--line);padding:7px 8px;border-radius:var(--r-sm)}.artifact-item>div{display:flex;flex-direction:column;min-width:0}.artifact-item strong{font-size:var(--fs-caption);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.artifact-item span{font-size:var(--fs-micro);color:var(--muted);margin-top:2px}.artifact-item .icon-button{width:32px;height:32px;display:grid;border:0;padding:0;color:var(--muted);text-decoration:none}.artifact-item .icon-button:hover{color:var(--ink)}.artifact-item .icon-button:disabled{opacity:.45}@media(max-width:600px){.artifact-item{min-width:100%;grid-template-columns:16px minmax(0,1fr) 32px 32px}} +.result-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--white);border:1px solid var(--line);border-radius:var(--r-ctl);margin-bottom:12px}.result-summary>div{min-height:72px;padding:13px 16px;display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;column-gap:8px;border-right:1px solid var(--line-soft)}.result-summary>div:last-child{border-right:0}.result-summary svg{grid-row:1/3;color:var(--muted)}.result-summary span{font-size:var(--fs-caption);color:var(--muted)}.result-summary strong{font-size:18px;line-height:1}.section-count{font-size:var(--fs-caption);color:var(--muted)}.result-observations{margin-top:12px}.result-table{min-width:870px}.result-table .table-head,.result-table .table-row{grid-template-columns:150px 130px 110px 125px 64px minmax(190px,1fr)}.result-table .table-row>span{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;min-width:0}.result-table .table-row>span>strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.result-table .table-row>span>small{font-size:var(--fs-micro);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.result-table .result-roi{font-size:15px;font-weight:720;color:var(--success)}.result-table .metric-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px 9px}.result-table .metric-list small{display:flex;justify-content:space-between;gap:6px}.results-columns{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.result-history{overflow:hidden}.batch-list article{display:grid;grid-template-columns:24px minmax(0,1fr) auto;align-items:center;gap:10px;padding:13px 17px;border-top:1px solid var(--line-soft)}.batch-list article>svg{color:var(--muted)}.batch-list article>div{display:flex;flex-direction:column;gap:3px;min-width:0}.batch-list strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.batch-list span,.batch-list code{font-size:var(--fs-micro);color:var(--muted)}.rating-list article{padding:14px 17px;border-top:1px solid var(--line-soft)}.rating-list header{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.rating-list header>div{display:flex;flex-direction:column;min-width:0}.rating-list strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rating-list span{font-size:var(--fs-micro);color:var(--muted);margin-top:3px}.rating-list p{font-size:var(--fs-label);line-height:1.55;margin:10px 0}.rating-list footer{padding:8px 10px;background:var(--surface-raised);border-left:2px solid var(--success);font-size:var(--fs-caption);color:var(--muted)}.observation-picker{grid-column:1/-1;border:1px solid var(--line);border-radius:var(--r-ctl);max-height:190px;overflow:auto}.observation-picker>span{display:block;padding:9px 11px;font-size:var(--fs-caption);color:var(--muted);background:var(--surface-muted)}.observation-picker label{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border-top:1px solid var(--line-soft)}.observation-picker input{margin-top:2px}.observation-picker label>div{display:flex;flex-direction:column;gap:2px}.observation-picker strong{font-size:var(--fs-label)}.observation-picker label span{font-size:var(--fs-micro);color:var(--muted)} @media(max-width:900px){.results-columns{grid-template-columns:1fr}.result-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.result-summary>div:nth-child(2){border-right:0}.result-summary>div:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}} @media(max-width:600px){.result-summary>div{min-height:62px;padding:10px 12px}.result-summary strong{font-size:16px}.results-columns{gap:12px;margin-top:12px}} -.lineage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.lineage-search{height:36px;min-width:260px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:6px;background:#fff;padding:0 10px;color:var(--muted)}.lineage-search input{border:0;outline:0;min-width:0;width:100%;color:var(--ink);font-size:11px}.lineage-direction{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#fff}.lineage-direction button{height:34px;display:flex;align-items:center;gap:5px;border:0;border-left:1px solid var(--line);background:#fff;color:var(--muted);padding:0 11px;font-size:10px}.lineage-direction button:first-child{border-left:0}.lineage-direction button.active{background:var(--sidebar);color:#fff}.lineage-loading{min-height:240px;display:grid;place-items:center}.lineage-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-radius:7px;background:#fff;margin-bottom:12px}.lineage-summary>div{min-height:62px;padding:11px 15px;display:grid;grid-template-columns:20px minmax(0,1fr);align-items:center;column-gap:7px;border-right:1px solid var(--line-soft)}.lineage-summary>div:last-child{border-right:0}.lineage-summary svg{grid-row:1/3;color:var(--muted)}.lineage-summary span{font-size:9px;color:var(--muted)}.lineage-summary strong{font-size:17px}.lineage-board{display:grid;grid-template-columns:repeat(9,minmax(150px,1fr));gap:1px;overflow-x:auto;border:1px solid var(--line);border-radius:7px;background:var(--line);margin-bottom:16px;transition:opacity .15s}.lineage-board.is-loading{opacity:.55}.lineage-stage{min-height:230px;background:#f6f8f7}.lineage-stage>header{height:38px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:#fff}.lineage-stage>header span{font-size:9px;font-weight:700;color:var(--muted)}.lineage-stage>header strong{font-size:9px;color:var(--muted)}.lineage-stage>div{display:flex;flex-direction:column;gap:6px;padding:7px}.lineage-node{width:100%;min-height:78px;display:flex;flex-direction:column;align-items:flex-start;gap:5px;border:1px solid var(--line);border-radius:6px;background:#fff;padding:8px;text-align:left;color:var(--ink)}.lineage-node:hover{border-color:#aab3af}.lineage-node.active{border-color:var(--accent);box-shadow:inset 2px 0 var(--accent)}.lineage-node>span{font-size:8px;color:var(--muted)}.lineage-node>strong{width:100%;font-size:10px;line-height:1.4;overflow-wrap:anywhere}.lineage-node>footer{width:100%;margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:5px}.lineage-node code{font-size:8px;color:var(--muted)}.lineage-node .status{font-size:8px;min-height:18px;padding:1px 5px}.lineage-detail-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,.95fr);gap:16px}.lineage-relations,.lineage-impact{overflow:hidden}.relation-row{display:grid;grid-template-columns:minmax(0,1fr) 90px minmax(0,1fr);gap:8px;align-items:center;padding:12px 16px;border-top:1px solid var(--line-soft)}.relation-row>div:first-child,.relation-row>div:nth-child(3){display:flex;flex-direction:column;min-width:0;padding:6px 8px}.relation-row span{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.relation-row small{font-size:8px;color:var(--muted);margin-top:2px}.relation-row>div:nth-child(2){display:flex;align-items:center;justify-content:center;gap:4px;color:var(--muted)}.relation-row code{font-size:8px}.relation-row p{grid-column:1/-1;margin:0;padding:6px 8px;background:#f6f8f7;color:var(--muted);font-size:9px;line-height:1.5}.relation-focus{border-left:2px solid var(--accent);background:#fff4f2}.impact-table>article{display:grid;grid-template-columns:4px minmax(0,1fr);gap:11px;padding:12px 16px;border-top:1px solid var(--line-soft)}.impact-severity{width:4px;border-radius:2px;background:#aab3af}.impact-blocked{background:#b23d34}.impact-attention{background:var(--amber)}.impact-review{background:var(--cyan)}.impact-table article>div{min-width:0}.impact-table header{display:flex;align-items:center;justify-content:space-between;gap:8px}.impact-table strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.impact-table p{font-size:9px;line-height:1.5;color:var(--muted);margin:7px 0}.impact-table footer{font-size:9px;padding:7px 9px;background:#f4f7f5;border-left:2px solid var(--green);color:#4f5c57}.lineage-impact .empty,.lineage-relations .empty{min-height:170px} +.lineage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.lineage-search{height:36px;min-width:260px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--white);padding:0 10px;color:var(--muted)}.lineage-search input{border:0;outline:0;min-width:0;width:100%;color:var(--ink);font-size:var(--fs-meta)}.lineage-direction{display:flex;border:1px solid var(--line);border-radius:var(--r-ctl);overflow:hidden;background:var(--white)}.lineage-direction button{height:34px;display:flex;align-items:center;gap:5px;border:0;border-left:1px solid var(--line);background:var(--white);color:var(--muted);padding:0 11px;font-size:var(--fs-label)}.lineage-direction button:first-child{border-left:0}.lineage-direction button.active{background:var(--sidebar);color:var(--white)}.lineage-loading{min-height:240px;display:grid;place-items:center}.lineage-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--white);margin-bottom:12px}.lineage-summary>div{min-height:62px;padding:11px 15px;display:grid;grid-template-columns:20px minmax(0,1fr);align-items:center;column-gap:7px;border-right:1px solid var(--line-soft)}.lineage-summary>div:last-child{border-right:0}.lineage-summary svg{grid-row:1/3;color:var(--muted)}.lineage-summary span{font-size:var(--fs-caption);color:var(--muted)}.lineage-summary strong{font-size:17px}.lineage-board{display:grid;grid-template-columns:repeat(9,minmax(150px,1fr));gap:1px;overflow-x:auto;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--line);margin-bottom:16px;transition:opacity .15s}.lineage-board.is-loading{opacity:.55}.lineage-stage{min-height:230px;background:var(--surface-muted)}.lineage-stage>header{height:38px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:var(--white)}.lineage-stage>header span{font-size:var(--fs-caption);font-weight:700;color:var(--muted)}.lineage-stage>header strong{font-size:var(--fs-caption);color:var(--muted)}.lineage-stage>div{display:flex;flex-direction:column;gap:6px;padding:7px}.lineage-node{width:100%;min-height:78px;display:flex;flex-direction:column;align-items:flex-start;gap:5px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--white);padding:8px;text-align:left;color:var(--ink)}.lineage-node:hover{border-color:var(--disabled)}.lineage-node.active{border-color:var(--review);box-shadow:inset 2px 0 var(--review)}.lineage-node>span{font-size:var(--fs-micro);color:var(--muted)}.lineage-node>strong{width:100%;font-size:var(--fs-label);line-height:1.4;overflow-wrap:anywhere}.lineage-node>footer{width:100%;margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:5px}.lineage-node code{font-size:var(--fs-micro);color:var(--muted)}.lineage-node .status{font-size:var(--fs-micro);min-height:18px;padding:1px 5px}.lineage-detail-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,.95fr);gap:16px}.lineage-relations,.lineage-impact{overflow:hidden}.relation-row{display:grid;grid-template-columns:minmax(0,1fr) 90px minmax(0,1fr);gap:8px;align-items:center;padding:12px 16px;border-top:1px solid var(--line-soft)}.relation-row>div:first-child,.relation-row>div:nth-child(3){display:flex;flex-direction:column;min-width:0;padding:6px 8px}.relation-row span{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.relation-row small{font-size:var(--fs-micro);color:var(--muted);margin-top:2px}.relation-row>div:nth-child(2){display:flex;align-items:center;justify-content:center;gap:4px;color:var(--muted)}.relation-row code{font-size:var(--fs-micro)}.relation-row p{grid-column:1/-1;margin:0;padding:6px 8px;background:var(--surface-muted);color:var(--muted);font-size:var(--fs-caption);line-height:1.5}.relation-focus{border-left:2px solid var(--review);background:var(--review-soft)}.impact-table>article{display:grid;grid-template-columns:4px minmax(0,1fr);gap:11px;padding:12px 16px;border-top:1px solid var(--line-soft)}.impact-severity{width:4px;border-radius:var(--r-sm);background:var(--disabled)}.impact-blocked{background:var(--danger)}.impact-attention{background:var(--warning)}.impact-review{background:var(--info)}.impact-table article>div{min-width:0}.impact-table header{display:flex;align-items:center;justify-content:space-between;gap:8px}.impact-table strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.impact-table p{font-size:var(--fs-caption);line-height:1.5;color:var(--muted);margin:7px 0}.impact-table footer{font-size:var(--fs-caption);padding:7px 9px;background:var(--surface-raised);border-left:2px solid var(--success);color:var(--muted)}.lineage-impact .empty,.lineage-relations .empty{min-height:170px} @media(max-width:980px){.lineage-detail-grid{grid-template-columns:1fr}.lineage-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.lineage-summary>div:nth-child(2){border-right:0}.lineage-summary>div:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}} @media(max-width:600px){.lineage-toolbar{align-items:stretch;flex-direction:column}.lineage-search{min-width:0;width:100%}.lineage-direction button{flex:1;justify-content:center}.lineage-summary>div{min-height:55px;padding:9px 11px}.lineage-board{grid-template-columns:repeat(9,145px)}.relation-row{grid-template-columns:minmax(0,1fr) 64px minmax(0,1fr);padding:10px}.relation-row>div:first-child,.relation-row>div:nth-child(3){padding:4px}.relation-row code{display:none}} -.tenant-switcher>label{min-width:0;flex:1;display:flex;flex-direction:column;position:relative}.tenant-switcher select{width:100%;border:0;appearance:none;background:transparent;color:#fff;font-size:12px;font-weight:650;outline:none;padding:0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tenant-switcher option{color:var(--ink);background:#fff}.tenant-switcher small{font-size:10px;color:#98a49f;margin-top:3px}.tenant-switcher>svg{flex:0 0 auto;color:#98a49f;pointer-events:none}.sidebar-footer>div:nth-child(2){flex:1}.sidebar-footer .icon-button{flex:0 0 32px;color:#b9c3bf}.status-archived,.status-revoked,.status-expired,.status-canceled{background:#edf0ef;color:#5e6864}.status-pending,.status-waiting_for_computer{background:var(--amber-soft);color:var(--amber)} -.team-page{max-width:1280px}.team-accept{min-height:76px;display:grid;grid-template-columns:38px minmax(220px,1fr) minmax(220px,.7fr) auto;gap:13px;align-items:center;padding:14px 17px;margin-bottom:18px}.team-callout-icon{width:36px;height:36px;border-radius:6px;background:var(--green-soft);color:var(--green);display:grid;place-items:center}.team-accept>div:nth-child(2){display:flex;flex-direction:column;gap:4px}.team-accept strong{font-size:12px}.team-accept span{font-size:10px;color:var(--muted)}.team-accept input{height:36px;min-width:0;border:1px solid #cfd5d2;border-radius:5px;padding:0 10px;font-size:11px}.team-loading{min-height:240px;display:grid;place-items:center}.team-members{overflow:hidden;margin-bottom:18px}.team-table{overflow-x:auto}.team-table-head,.team-member-row{min-width:780px;display:grid;grid-template-columns:minmax(260px,1.5fr) minmax(150px,.8fr) 90px 110px 40px;gap:14px;align-items:center;padding:0 18px}.team-table-head{height:39px;background:#f6f8f7;color:var(--muted);font-size:9px;text-transform:uppercase}.team-member-row{min-height:66px;border-top:1px solid var(--line-soft)}.team-person{display:flex;align-items:center;gap:11px;min-width:0}.team-person>div{width:34px;height:34px;flex:0 0 auto;border-radius:6px;background:#edf2f0;color:#395048;display:grid;place-items:center;font-size:10px;font-weight:750}.team-person>span{display:flex;flex-direction:column;min-width:0;gap:3px}.team-person strong{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.team-person strong small{display:inline-flex;margin-left:6px;padding:1px 5px;border-radius:8px;background:var(--soft);font-size:8px;color:var(--muted)}.team-person>span>small,.team-member-row time{font-size:9px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.team-member-row select{width:100%;height:32px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:0 8px;font-size:10px}.role-label{display:flex;align-items:center;gap:6px;font-size:10px;color:#4e5854}.team-member-row>div:last-child{display:flex;justify-content:flex-end;color:#a2443d}.team-admin-grid{display:grid;grid-template-columns:minmax(300px,.8fr) minmax(380px,1.2fr);gap:18px}.invite-create,.invite-list{overflow:hidden}.invite-create>.section-header>svg{color:var(--muted)}.team-form{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(140px,.8fr);gap:12px;padding:18px}.team-form>.button{grid-column:1/-1;justify-self:flex-end}.invite-token{margin:0 18px 18px;padding:12px;border:1px solid #cbe9dd;border-radius:6px;background:var(--green-soft)}.invite-token>span{display:block;font-size:9px;color:var(--green);font-weight:700;margin-bottom:7px}.invite-token>div{display:flex;align-items:center;gap:8px}.invite-token code{min-width:0;flex:1;font-size:10px;overflow-wrap:anywhere}.invite-token .icon-button{flex:0 0 32px}.invite-list>div>article{min-height:61px;display:grid;grid-template-columns:minmax(0,1fr) auto 32px;gap:9px;align-items:center;padding:10px 17px;border-top:1px solid var(--line-soft)}.invite-list article>div{display:flex;flex-direction:column;min-width:0;gap:4px}.invite-list article strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.invite-list article span{font-size:9px;color:var(--muted)}.invite-list article .icon-button{color:#a2443d}.confirm-copy{display:flex;align-items:flex-start;gap:12px;padding:5px 0}.confirm-copy>svg{flex:0 0 auto;color:var(--amber)}.confirm-copy p{margin:0;font-size:12px;line-height:1.65;color:#46504c}.waiting-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:7px}.waiting-row .waiting{margin-top:0}.connect-panel>.banner{margin:12px 0 0}.project-heading>.heading-actions{flex-wrap:wrap;justify-content:flex-end} +.tenant-switcher>label{min-width:0;flex:1;display:flex;flex-direction:column;position:relative}.tenant-switcher select{width:100%;border:0;appearance:none;background:transparent;color:var(--white);font-size:var(--fs-body);font-weight:650;outline:none;padding:0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tenant-switcher option{color:var(--ink);background:var(--white)}.tenant-switcher small{font-size:var(--fs-label);color:var(--text-soft);margin-top:3px}.tenant-switcher>svg{flex:0 0 auto;color:var(--text-soft);pointer-events:none}.sidebar-footer>div:nth-child(2){flex:1}.sidebar-footer .icon-button{flex:0 0 32px;color:var(--sidebar-text)}.status-archived,.status-revoked,.status-expired,.status-canceled{background:var(--neutral);color:var(--muted)}.status-pending,.status-waiting_for_computer{background:var(--warning-soft);color:var(--warning)} +.team-page{max-width:1280px}.team-accept{min-height:76px;display:grid;grid-template-columns:38px minmax(220px,1fr) minmax(220px,.7fr) auto;gap:13px;align-items:center;padding:14px 17px;margin-bottom:18px}.team-callout-icon{width:36px;height:36px;border-radius:var(--r-ctl);background:var(--success-soft);color:var(--success);display:grid;place-items:center}.team-accept>div:nth-child(2){display:flex;flex-direction:column;gap:4px}.team-accept strong{font-size:var(--fs-body)}.team-accept span{font-size:var(--fs-label);color:var(--muted)}.team-accept input{height:36px;min-width:0;border:1px solid var(--line-strong);border-radius:var(--r-sm);padding:0 10px;font-size:var(--fs-meta)}.team-loading{min-height:240px;display:grid;place-items:center}.team-members{overflow:hidden;margin-bottom:18px}.team-table{overflow-x:auto}.team-table-head,.team-member-row{min-width:780px;display:grid;grid-template-columns:minmax(260px,1.5fr) minmax(150px,.8fr) 90px 110px 40px;gap:14px;align-items:center;padding:0 18px}.team-table-head{height:39px;background:var(--surface-muted);color:var(--muted);font-size:var(--fs-caption);text-transform:uppercase}.team-member-row{min-height:66px;border-top:1px solid var(--line-soft)}.team-person{display:flex;align-items:center;gap:11px;min-width:0}.team-person>div{width:34px;height:34px;flex:0 0 auto;border-radius:var(--r-ctl);background:var(--neutral);color:var(--ink);display:grid;place-items:center;font-size:var(--fs-label);font-weight:750}.team-person>span{display:flex;flex-direction:column;min-width:0;gap:3px}.team-person strong{font-size:var(--fs-meta);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.team-person strong small{display:inline-flex;margin-left:6px;padding:1px 5px;border-radius:var(--r-card);background:var(--soft);font-size:var(--fs-micro);color:var(--muted)}.team-person>span>small,.team-member-row time{font-size:var(--fs-caption);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.team-member-row select{width:100%;height:32px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--white);padding:0 8px;font-size:var(--fs-label)}.role-label{display:flex;align-items:center;gap:6px;font-size:var(--fs-label);color:var(--muted)}.team-member-row>div:last-child{display:flex;justify-content:flex-end;color:var(--danger)}.team-admin-grid{display:grid;grid-template-columns:minmax(300px,.8fr) minmax(380px,1.2fr);gap:18px}.invite-create,.invite-list{overflow:hidden}.invite-create>.section-header>svg{color:var(--muted)}.team-form{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(140px,.8fr);gap:12px;padding:18px}.team-form>.button{grid-column:1/-1;justify-self:flex-end}.invite-token{margin:0 18px 18px;padding:12px;border:1px solid var(--success-soft);border-radius:var(--r-ctl);background:var(--success-soft)}.invite-token>span{display:block;font-size:var(--fs-caption);color:var(--success);font-weight:700;margin-bottom:7px}.invite-token>div{display:flex;align-items:center;gap:8px}.invite-token code{min-width:0;flex:1;font-size:var(--fs-label);overflow-wrap:anywhere}.invite-token .icon-button{flex:0 0 32px}.invite-list>div>article{min-height:61px;display:grid;grid-template-columns:minmax(0,1fr) auto 32px;gap:9px;align-items:center;padding:10px 17px;border-top:1px solid var(--line-soft)}.invite-list article>div{display:flex;flex-direction:column;min-width:0;gap:4px}.invite-list article strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.invite-list article span{font-size:var(--fs-caption);color:var(--muted)}.invite-list article .icon-button{color:var(--danger)}.confirm-copy{display:flex;align-items:flex-start;gap:12px;padding:5px 0}.confirm-copy>svg{flex:0 0 auto;color:var(--warning)}.confirm-copy p{margin:0;font-size:var(--fs-body);line-height:1.65;color:var(--ink)}.waiting-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:7px}.waiting-row .waiting{margin-top:0}.connect-panel>.banner{margin:12px 0 0}.project-heading>.heading-actions{flex-wrap:wrap;justify-content:flex-end} @media(max-width:900px){.team-accept{grid-template-columns:38px minmax(0,1fr) auto}.team-accept input{grid-column:2/3}.team-accept>.button{grid-column:3}.team-admin-grid{grid-template-columns:1fr}.project-heading{align-items:flex-start}} -@media(max-width:600px){.tenant-switcher select{font-size:11px}.team-accept{grid-template-columns:36px minmax(0,1fr);padding:13px}.team-accept input{grid-column:1/-1;width:100%}.team-accept>.button{grid-column:1/-1;width:100%}.team-table-head,.team-member-row{min-width:700px}.team-form{grid-template-columns:1fr}.team-form>.button{width:100%}.project-heading{flex-direction:column}.project-heading>.heading-actions{width:100%;justify-content:flex-start}.project-heading>.heading-actions .button{flex:1}.waiting-row{align-items:flex-start;flex-direction:column}.waiting-row .button{width:100%}} -.submission-loading{min-height:240px;display:grid;place-items:center}.submission-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:#fff;border:1px solid var(--line);border-radius:7px;margin-bottom:12px}.submission-summary>div{min-height:66px;padding:11px 15px;display:grid;grid-template-columns:21px minmax(0,1fr);align-items:center;column-gap:8px;border-right:1px solid var(--line-soft)}.submission-summary>div:last-child{border-right:0}.submission-summary svg{grid-row:1/3;color:var(--muted)}.submission-summary span{font-size:9px;color:var(--muted)}.submission-summary strong{font-size:17px}.submission-workspace{display:grid;grid-template-columns:minmax(245px,.32fr) minmax(0,1fr);gap:14px;align-items:start}.submission-list,.submission-detail{overflow:hidden}.submission-list>div>button{width:100%;min-height:62px;display:grid;grid-template-columns:30px minmax(0,1fr) auto 15px;align-items:center;gap:9px;padding:9px 12px;border:0;border-top:1px solid var(--line-soft);background:#fff;text-align:left;color:var(--ink)}.submission-list>div>button:hover{background:#f7f9f8}.submission-list>div>button.active{background:#eef5f2;box-shadow:inset 3px 0 var(--green)}.submission-list>div>button>div{display:flex;flex-direction:column;min-width:0;gap:3px}.submission-list strong{font-size:10px}.submission-list small{font-size:8px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.submission-type{width:28px;height:28px;border-radius:5px;background:#edf1ef;display:grid;place-items:center;font-size:8px;font-weight:750;color:#53615b}.submission-list>div>button.active .submission-type{background:#dbece5;color:var(--green)}.revision-strip{height:48px;display:flex;align-items:center;gap:6px;padding:6px 15px;border-top:1px solid var(--line-soft);border-bottom:1px solid var(--line);overflow-x:auto;background:#f7f9f8}.revision-strip button{height:32px;min-width:92px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:5px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:0 7px;color:var(--ink)}.revision-strip button.active{border-color:var(--green);box-shadow:inset 0 -2px var(--green)}.revision-strip span{font-size:9px;font-weight:750}.revision-strip small{font-size:8px;color:var(--muted);overflow:hidden}.revision-strip i{grid-column:1/-1;display:none}.revision-facts{display:grid;grid-template-columns:1.25fr 1fr .55fr .55fr;border-bottom:1px solid var(--line)}.revision-facts>div{min-height:49px;padding:9px 13px;display:flex;flex-direction:column;justify-content:center;gap:4px;border-right:1px solid var(--line-soft);min-width:0}.revision-facts>div:last-child{border-right:0}.revision-facts span{font-size:8px;color:var(--muted)}.revision-facts strong,.revision-facts code{font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.submission-detail>.banner{margin:10px 14px}.revision-compare{display:flex;align-items:center;gap:9px;padding:9px 14px;background:#f3f6f5;border-bottom:1px solid var(--line-soft)}.revision-compare svg{color:var(--cyan)}.revision-compare>div{display:flex;flex-direction:column;gap:2px}.revision-compare strong{font-size:9px}.revision-compare span{font-size:8px;color:var(--muted)}.submission-tabs{display:grid;grid-template-columns:minmax(0,1fr) 255px;min-height:300px}.submission-tabs>section{border-right:1px solid var(--line)}.submission-tabs header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-bottom:1px solid var(--line-soft);background:#fafbfb}.submission-tabs header strong{font-size:9px}.submission-tabs header span{font-size:8px;color:var(--muted)}.object-list article{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:11px 13px;border-bottom:1px solid var(--line-soft)}.object-list article>div{display:flex;flex-direction:column;gap:3px;min-width:0}.object-list article strong{font-size:10px;overflow-wrap:anywhere}.object-list article span,.object-list article>code{font-size:8px;color:var(--muted)}.object-list details{grid-column:1/-1}.object-list summary{cursor:pointer;font-size:8px;color:var(--green)}.object-list pre{max-height:220px;overflow:auto;margin:8px 0 0;padding:9px;background:#f4f6f5;border:1px solid var(--line-soft);border-radius:4px;font-size:8px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.submission-tabs>aside{min-width:0}.disclosure-summary dl{display:grid;grid-template-columns:repeat(3,1fr);margin:0;border-bottom:1px solid var(--line-soft)}.disclosure-summary dl>div{padding:8px 5px;text-align:center;border-right:1px solid var(--line-soft)}.disclosure-summary dl>div:last-child{border-right:0}.disclosure-summary dt{font-size:7px;color:var(--muted)}.disclosure-summary dd{margin:3px 0 0;font-size:12px;font-weight:750}.disclosure-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px;padding:8px 12px;border-bottom:1px solid var(--line-soft)}.disclosure-row span{font-size:8px;overflow:hidden;text-overflow:ellipsis}.disclosure-row strong{font-size:8px;color:var(--green)}.disclosure-row code{grid-column:1/-1;font-size:7px;color:var(--muted)}.feedback-list{border-top:1px solid var(--line)}.feedback-list article{padding:9px 12px;border-bottom:1px solid var(--line-soft)}.feedback-list article span{font-size:8px;color:var(--cyan)}.feedback-list article p{margin:5px 0;font-size:9px;line-height:1.5}.feedback-list article small{font-size:7px;color:var(--muted)}.submission-decision{border-top:1px solid var(--line);background:#f7f9f8;padding:12px 14px}.decision-segment{display:inline-flex;border:1px solid var(--line);border-radius:5px;overflow:hidden;background:#fff;margin-bottom:10px}.decision-segment button{height:31px;display:flex;align-items:center;gap:5px;border:0;border-left:1px solid var(--line);background:#fff;color:var(--muted);padding:0 12px;font-size:9px}.decision-segment button:first-child{border-left:0}.decision-segment button.active{background:var(--sidebar);color:#fff}.decision-fields{display:grid;grid-template-columns:minmax(180px,.55fr) minmax(260px,1fr) auto;align-items:end;gap:10px}.decision-fields>.field:only-of-type{grid-column:1/3}.decision-fields textarea{resize:vertical;min-height:65px}.decision-fields>.button{margin-bottom:1px}.status-submitted,.status-in_review{background:#e5f0f6;color:#28607a}.status-changes_requested{background:var(--amber-soft);color:var(--amber)}.status-superseded{background:#edf0ef;color:#5e6864} +@media(max-width:600px){.tenant-switcher select{font-size:var(--fs-meta)}.team-accept{grid-template-columns:36px minmax(0,1fr);padding:13px}.team-accept input{grid-column:1/-1;width:100%}.team-accept>.button{grid-column:1/-1;width:100%}.team-table{overflow:visible}.team-table-head{display:none}.team-member-row{min-width:0;grid-template-columns:minmax(0,1fr) auto;gap:11px 12px;padding:14px 18px}.team-member-row>.team-person{grid-column:1/-1}.team-member-row>div:nth-child(2){grid-column:1}.team-member-row>.status{grid-column:2}.team-member-row>time{grid-column:1}.team-member-row>time::before{content:"加入 ";color:var(--muted)}.team-member-row>div:last-child{grid-column:2}.team-member-row select{max-width:190px}.team-form{grid-template-columns:1fr}.team-form>.button{width:100%}.project-heading{flex-direction:column}.project-heading>.heading-actions{width:100%;justify-content:flex-start}.project-heading>.heading-actions .button{flex:1}.waiting-row{align-items:flex-start;flex-direction:column}.waiting-row .button{width:100%}} +.submission-loading{min-height:240px;display:grid;place-items:center}.submission-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--white);border:1px solid var(--line);border-radius:var(--r-ctl);margin-bottom:12px}.submission-summary>div{min-height:66px;padding:11px 15px;display:grid;grid-template-columns:21px minmax(0,1fr);align-items:center;column-gap:8px;border-right:1px solid var(--line-soft)}.submission-summary>div:last-child{border-right:0}.submission-summary svg{grid-row:1/3;color:var(--muted)}.submission-summary span{font-size:var(--fs-caption);color:var(--muted)}.submission-summary strong{font-size:17px}.submission-workspace{display:grid;grid-template-columns:minmax(245px,.32fr) minmax(0,1fr);gap:14px;align-items:start}.submission-list,.submission-detail{overflow:hidden}.submission-list>div>button{width:100%;min-height:62px;display:grid;grid-template-columns:30px minmax(0,1fr) auto 15px;align-items:center;gap:9px;padding:9px 12px;border:0;border-top:1px solid var(--line-soft);background:var(--white);text-align:left;color:var(--ink)}.submission-list>div>button:hover{background:var(--surface-muted)}.submission-list>div>button.active{background:var(--review-soft);box-shadow:inset 3px 0 var(--review)}.submission-list>div>button>div{display:flex;flex-direction:column;min-width:0;gap:3px}.submission-list strong{font-size:var(--fs-label)}.submission-list small{font-size:var(--fs-micro);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.submission-type{width:28px;height:28px;border-radius:var(--r-sm);background:var(--neutral);display:grid;place-items:center;font-size:var(--fs-micro);font-weight:750;color:var(--muted)}.submission-list>div>button.active .submission-type{background:var(--review-soft);color:var(--review)}.revision-strip{height:48px;display:flex;align-items:center;gap:6px;padding:6px 15px;border-top:1px solid var(--line-soft);border-bottom:1px solid var(--line);overflow-x:auto;background:var(--surface-muted)}.revision-strip button{height:32px;min-width:92px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:5px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--white);padding:0 7px;color:var(--ink)}.revision-strip button.active{border-color:var(--review);box-shadow:inset 0 -2px var(--review)}.revision-strip span{font-size:var(--fs-caption);font-weight:750}.revision-strip small{font-size:var(--fs-micro);color:var(--muted);overflow:hidden}.revision-strip i{grid-column:1/-1;display:none}.revision-facts{display:grid;grid-template-columns:1.25fr 1fr .55fr .55fr;border-bottom:1px solid var(--line)}.revision-facts>div{min-height:49px;padding:9px 13px;display:flex;flex-direction:column;justify-content:center;gap:4px;border-right:1px solid var(--line-soft);min-width:0}.revision-facts>div:last-child{border-right:0}.revision-facts span{font-size:var(--fs-micro);color:var(--muted)}.revision-facts strong,.revision-facts code{font-size:var(--fs-caption);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.submission-detail>.banner{margin:10px 14px}.revision-compare{display:flex;align-items:center;gap:9px;padding:9px 14px;background:var(--surface-raised);border-bottom:1px solid var(--line-soft)}.revision-compare svg{color:var(--info)}.revision-compare>div{display:flex;flex-direction:column;gap:2px}.revision-compare strong{font-size:var(--fs-caption)}.revision-compare span{font-size:var(--fs-micro);color:var(--muted)}.submission-tabs{display:grid;grid-template-columns:minmax(0,1fr) 255px;min-height:300px}.submission-tabs>section{border-right:1px solid var(--line)}.submission-tabs header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 13px;border-bottom:1px solid var(--line-soft);background:var(--surface-subtle)}.submission-tabs header strong{font-size:var(--fs-caption)}.submission-tabs header span{font-size:var(--fs-micro);color:var(--muted)}.object-list article{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:11px 13px;border-bottom:1px solid var(--line-soft)}.object-list article>div{display:flex;flex-direction:column;gap:3px;min-width:0}.object-list article strong{font-size:var(--fs-label);overflow-wrap:anywhere}.object-list article span,.object-list article>code{font-size:var(--fs-micro);color:var(--muted)}.object-list details{grid-column:1/-1}.object-list summary{cursor:pointer;font-size:var(--fs-micro);color:var(--link)}.object-list pre{max-height:220px;overflow:auto;margin:8px 0 0;padding:9px;background:var(--surface-raised);border:1px solid var(--line-soft);border-radius:var(--r-sm);font-size:var(--fs-micro);line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.submission-tabs>aside{min-width:0}.disclosure-summary dl{display:grid;grid-template-columns:repeat(3,1fr);margin:0;border-bottom:1px solid var(--line-soft)}.disclosure-summary dl>div{padding:8px 5px;text-align:center;border-right:1px solid var(--line-soft)}.disclosure-summary dl>div:last-child{border-right:0}.disclosure-summary dt{font-size:var(--fs-micro);color:var(--muted)}.disclosure-summary dd{margin:3px 0 0;font-size:var(--fs-body);font-weight:750}.disclosure-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px;padding:8px 12px;border-bottom:1px solid var(--line-soft)}.disclosure-row span{font-size:var(--fs-micro);overflow:hidden;text-overflow:ellipsis}.disclosure-row strong{font-size:var(--fs-micro);color:var(--success)}.disclosure-row code{grid-column:1/-1;font-size:var(--fs-micro);color:var(--muted)}.feedback-list{border-top:1px solid var(--line)}.feedback-list article{padding:9px 12px;border-bottom:1px solid var(--line-soft)}.feedback-list article span{font-size:var(--fs-micro);color:var(--info)}.feedback-list article p{margin:5px 0;font-size:var(--fs-caption);line-height:1.5}.feedback-list article small{font-size:var(--fs-micro);color:var(--muted)}.submission-decision{border-top:1px solid var(--line);background:var(--surface-muted);padding:12px 14px}.decision-segment{display:inline-flex;border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden;background:var(--white);margin-bottom:10px}.decision-segment button{height:31px;display:flex;align-items:center;gap:5px;border:0;border-left:1px solid var(--line);background:var(--white);color:var(--muted);padding:0 12px;font-size:var(--fs-caption)}.decision-segment button:first-child{border-left:0}.decision-segment button.active{background:var(--sidebar);color:var(--white)}.decision-fields{display:grid;grid-template-columns:minmax(180px,.55fr) minmax(260px,1fr) auto;align-items:end;gap:10px}.decision-fields>.field:only-of-type{grid-column:1/3}.decision-fields textarea{resize:vertical;min-height:65px}.decision-fields>.button{margin-bottom:1px}.status-submitted,.status-in_review{background:var(--review-soft);color:var(--review)}.status-changes_requested{background:var(--warning-soft);color:var(--warning)}.status-superseded{background:var(--neutral);color:var(--muted)} @media(max-width:1050px){.submission-workspace{grid-template-columns:220px minmax(0,1fr)}.submission-tabs{grid-template-columns:1fr}.submission-tabs>section{border-right:0}.submission-tabs>aside{border-top:1px solid var(--line)}.decision-fields{grid-template-columns:1fr 1fr}.decision-fields>.button{grid-column:1/-1;justify-self:end}} @media(max-width:760px){.submission-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.submission-summary>div:nth-child(2){border-right:0}.submission-summary>div:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}.submission-workspace{grid-template-columns:1fr}.submission-list>div{max-height:210px;overflow:auto}.revision-facts{grid-template-columns:1fr 1fr}.revision-facts>div:nth-child(2){border-right:0}.revision-facts>div:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}.decision-fields{grid-template-columns:1fr}.decision-fields>.field:only-of-type{grid-column:auto}.decision-fields>.button{grid-column:auto;width:100%}} -.auth-bg{background:#eef1ef}.auth-grid{pointer-events:none;background-image:linear-gradient(rgba(32,37,36,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(32,37,36,.035) 1px,transparent 1px)}.auth-logo{border-radius:8px;box-shadow:0 10px 30px rgba(32,37,36,.14)}.auth-title{background:none;color:var(--ink);-webkit-text-fill-color:initial}.auth-card{background:#fff;border-color:var(--line);border-radius:8px;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:0 20px 50px rgba(20,30,26,.08)} -.project-template-picker{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:9px;margin-bottom:16px}.template-create-inline{margin:-4px 0 18px;padding:15px;border:1px solid var(--line);border-radius:6px;background:#f6f8f7}.template-create-inline>header{display:flex;flex-direction:column;gap:3px;margin-bottom:12px}.template-create-inline>header strong{font-size:11px}.template-create-inline>header span{font-size:9px;color:var(--muted)}.template-create-inline>.button{display:flex;margin:12px 0 0 auto}@media(max-width:600px){.project-template-picker{grid-template-columns:1fr}.project-template-picker>.button{width:100%}.template-create-inline>.button{width:100%}} +.auth-grid{pointer-events:none;background-image:linear-gradient(rgba(32,33,37,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(32,33,37,.035) 1px,transparent 1px)} +.project-template-picker{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:9px;margin-bottom:16px}.template-create-inline{margin:-4px 0 18px;padding:15px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--surface-muted)}.template-create-inline>header{display:flex;flex-direction:column;gap:3px;margin-bottom:12px}.template-create-inline>header strong{font-size:var(--fs-meta)}.template-create-inline>header span{font-size:var(--fs-caption);color:var(--muted)}.template-create-inline>.button{display:flex;margin:12px 0 0 auto}@media(max-width:600px){.project-template-picker{grid-template-columns:1fr}.project-template-picker>.button{width:100%}.template-create-inline>.button{width:100%}} -.status-suspended{background:#fdebea;color:#a93d35}.admin-shell{min-height:100vh;background:#f5f7f6}.admin-sidebar{position:fixed;inset:0 auto 0 0;width:228px;padding:18px 13px 14px;background:#18201e;color:#fff;display:flex;flex-direction:column;z-index:35}.admin-brand{display:flex;align-items:center;gap:10px;padding:0 7px 19px;border-bottom:1px solid #303a36}.admin-brand>div:nth-child(2){display:flex;flex-direction:column;min-width:0}.admin-brand strong{font-size:14px}.admin-brand span{font-size:10px;color:#96a19d;margin-top:2px}.admin-brand>.icon-button{display:none;margin-left:auto;color:#aeb8b4}.admin-environment{display:flex;align-items:center;gap:10px;margin:15px 6px 18px;padding:9px 10px;border:1px solid #34403b;border-radius:6px;background:#222c29}.admin-environment>span,.admin-health>span{width:8px;height:8px;border-radius:50%;background:#46b889;box-shadow:0 0 0 3px rgba(70,184,137,.13);flex:0 0 auto}.admin-environment>div,.admin-health>div{display:flex;flex-direction:column;gap:2px}.admin-environment strong{font-size:10px;color:#dce3e0}.admin-environment small{font-size:9px;color:#87938e}.admin-sidebar nav{display:flex;flex-direction:column;gap:3px}.admin-sidebar nav a{height:40px;width:100%;display:flex;align-items:center;gap:10px;padding:0 11px;border-radius:6px;background:transparent;color:#aeb8b4;text-align:left;text-decoration:none;font-size:12px}.admin-sidebar nav a:hover,.admin-sidebar nav a.active{background:#2b3632;color:#fff}.admin-sidebar nav a.active{box-shadow:inset 2px 0 var(--accent)}.admin-sidebar nav a strong{flex:1;font-size:12px;font-weight:500}.admin-sidebar nav a small{min-width:21px;height:19px;padding:0 6px;display:grid;place-items:center;border-radius:9px;background:#36423d;color:#b8c1bd;font-size:8px}.admin-sidebar-footer{margin-top:auto;padding:15px 7px 1px;border-top:1px solid #303a36;display:flex;align-items:center;gap:9px;color:#96a19d}.admin-sidebar-footer>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:2px}.admin-sidebar-footer strong{font-size:10px;color:#e8ecea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-sidebar-footer span{font-size:9px}.admin-sidebar-footer .icon-button{flex:0 0 32px;color:#b8c1bd}.admin-main{min-height:100vh;margin-left:228px}.admin-topbar{height:62px;padding:0 30px;position:sticky;top:0;z-index:20;background:rgba(255,255,255,.95);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:18px}.admin-topbar>div:first-child{display:flex;flex-direction:column;gap:2px;min-width:0}.admin-topbar>div:first-child strong{font-size:12px}.admin-topbar>div:first-child span{font-size:9px;color:var(--muted);white-space:nowrap}.admin-topbar-actions{display:flex;align-items:center;gap:8px}.admin-topbar-actions>.icon-button{border:1px solid var(--line);background:#fff}.admin-search{width:230px;height:34px;display:flex;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:#f8f9f8;color:var(--muted)}.admin-search:focus-within{border-color:#91a09a;background:#fff}.admin-search input{width:100%;min-width:0;border:0;outline:0;background:transparent;font-size:10px;color:var(--ink)}.admin-directory-heading .admin-search{align-self:flex-end}.admin-mobile-header{display:none}.admin-page{max-width:1440px;margin:0 auto;padding:31px 34px 55px}.admin-heading{min-height:66px;margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.admin-heading h1{font-size:25px;line-height:1.2;margin:5px 0 5px}.admin-heading p{margin:0;color:var(--muted);font-size:11px}.admin-health{display:flex;align-items:center;gap:10px;padding:9px 13px;border:1px solid var(--line);border-radius:6px;background:#fff}.admin-health strong{font-size:10px}.admin-health small{font-size:8px;color:var(--muted)}.admin-loading{min-height:360px;display:grid;place-items:center}.admin-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-radius:8px;background:#fff;margin-bottom:18px}.admin-stat-grid article{height:102px;padding:18px;display:flex;align-items:center;gap:13px;border-right:1px solid var(--line-soft)}.admin-stat-grid article:last-child{border-right:0}.admin-stat-grid article>div:last-child{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:8px;min-width:0}.admin-stat-grid article strong{font-size:23px;line-height:1}.admin-stat-grid article span{font-size:10px;font-weight:650}.admin-stat-grid article small{grid-column:1/-1;margin-top:6px;font-size:8px;color:var(--muted)}.admin-overview-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,.7fr);gap:18px}.admin-table-section{overflow:hidden}.admin-table-section .section-header h2,.admin-activity .section-header h2{font-size:14px}.admin-text-button{align-self:center;border:0;background:transparent;color:var(--green);font-size:9px;font-weight:700;padding:6px}.admin-text-button:hover{text-decoration:underline}.admin-table-scroll{overflow-x:auto}.admin-tenant-table{min-width:960px}.admin-tenant-table.is-compact{min-width:760px}.admin-tenant-table>header,.admin-tenant-table>article{display:grid;grid-template-columns:minmax(210px,1.35fr) 88px 52px 52px 68px 68px 100px 42px;gap:10px;align-items:center;padding:0 17px}.admin-tenant-table.is-compact>header,.admin-tenant-table.is-compact>article{grid-template-columns:minmax(190px,1.35fr) 78px 45px 45px 60px 60px 92px}.admin-tenant-table>header{height:38px;background:#f6f8f7;color:var(--muted);font-size:8px;text-transform:uppercase}.admin-tenant-table>article{min-height:65px;border-top:1px solid var(--line-soft);font-size:10px}.admin-tenant-table>article>strong{font-size:11px}.admin-tenant-table time{font-size:8px;color:var(--muted)}.admin-tenant-name,.admin-user-name{display:flex;align-items:center;gap:10px;min-width:0}.admin-tenant-name>span{width:32px;height:32px;flex:0 0 32px;display:grid;place-items:center;border-radius:6px;background:#e9efec;color:#345347;font-size:10px;font-weight:750}.admin-tenant-name>div,.admin-user-name>div{display:flex;flex-direction:column;min-width:0;gap:3px}.admin-tenant-name strong,.admin-user-name strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-tenant-name small,.admin-user-name small{font-size:8px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-row-action{display:flex;align-items:center;justify-content:flex-end}.admin-row-action>span{font-size:8px;color:var(--muted);white-space:nowrap}.admin-row-action .icon-button{color:#a84a41}.admin-row-action .icon-button:has(.lucide-play-circle){color:var(--green)}.admin-activity{overflow:hidden}.admin-activity>article{min-height:61px;display:grid;grid-template-columns:33px minmax(0,1fr) auto;align-items:center;gap:10px;padding:10px 16px;border-top:1px solid var(--line-soft)}.admin-activity>article>div{display:flex;flex-direction:column;min-width:0;gap:3px}.admin-activity strong{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-activity span,.admin-activity small{font-size:8px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-user-avatar{width:31px;height:31px;flex:0 0 31px;display:grid;place-items:center;border-radius:50%;background:#edf1ef;color:#41534c;font-size:9px;font-weight:750}.admin-badge{width:max-content;display:inline-flex;align-items:center;gap:4px;padding:3px 7px;border-radius:9px;background:var(--green-soft);color:var(--green)!important;font-size:8px!important;font-weight:700}.admin-user-table{min-width:870px}.admin-user-table>header,.admin-user-table>article{display:grid;grid-template-columns:minmax(220px,1.15fr) 110px minmax(260px,1.4fr) 76px 90px;align-items:center;gap:13px;padding:0 18px}.admin-user-table>header{height:39px;background:#f6f8f7;color:var(--muted);font-size:8px;text-transform:uppercase}.admin-user-table>article{min-height:68px;border-top:1px solid var(--line-soft);font-size:9px}.admin-user-table time,.admin-muted{font-size:8px;color:var(--muted)}.admin-verified{font-size:8px;color:var(--green)}.admin-memberships{display:flex;align-items:center;gap:5px;overflow:hidden}.admin-memberships>span{min-width:0;max-width:145px;display:flex;flex-direction:column;gap:2px;padding:5px 7px;border:1px solid var(--line);border-radius:5px;background:#fafbfa;font-size:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-memberships small{font-size:7px;color:var(--muted)}.admin-confirm{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;align-items:start}.admin-confirm>div:first-child{width:36px;height:36px;display:grid;place-items:center;border-radius:6px}.admin-confirm>div.warning{background:var(--amber-soft);color:var(--amber)}.admin-confirm>div.success{background:var(--green-soft);color:var(--green)}.admin-confirm>div:last-child{display:flex;flex-direction:column;gap:6px}.admin-confirm strong{font-size:12px}.admin-confirm p{margin:0;color:var(--muted);font-size:10px;line-height:1.6}.is-spinning{animation:spin 1s linear infinite} +.status-suspended{background:var(--danger-soft);color:var(--danger)}.admin-shell{min-height:100vh;background:var(--surface-muted)}.admin-sidebar{position:fixed;inset:0 auto 0 0;width:228px;padding:18px 13px 14px;background:var(--sidebar);color:var(--white);display:flex;flex-direction:column;z-index:35}.admin-brand{display:flex;align-items:center;gap:10px;padding:0 7px 19px;border-bottom:1px solid var(--sidebar-hover)}.admin-brand>div:nth-child(2){display:flex;flex-direction:column;min-width:0}.admin-brand strong{font-size:14px}.admin-brand span{font-size:var(--fs-label);color:var(--text-soft);margin-top:2px}.admin-brand>.icon-button{display:none;margin-left:auto;color:var(--sidebar-text)}.admin-environment{display:flex;align-items:center;gap:10px;margin:15px 6px 18px;padding:9px 10px;border:1px solid var(--sidebar-line);border-radius:var(--r-ctl);background:var(--sidebar-panel)}.admin-environment>span,.admin-health>span{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 0 3px var(--success-ring);flex:0 0 auto}.admin-environment>div,.admin-health>div{display:flex;flex-direction:column;gap:2px}.admin-environment strong{font-size:var(--fs-label);color:var(--sidebar-strong)}.admin-environment small{font-size:var(--fs-caption);color:var(--text-soft)}.admin-sidebar nav{display:flex;flex-direction:column;gap:3px}.admin-sidebar nav a{height:40px;width:100%;display:flex;align-items:center;gap:10px;padding:0 11px;border-radius:var(--r-ctl);background:transparent;color:var(--sidebar-text);text-align:left;text-decoration:none;font-size:var(--fs-body)}.admin-sidebar nav a:hover,.admin-sidebar nav a.active{background:var(--sidebar-hover);color:var(--white)}.admin-sidebar nav a.active{box-shadow:inset 3px 0 var(--brand)}.admin-sidebar nav a.active>svg{color:var(--brand)}.admin-sidebar nav a strong{flex:1;font-size:var(--fs-body);font-weight:500}.admin-sidebar nav a small{min-width:21px;height:19px;padding:0 6px;display:grid;place-items:center;border-radius:var(--r-card);background:var(--sidebar-line);color:var(--sidebar-text);font-size:var(--fs-micro)}.admin-sidebar-footer{margin-top:auto;padding:15px 7px 1px;border-top:1px solid var(--sidebar-hover);display:flex;align-items:center;gap:9px;color:var(--text-soft)}.admin-sidebar-footer>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:2px}.admin-sidebar-footer strong{font-size:var(--fs-label);color:var(--sidebar-strong);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-sidebar-footer span{font-size:var(--fs-caption)}.admin-sidebar-footer .icon-button{flex:0 0 32px;color:var(--sidebar-text)}.admin-main{min-height:100vh;margin-left:228px}.admin-topbar{height:62px;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);padding:0 30px;position:sticky;top:0;z-index:20;background:rgba(255,255,255,.95);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:18px}.admin-topbar>div:first-child{display:flex;flex-direction:column;gap:2px;min-width:0}.admin-topbar>div:first-child strong{font-size:var(--fs-body)}.admin-topbar>div:first-child span{font-size:var(--fs-caption);color:var(--muted);white-space:nowrap}.admin-topbar-actions{display:flex;align-items:center;gap:8px}.admin-topbar-actions>.icon-button{border:1px solid var(--line);background:var(--white)}.admin-search{width:230px;height:34px;display:flex;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--surface-muted);color:var(--muted)}.admin-search:focus-within{border-color:var(--text-soft);background:var(--white)}.admin-search input{width:100%;min-width:0;border:0;outline:0;background:transparent;font-size:var(--fs-label);color:var(--ink)}.admin-directory-heading .admin-search{align-self:flex-end}.admin-mobile-header{display:none}.admin-page{max-width:1440px;margin:0 auto;padding:31px 34px 55px}.admin-heading{min-height:66px;margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.admin-heading h1{font-size:25px;line-height:1.2;margin:5px 0 5px}.admin-heading p{margin:0;color:var(--muted);font-size:var(--fs-meta)}.admin-health{display:flex;align-items:center;gap:10px;padding:9px 13px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--white)}.admin-health strong{font-size:var(--fs-label)}.admin-health small{font-size:var(--fs-micro);color:var(--muted)}.admin-loading{min-height:360px;display:grid;place-items:center}.admin-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-card);background:var(--white);margin-bottom:18px}.admin-stat-grid article{height:102px;padding:18px;display:flex;align-items:center;gap:13px;border-right:1px solid var(--line-soft)}.admin-stat-grid article:last-child{border-right:0}.admin-stat-grid article>div:last-child{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:8px;min-width:0}.admin-stat-grid article strong{font-size:23px;line-height:1}.admin-stat-grid article span{font-size:var(--fs-label);font-weight:650}.admin-stat-grid article small{grid-column:1/-1;margin-top:6px;font-size:var(--fs-micro);color:var(--muted)}.admin-overview-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,.7fr);gap:18px}.admin-table-section{overflow:hidden}.admin-table-section .section-header h2,.admin-activity .section-header h2{font-size:14px}.admin-text-button{align-self:center;border:0;background:transparent;color:var(--link);font-size:var(--fs-caption);font-weight:700;padding:6px}.admin-text-button:hover{text-decoration:underline}.admin-table-scroll{overflow-x:auto}.admin-tenant-table{min-width:960px}.admin-tenant-table.is-compact{min-width:760px}.admin-tenant-table>header,.admin-tenant-table>article{display:grid;grid-template-columns:minmax(210px,1.35fr) 88px 52px 52px 68px 68px 100px 42px;gap:10px;align-items:center;padding:0 17px}.admin-tenant-table.is-compact>header,.admin-tenant-table.is-compact>article{grid-template-columns:minmax(190px,1.35fr) 78px 45px 45px 60px 60px 92px}.admin-tenant-table>header{height:38px;background:var(--surface-muted);color:var(--muted);font-size:var(--fs-micro);text-transform:uppercase}.admin-tenant-table>article{min-height:65px;border-top:1px solid var(--line-soft);font-size:var(--fs-label)}.admin-tenant-table>article>strong{font-size:var(--fs-meta)}.admin-tenant-table time{font-size:var(--fs-micro);color:var(--muted)}.admin-tenant-name,.admin-user-name{display:flex;align-items:center;gap:10px;min-width:0}.admin-tenant-name>span{width:32px;height:32px;flex:0 0 32px;display:grid;place-items:center;border-radius:var(--r-ctl);background:var(--neutral);color:var(--ink);font-size:var(--fs-label);font-weight:750}.admin-tenant-name>div,.admin-user-name>div{display:flex;flex-direction:column;min-width:0;gap:3px}.admin-tenant-name strong,.admin-user-name strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-tenant-name small,.admin-user-name small{font-size:var(--fs-micro);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-row-action{display:flex;align-items:center;justify-content:flex-end}.admin-row-action>span{font-size:var(--fs-micro);color:var(--muted);white-space:nowrap}.admin-row-action .icon-button{color:var(--danger)}.admin-row-action .icon-button:has(.lucide-play-circle){color:var(--success)}.admin-activity{overflow:hidden}.admin-activity>article{min-height:61px;display:grid;grid-template-columns:33px minmax(0,1fr) auto;align-items:center;gap:10px;padding:10px 16px;border-top:1px solid var(--line-soft)}.admin-activity>article>div{display:flex;flex-direction:column;min-width:0;gap:3px}.admin-activity strong{font-size:var(--fs-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-activity span,.admin-activity small{font-size:var(--fs-micro);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-user-avatar{width:31px;height:31px;flex:0 0 31px;display:grid;place-items:center;border-radius:50%;background:var(--neutral);color:var(--ink);font-size:var(--fs-caption);font-weight:750}.admin-badge{width:max-content;display:inline-flex;align-items:center;gap:4px;padding:3px 7px;border-radius:var(--r-card);background:var(--success-soft);color:var(--success)!important;font-size:var(--fs-micro)!important;font-weight:700}.admin-user-table{min-width:870px}.admin-user-table>header,.admin-user-table>article{display:grid;grid-template-columns:minmax(220px,1.15fr) 110px minmax(260px,1.4fr) 76px 90px;align-items:center;gap:13px;padding:0 18px}.admin-user-table>header{height:39px;background:var(--surface-muted);color:var(--muted);font-size:var(--fs-micro);text-transform:uppercase}.admin-user-table>article{min-height:68px;border-top:1px solid var(--line-soft);font-size:var(--fs-caption)}.admin-user-table time,.admin-muted{font-size:var(--fs-micro);color:var(--muted)}.admin-verified{font-size:var(--fs-micro);color:var(--success)}.admin-memberships{display:flex;align-items:center;gap:5px;overflow:hidden}.admin-memberships>span{min-width:0;max-width:145px;display:flex;flex-direction:column;gap:2px;padding:5px 7px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--surface-subtle);font-size:var(--fs-micro);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-memberships small{font-size:var(--fs-micro);color:var(--muted)}.admin-confirm{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;align-items:start}.admin-confirm>div:first-child{width:36px;height:36px;display:grid;place-items:center;border-radius:var(--r-ctl)}.admin-confirm>div.warning{background:var(--warning-soft);color:var(--warning)}.admin-confirm>div.success{background:var(--success-soft);color:var(--success)}.admin-confirm>div:last-child{display:flex;flex-direction:column;gap:6px}.admin-confirm strong{font-size:var(--fs-body)}.admin-confirm p{margin:0;color:var(--muted);font-size:var(--fs-label);line-height:1.6}.is-spinning{animation:spin 1s linear infinite} @media(max-width:1050px){.admin-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.admin-stat-grid article:nth-child(2){border-right:0}.admin-stat-grid article:nth-child(-n+2){border-bottom:1px solid var(--line-soft)}.admin-overview-grid{grid-template-columns:1fr}.admin-activity{max-width:none}} -@media(max-width:760px){.admin-sidebar{transform:translateX(-100%);transition:transform .2s ease}.admin-sidebar.admin-sidebar-open{transform:translateX(0)}.admin-brand>.icon-button{display:grid}.admin-main{margin-left:0}.admin-mobile-header{height:52px;padding:0 13px;display:grid;grid-template-columns:32px 30px minmax(0,1fr) 32px;align-items:center;gap:8px;background:#18201e;color:#fff;position:sticky;top:0;z-index:25}.admin-mobile-header .brand-mark{width:30px;height:30px}.admin-mobile-header strong{font-size:12px}.admin-topbar{top:52px;height:56px;padding:0 14px}.admin-topbar>div:first-child span{display:none}.admin-topbar-actions>.button{width:34px;padding:0;font-size:0}.admin-search{width:min(210px,45vw)}.admin-page{padding:22px 15px 42px}.admin-heading{min-height:55px;margin-bottom:16px}.admin-heading h1{font-size:21px}.admin-health{display:none}.admin-stat-grid article{height:86px;padding:13px}.admin-stat-grid article strong{font-size:20px}.admin-overview-grid{gap:13px}} -@media(max-width:480px){.admin-topbar>div:first-child{display:none}.admin-topbar{justify-content:flex-end}.admin-topbar-actions{width:100%}.admin-search{flex:1;width:auto}.admin-stat-grid article{gap:9px}.admin-stat-grid .stat-icon{width:32px;height:32px}.admin-stat-grid article>div:last-child{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.admin-stat-grid article small{margin-top:1px}.admin-heading p{font-size:10px}} +@media(max-width:760px){.admin-sidebar{transform:translateX(-100%);transition:transform .2s ease}.admin-sidebar.admin-sidebar-open{transform:translateX(0)}.admin-brand>.icon-button{display:grid}.admin-main{margin-left:0}.admin-mobile-header{height:52px;padding:0 13px;display:grid;grid-template-columns:32px 30px minmax(0,1fr) 32px;align-items:center;gap:8px;background:var(--sidebar);color:var(--white);position:sticky;top:0;z-index:25}.admin-mobile-header .brand-mark{width:30px;height:30px}.admin-mobile-header strong{font-size:var(--fs-body)}.admin-topbar{top:52px;height:56px;padding:0 14px}.admin-topbar>div:first-child span{display:none}.admin-topbar-actions>.button{width:34px;padding:0;font-size:0}.admin-search{width:min(210px,45vw)}.admin-page{padding:22px 15px 42px}.admin-heading{min-height:55px;margin-bottom:16px}.admin-heading h1{font-size:21px}.admin-health{display:none}.admin-stat-grid article{height:86px;padding:13px}.admin-stat-grid article strong{font-size:20px}.admin-overview-grid{gap:13px}.admin-table-section .admin-table-scroll{overflow:visible}.admin-tenant-table.is-compact{min-width:0}.admin-tenant-table.is-compact>header,.admin-tenant-table.is-compact>article{grid-template-columns:minmax(0,1fr) auto}.admin-tenant-table.is-compact>header>:nth-child(n+3),.admin-tenant-table.is-compact>article>:nth-child(n+3){display:none}} +@media(max-width:480px){.admin-topbar>div:first-child{display:none}.admin-topbar{justify-content:flex-end}.admin-topbar-actions{width:100%}.admin-search{flex:1;width:auto}.admin-stat-grid article{gap:9px}.admin-stat-grid .stat-icon{width:32px;height:32px}.admin-stat-grid article>div:last-child{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.admin-stat-grid article small{margin-top:1px}.admin-heading p{font-size:var(--fs-label)}} -.spin{animation:spin 1.2s linear infinite}.status-verifying{background:#e5f0f6;color:#28607a}.status-connected{background:var(--green-soft);color:var(--green)}.status-failed{background:#fdebea;color:#a93d35}.connect-progress{min-height:68px;padding:12px 17px;display:grid;grid-template-columns:28px minmax(0,1fr) auto auto;align-items:center;gap:11px;border-bottom:1px solid var(--line-soft);background:#f6f8f7}.connect-progress>svg{color:var(--cyan)}.connect-progress>div{display:flex;min-width:0;flex-direction:column;gap:3px}.connect-progress strong{font-size:10px}.connect-progress span{font-size:9px;color:var(--muted)} -.agent-bootstrap-heading{display:grid;grid-template-columns:38px minmax(0,1fr) 20px;align-items:center;gap:11px;padding-bottom:16px;border-bottom:1px solid var(--line-soft)}.agent-bootstrap-mark{width:36px;height:36px;display:grid;place-items:center;border-radius:6px;background:#18201e;color:#e6ece9}.agent-bootstrap-heading>div:nth-child(2){display:flex;min-width:0;flex-direction:column;gap:3px}.agent-bootstrap-heading strong{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-bootstrap-heading span{font-size:9px;color:var(--muted)}.agent-bootstrap-heading>svg{color:var(--green)} -.agent-bootstrap-state{min-height:58px;margin:15px 0;display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:10px;padding:10px 12px;border-left:3px solid #aeb7b3;background:#f5f7f6}.agent-bootstrap-state>div{display:flex;min-width:0;flex-direction:column;gap:3px}.agent-bootstrap-state strong{font-size:11px}.agent-bootstrap-state span{font-size:9px;line-height:1.5;color:var(--muted)}.agent-bootstrap-state-waiting{border-color:var(--amber);background:var(--amber-soft);color:var(--amber)}.agent-bootstrap-state-progress{border-color:var(--cyan);background:#eef5f8;color:#28607a}.agent-bootstrap-state-success{border-color:var(--green);background:var(--green-soft);color:var(--green)}.agent-bootstrap-state-error{border-color:#b64b42;background:#fdf0ef;color:#a93d35} -.agent-bootstrap-steps{display:grid;grid-template-columns:1fr 1fr;gap:0;margin:0 0 14px;padding:0;list-style:none;border:1px solid var(--line);border-radius:6px}.agent-bootstrap-steps li{min-height:66px;display:grid;grid-template-columns:25px minmax(0,1fr);align-items:center;gap:9px;padding:11px 13px}.agent-bootstrap-steps li+li{border-left:1px solid var(--line)}.agent-bootstrap-steps li>span{width:24px;height:24px;display:grid;place-items:center;border-radius:50%;background:#e8eeeb;color:#345347;font-size:9px;font-weight:750}.agent-bootstrap-steps li>div{display:flex;min-width:0;flex-direction:column;gap:3px}.agent-bootstrap-steps strong{font-size:10px}.agent-bootstrap-steps p{margin:0;color:var(--muted);font-size:8px;line-height:1.45} -.agent-prompt{overflow:hidden;border:1px solid #303a36;border-radius:6px;background:#18201e;color:#e2e9e6}.agent-prompt>header{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 9px 7px 13px;border-bottom:1px solid #303a36}.agent-prompt>header>div{display:flex;min-width:0;flex-direction:column;gap:2px}.agent-prompt>header span{font-size:8px;font-weight:750;color:#65c59d}.agent-prompt>header small{font-size:8px;color:#97a39e}.agent-prompt .icon-button{color:#dce4e0;background:#25302c}.agent-prompt pre{max-height:210px;overflow:auto;margin:0;padding:14px;font-size:10px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere}.agent-prompt code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} -.agent-prompt+.banner,.agent-bootstrap-state+.banner{margin-top:11px}.manual-install{margin-top:12px;padding-top:11px;border-top:1px solid var(--line-soft)}.manual-install summary{width:max-content;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:9px;font-weight:650;cursor:pointer}.manual-install p{margin:8px 0;color:var(--muted);font-size:9px}.manual-install .command-box{padding:9px 9px 9px 11px}.manual-install .command-box code{white-space:normal;overflow-wrap:anywhere;font-size:9px}.agent-verifying,.agent-complete{min-height:88px;display:grid;grid-template-columns:26px minmax(0,1fr);align-items:center;gap:10px;padding:16px;border:1px solid var(--line);border-radius:6px}.agent-verifying>svg{color:var(--cyan)}.agent-complete>svg{color:var(--green)}.agent-verifying>div,.agent-complete>div{display:flex;flex-direction:column;gap:4px}.agent-verifying strong,.agent-complete strong{font-size:11px}.agent-verifying span,.agent-complete span{font-size:9px;line-height:1.5;color:var(--muted)} -@media(max-width:600px){.connect-progress{grid-template-columns:25px minmax(0,1fr) auto}.connect-progress>.button{grid-column:1/-1;width:100%}.agent-bootstrap-steps{grid-template-columns:1fr}.agent-bootstrap-steps li+li{border-left:0;border-top:1px solid var(--line)}.agent-prompt pre{font-size:9px}.agent-bootstrap-heading{grid-template-columns:36px minmax(0,1fr)}.agent-bootstrap-heading>svg{display:none}.agent-bootstrap-state{align-items:start}.agent-bootstrap-state>svg{margin-top:2px}} +.spin{animation:spin 1.2s linear infinite}.status-verifying{background:var(--info-soft);color:var(--info)}.status-connected{background:var(--success-soft);color:var(--success)}.status-failed{background:var(--danger-soft);color:var(--danger)}.connect-progress{min-height:68px;padding:12px 17px;display:grid;grid-template-columns:28px minmax(0,1fr) auto auto;align-items:center;gap:11px;border-bottom:1px solid var(--line-soft);background:var(--surface-muted)}.connect-progress>svg{color:var(--info)}.connect-progress>div{display:flex;min-width:0;flex-direction:column;gap:3px}.connect-progress strong{font-size:var(--fs-label)}.connect-progress span{font-size:var(--fs-caption);color:var(--muted)} +.agent-project-context{margin:0 0 18px;color:var(--muted);font-size:var(--fs-caption);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-bootstrap-state{min-height:58px;margin:0 0 15px;display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:10px;padding:10px 12px;border-left:3px solid var(--disabled);background:var(--surface-muted)}.agent-bootstrap-state>div{display:flex;min-width:0;flex-direction:column;gap:3px}.agent-bootstrap-state strong{font-size:var(--fs-meta)}.agent-bootstrap-state span{font-size:var(--fs-caption);line-height:1.5;color:var(--muted)}.agent-bootstrap-state-progress{border-color:var(--info);background:var(--info-soft);color:var(--info)}.agent-bootstrap-state-success{border-color:var(--success);background:var(--success-soft);color:var(--success)}.agent-bootstrap-state-error{border-color:var(--danger);background:var(--danger-soft);color:var(--danger)} +.agent-bootstrap-steps{margin:0;padding:0;list-style:none}.agent-bootstrap-steps li{position:relative;display:grid;grid-template-columns:26px minmax(0,1fr);align-items:start;gap:11px}.agent-bootstrap-steps li+li{margin-top:22px}.agent-bootstrap-steps li:first-child:after{content:"";position:absolute;left:12px;top:26px;width:1px;height:calc(100% + 22px);background:var(--line)}.agent-bootstrap-steps li>span{position:relative;z-index:1;width:21px;height:21px;display:grid;place-items:center;border:1px solid var(--disabled);border-radius:50%;background:var(--white);color:var(--ink);font-size:var(--fs-caption)}.agent-bootstrap-steps li>div{display:flex;min-width:0;flex-direction:column;gap:6px;padding-top:1px}.agent-bootstrap-steps strong{font-size:var(--fs-meta)}.agent-bootstrap-steps p{margin:0;color:var(--muted);font-size:var(--fs-caption);line-height:1.5} +.agent-prompt{overflow:hidden;margin-top:5px;border:1px solid var(--line);border-radius:var(--r-ctl);background:var(--surface-raised);color:var(--ink)}.agent-prompt pre{overflow:auto;margin:0;padding:12px 13px;font-size:var(--fs-label);line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}.agent-prompt-values{border-top:1px solid var(--line);color:var(--muted)}.agent-prompt code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.agent-waiting-footer{min-height:64px;margin-top:19px;padding-top:14px;border-top:1px solid var(--line-soft);display:flex;align-items:center;justify-content:space-between;gap:14px}.agent-waiting-footer>div{display:grid;grid-template-columns:10px minmax(0,1fr);align-items:center;gap:9px;min-width:0}.agent-waiting-dot{width:8px;height:8px;border-radius:50%;background:var(--text-soft)}.agent-waiting-dot.is-slow{background:var(--warning)}.agent-waiting-footer p{min-width:0;margin:0;display:flex;flex-direction:column;gap:2px}.agent-waiting-footer strong{font-size:var(--fs-caption);font-weight:600;color:var(--muted)}.agent-waiting-footer small{font-size:var(--fs-micro);line-height:1.4;color:var(--muted)}.agent-copy-button{flex:0 0 auto;background:var(--sidebar);border-color:var(--sidebar);color:var(--white)}.agent-copy-button:hover{background:var(--sidebar-hover);border-color:var(--sidebar-hover)} +.agent-bootstrap-steps+.banner,.agent-waiting-footer+.banner{margin-top:11px}.manual-install{margin-top:9px;padding-top:10px;border-top:1px solid var(--line-soft)}.manual-install summary{width:max-content;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:var(--fs-caption);font-weight:650;cursor:pointer}.manual-install p{margin:8px 0;color:var(--muted);font-size:var(--fs-caption)}.manual-install .command-box{padding:9px 9px 9px 11px}.manual-install .command-box code{white-space:normal;overflow-wrap:anywhere;font-size:var(--fs-caption)}.agent-verifying,.agent-complete{min-height:88px;display:grid;grid-template-columns:26px minmax(0,1fr);align-items:center;gap:10px;padding:16px;border:1px solid var(--line);border-radius:var(--r-ctl)}.agent-verifying>svg{color:var(--info)}.agent-complete>svg{color:var(--success)}.agent-verifying>div,.agent-complete>div{display:flex;flex-direction:column;gap:4px}.agent-verifying strong,.agent-complete strong{font-size:var(--fs-meta)}.agent-verifying span,.agent-complete span{font-size:var(--fs-caption);line-height:1.5;color:var(--muted)} +@media(max-width:600px){.connect-progress{grid-template-columns:25px minmax(0,1fr) auto}.connect-progress>.button{grid-column:1/-1;width:100%}.agent-bootstrap-state{align-items:start}.agent-bootstrap-state>svg{margin-top:2px}.agent-prompt pre{font-size:var(--fs-caption)}.agent-waiting-footer{align-items:stretch;flex-direction:column}.agent-copy-button{width:100%}} + +.submission-client-review{min-height:58px;padding:10px 14px;border-top:1px solid var(--line);background:var(--surface-muted);display:flex;align-items:center;justify-content:space-between;gap:12px}.submission-client-review>div{display:flex;align-items:center;gap:9px;min-width:0}.submission-client-review>div>svg{color:var(--warning)}.submission-client-review>div>div{display:flex;flex-direction:column;gap:3px;min-width:0}.submission-client-review strong{font-size:var(--fs-label)}.submission-client-review span{font-size:var(--fs-caption);color:var(--muted)}.snapshot-delivery,.delivery-packages{margin-top:16px;overflow:hidden}.snapshot-list>article{min-height:62px;padding:10px 16px;border-top:1px solid var(--line-soft);display:flex;align-items:center;justify-content:space-between;gap:12px}.snapshot-list>article>div:first-child,.delivery-packages article>header>div{display:flex;align-items:center;gap:10px;min-width:0}.snapshot-list>article>div:first-child>svg,.delivery-packages article>header svg{color:var(--success);flex:0 0 auto}.snapshot-list article span,.delivery-packages article span{display:flex;flex-direction:column;gap:3px;min-width:0}.snapshot-list strong,.delivery-packages strong{font-size:var(--fs-label);overflow-wrap:anywhere}.snapshot-list small,.delivery-packages small{font-size:var(--fs-caption);color:var(--muted)}.snapshot-actions{display:flex;align-items:center;gap:5px;flex:0 0 auto}.snapshot-actions .button-ghost{width:34px;padding:0}.delivery-packages>div>article{border-top:1px solid var(--line-soft)}.delivery-packages article>header{padding:11px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.delivery-packages article>div{padding:0 16px 11px 42px;display:flex;align-items:center;gap:5px;flex-wrap:wrap}.delivery-packages article>div .button{min-height:30px;padding:0 9px;font-size:var(--fs-caption)}.status-current{background:var(--success-soft);color:var(--success)}.status-v1_import{background:var(--neutral);color:var(--muted)} +@media(max-width:760px){.submission-client-review,.snapshot-list>article{align-items:stretch;flex-direction:column}.submission-client-review>.button{width:100%}.snapshot-actions{display:grid;grid-template-columns:repeat(3,34px) minmax(150px,1fr)}.delivery-packages article>div{padding-left:16px}} + +@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} diff --git a/web/src/types.ts b/web/src/types.ts index 3560481..0610d2d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,7 +13,7 @@ export interface Submission { id:string; project_id:string; workspace_id:string; export interface SourceDisclosure { id:string; source_ref:string; level:'metadata_only'|'evidence_pack'|'full_source'; sha256:string; byte_size:number; evidence_pack?:unknown } export interface SubmissionRevision { id:string; project_id:string; workspace_id:string; submission_id:string; revision_no:number; schema_version:string; content_hash:string; base_approved_snapshot_id?:string; local_run_summary:{run_id?:string;stage?:string;checks:{name:string;status:string;detail?:string}[];input_hash?:string;output_hash?:string;versions?:Record}; objects:Record[]; artifacts:{name:string;media_type:string;sha256:string;byte_size:number}[]; message?:string; idempotency_key:string; evidence_limited:boolean; created_by:string; created_at:string; source_disclosures:SourceDisclosure[] } export interface SubmissionDetails { submission:Submission; revisions:SubmissionRevision[]; comments:ReviewComment[] } -export interface ApprovedSnapshot { id:string; submission_id:string; submission_revision_id:string; submission_type:string; schema_version:string; content_hash:string; subject_hash:string; eligible_ids:string[]; created_by:string; created_at:string } +export interface ApprovedSnapshot { id:string; project_id:string; workspace_id:string; submission_id:string; submission_revision_id:string; submission_type:string; schema_version:string; content_hash:string; subject_hash:string; canonical_content?:unknown; eligible_ids:string[]; artifacts:{name:string;media_type:string;sha256:string;byte_size:number}[]; decision_id:string; created_by:string; created_at:string; origin:'current'|'v1_import'; external_ref?:string } export interface Dashboard { tenant: Tenant; projects: Project[]; recent_runs: Run[]; recent_audit: Audit[]; counts: Record; pipeline: {id: string; label: string; count: number; blocked: number}[] } export interface Evidence { source_revision_id: string; locator_kind: string; locator: string; quote: string } export interface TypedValue { type:string; text?:string; number?:number; boolean?:boolean; unit?:string } @@ -43,16 +43,20 @@ export interface ReviewGrant { id:string; subject_id:string; reviewer_email:stri export interface Membership { tenant_id:string; user_id:string; role:string; status:string; created_at:string; revoked_at?:string } export interface Member { membership:Membership; email:string; display_name:string } export interface MembershipInvite { id:string; email:string; role:string; status:string; expires_at:string; accepted_by?:string; accepted_at?:string; revoked_at?:string; created_at:string; invite_token?:string } -export interface Artifact { id:string; project_id:string; script_version_id:string; kind:string; capability_id:string; capability_version:string; capability_digest:string; schema_id:string; media_type:string; file_name:string; sha256:string; byte_size:number; visibility:string; retention_class:string; derived_from_artifact_id?:string; purpose:string; source_device_id?:string; validation_status:string; validation_error?:string; presentation_tier:string; metadata:Record; created_at:string } +export interface Artifact { id:string; project_id:string; approved_snapshot_id?:string; script_version_id?:string; kind:string; capability_id:string; capability_version:string; capability_digest:string; schema_id:string; media_type:string; file_name:string; sha256:string; byte_size:number; visibility:string; retention_class:string; derived_from_artifact_id?:string; purpose:string; source_device_id?:string; validation_status:string; validation_error?:string; presentation_tier:string; metadata:Record; created_at:string } +export interface DeliveryPackage { id:string; project_id:string; approved_snapshot_ids:string[]; script_id:string; status:string; manifest:Artifact[]; created_by:string; created_at:string } export interface ArtifactPresentation { artifact:Artifact; tier:'cloud_native'|'hosted_preview'|'safe_rendition'|'local_open'|'metadata_only'; review_projection?:Artifact; renditions:Artifact[]; actions:string[]; source_device?:{id:string;display_name:string;online:boolean} } export interface ArtifactOpenRequest { open_request_id:string; artifact_id:string; device_id:string; state:'pending'|'accepted'|'opened'|'not_available'|'failed'|'expired'; reason?:string; expires_at:string; created_at:string } export interface ArtifactOpenResult { dry_run:boolean; request?:ArtifactOpenRequest; presentation:ArtifactPresentation } -export interface PerformanceObservation { id:string; project_id:string; import_batch_id:string; row_number:number; script_version_id:string; platform:string; account_alias:string; published_at:string; window_hours:number; sample_status:string; metrics:Record; currency?:string; spend:number; gmv:number; roi?:number; dedup_key:string; issue_category:string; notes:string; created_at:string } +export interface PerformanceObservation { id:string; project_id:string; import_batch_id:string; row_number:number; approved_snapshot_id?:string; script_version_id?:string; platform:string; account_alias:string; published_at:string; window_hours:number; sample_status:string; metrics:Record; currency?:string; spend:number; gmv:number; roi?:number; dedup_key:string; issue_category:string; notes:string; created_at:string } export interface PerformanceImportBatch { id:string; project_id:string; source_name:string; source_format:'manual'|'json'|'csv'|'xlsx'; source_sha256:string; currency?:string; row_count:number; imported_count:number; status:'imported'; created_by:string; created_at:string } -export interface RatingDecision { id:string; project_id:string; subject_type:'script_version'|'content_framework'|'shot_pattern'; subject_id:string; observation_ids:string[]; rating:'seed_candidate'|'repairable'|'discarded'|'insufficient_sample'; reason:string; next_action:string; created_by:string; created_at:string } +export interface RatingDecision { id:string; project_id:string; subject_type:'approved_snapshot'|'script_version'|'content_framework'|'shot_pattern'; subject_id:string; observation_ids:string[]; rating:'seed_candidate'|'repairable'|'discarded'|'insufficient_sample'; reason:string; next_action:string; created_by:string; created_at:string } export interface LineageNode { key:string; type:string; id:string; label:string; status:string; stage:string; created_at:string; metadata?:Record } export interface LineageEdge { from:string; to:string; relation:string; reason:string } export interface LineageGraph { project_id:string; focus_key?:string; direction:'both'|'upstream'|'downstream'; nodes:LineageNode[]; edges:LineageEdge[]; stage_count:Record; generated_at:string } export interface ImpactItem { node:LineageNode; depth:number; severity:'blocked'|'attention'|'review'; reason:string; current_status:string; recommended_action:string } export interface ImpactAnalysis { project_id:string; focus?:LineageNode; items:ImpactItem[]; generated_at:string } -export interface ReviewProjection { project:Project; script:Script; comments:ReviewComment[]; verified:boolean } +export interface ScriptShotV2 { shot_id:string; start_ms:number; end_ms:number; role:string; narrative_purpose:string; subject:string; visual_intent:string; subject_action:string; composition:string; camera_motion:string; voiceover?:string; on_screen_text?:string; acceptance_criteria:string[] } +export interface ScriptPackageV2 { id:string; kind:string; status:string; schema_version:string; deliverability:string; project_id:string; script_id:string; title:string; channel:string; duration_ms:number; aspect_ratio:string; direction:{title:string;angle:string}; experiment:{primary_variable:string;hypothesis:string}; shots:ScriptShotV2[] } +export interface SubmissionReviewSubject { submission_id:string; submission_revision_id:string; subject_hash:string; schema_version:string; objects:Record[] } +export interface ReviewProjection { project:Project; script?:Script; submission?:SubmissionReviewSubject; comments:ReviewComment[]; verified:boolean } diff --git a/web/src/views/AssetViews.tsx b/web/src/views/AssetViews.tsx index 1b956a8..7599985 100644 --- a/web/src/views/AssetViews.tsx +++ b/web/src/views/AssetViews.tsx @@ -1,7 +1,7 @@ import { AlertTriangle, BarChart3, BrainCircuit, Check, CheckCircle2, ClipboardCheck, Database, FileArchive, FileSearch, FileSpreadsheet, GitCompareArrows, History, Plus, Scale, Upload, X, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; import { api, post, upload } from '../api'; -import type { Benchmark, EvidenceSpan, Framework, SourceImpactItem, Knowledge, PerformanceImportBatch, PerformanceObservation, Project, RatingDecision, Run, Script, SellingPoint, ShotPattern, Source, SourceRevision, VisualizationPlan } from '../types'; +import type { ApprovedSnapshot, Benchmark, EvidenceSpan, Framework, SourceImpactItem, Knowledge, PerformanceImportBatch, PerformanceObservation, Project, RatingDecision, Run, Script, SellingPoint, ShotPattern, Source, SourceRevision, VisualizationPlan } from '../types'; import { Banner, Button, Empty, Field, Modal, Status } from '../components/ui'; import { ProjectPage } from './OverviewView'; @@ -45,6 +45,7 @@ export function ResultsView({project}:{project:Project}) { const [batches,setBatches]=useState([]); const [ratings,setRatings]=useState([]); const [scripts,setScripts]=useState([]); + const [snapshots,setSnapshots]=useState([]); const [frameworks,setFrameworks]=useState([]); const [patterns,setPatterns]=useState([]); const [show,setShow]=useState<'observation'|'rating'>(); @@ -54,20 +55,24 @@ export function ResultsView({project}:{project:Project}) { api(`/api/bff/projects/${project.id}/performance-imports`), api(`/api/bff/projects/${project.id}/rating-decisions`), api(`/api/bff/projects/${project.id}/scripts`), + api(`/api/bff/projects/${project.id}/approved-snapshots?type=script`), api(`/api/bff/projects/${project.id}/frameworks`), api(`/api/bff/projects/${project.id}/shot-patterns`) - ]).then(([nextItems,nextBatches,nextRatings,nextScripts,nextFrameworks,nextPatterns])=>{ - setItems(nextItems);setBatches(nextBatches);setRatings(nextRatings);setScripts(nextScripts);setFrameworks(nextFrameworks);setPatterns(nextPatterns); + ]).then(([nextItems,nextBatches,nextRatings,nextScripts,nextSnapshots,nextFrameworks,nextPatterns])=>{ + setItems(nextItems);setBatches(nextBatches);setRatings(nextRatings);setScripts(nextScripts);setSnapshots(nextSnapshots);setFrameworks(nextFrameworks);setPatterns(nextPatterns); }).catch(e=>setError(e instanceof Error?e.message:'结果数据加载失败')); useEffect(()=>{load()},[project.id]); const latestROI=items.find(item=>item.roi!==undefined)?.roi; const scriptLabel=(id:string)=>{const script=scripts.find(item=>item.id===id);return script?`V${script.version} · ${script.package.title}`:`${id.slice(0,8)}…`}; + const snapshotLabel=(id:string)=>{const snapshot=snapshots.find(item=>item.id===id);return snapshot?`${snapshot.eligible_ids[0]||'剧本'} · ${snapshot.content_hash.slice(0,8)}`:`${id.slice(0,8)}…`}; + const observationSubject=(item:PerformanceObservation)=>item.approved_snapshot_id?snapshotLabel(item.approved_snapshot_id):scriptLabel(item.script_version_id||''); const subjectLabel=(item:RatingDecision)=>{ + if(item.subject_type==='approved_snapshot')return snapshotLabel(item.subject_id); if(item.subject_type==='script_version')return scriptLabel(item.subject_id); if(item.subject_type==='content_framework')return frameworks.find(value=>value.id===item.subject_id)?.name||`${item.subject_id.slice(0,8)}…`; return patterns.find(value=>value.id===item.subject_id)?.purpose||`${item.subject_id.slice(0,8)}…`; }; - return }> + return }> {error&&setError('')}>{error}}
观察记录{items.length}
@@ -76,35 +81,37 @@ export function ResultsView({project}:{project:Project}) {
人工评级{ratings.length}
观察数据与人工评级分开保存,不会自动修改剧本、框架或镜头模式状态。 - {items.length===0?
setShow('observation')}>录入观察}/>
:
PerformanceObservation

观察记录

{items.length} 条
发布与版本平台与账号样本消耗 / GMVROI指标
{items.map(item=>
{new Date(item.published_at).toLocaleDateString('zh-CN')}{scriptLabel(item.script_version_id)}{item.platform}{item.account_alias} · {item.window_hours}h{item.issue_category||'未归类'}{item.currency?`${item.currency} ${item.spend.toFixed(2)}`:'—'}{item.currency?`GMV ${item.gmv.toFixed(2)}`:'无金额'}{item.roi===undefined?'—':item.roi.toFixed(2)}{Object.entries(item.metrics).map(([key,value])=>{metricLabel(key)} {formatMetric(key,value)})}
)}
} + {items.length===0?
setShow('observation')}>录入观察}/>
:
PerformanceObservation

观察记录

{items.length} 条
发布与快照平台与账号样本消耗 / GMVROI指标
{items.map(item=>
{new Date(item.published_at).toLocaleDateString('zh-CN')}{observationSubject(item)}{item.platform}{item.account_alias} · {item.window_hours}h{item.issue_category||'未归类'}{item.currency?`${item.currency} ${item.spend.toFixed(2)}`:'—'}{item.currency?`GMV ${item.gmv.toFixed(2)}`:'无金额'}{item.roi===undefined?'—':item.roi.toFixed(2)}{Object.entries(item.metrics).map(([key,value])=>{metricLabel(key)} {formatMetric(key,value)})}
)}
}
ImportBatch

导入批次

{batches.length===0?:
{batches.map(batch=>
{batch.source_name}{batch.source_format.toUpperCase()} · {batch.imported_count} 行 · {new Date(batch.created_at).toLocaleString('zh-CN')}{batch.source_sha256.slice(0,16)}…
)}
}
RatingDecision

人工评级

{ratings.length===0?:
{ratings.map(item=>
{subjectLabel(item)}{item.subject_type} · {item.observation_ids.length} 条观察

{item.reason}

{item.next_action}
)}
}
- {show==='observation'&&setShow(undefined)} onCreated={()=>{setShow(undefined);load()}}/>} - {show==='rating'&&setShow(undefined)} onCreated={()=>{setShow(undefined);load()}}/>} + {show==='observation'&&setShow(undefined)} onCreated={()=>{setShow(undefined);load()}}/>} + {show==='rating'&&setShow(undefined)} onCreated={()=>{setShow(undefined);load()}}/>}
} -function ObservationModal({project,scripts,onClose,onCreated}:{project:Project;scripts:Script[];onClose:()=>void;onCreated:()=>void}) { - const [form,setForm]=useState({script_version_id:scripts[0]?.id||'',platform:project.channel||'douyin',account_alias:'',published_at:localDateTimeValue(),window_hours:'24',sample_status:'insufficient_sample',issue_category:'',impressions:'',three_second_retention_rate:'',completion_rate:'',clicks:'',conversions:'',currency:'',spend:'',gmv:'',notes:''}); +function ObservationModal({project,snapshots,onClose,onCreated}:{project:Project;snapshots:ApprovedSnapshot[];onClose:()=>void;onCreated:()=>void}) { + const [form,setForm]=useState({approved_snapshot_id:snapshots[0]?.id||'',platform:project.channel||'douyin',account_alias:'',published_at:localDateTimeValue(),window_hours:'24',sample_status:'insufficient_sample',issue_category:'',impressions:'',three_second_retention_rate:'',completion_rate:'',clicks:'',conversions:'',currency:'',spend:'',gmv:'',notes:''}); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const set=(key:string,value:string)=>setForm(previous=>({...previous,[key]:value})); - const submit=async()=>{setBusy(true);setError('');try{const metrics:Record={};for(const key of ['impressions','three_second_retention_rate','completion_rate','clicks','conversions'] as const){if(form[key]!=='')metrics[key]=Number(form[key])}await post(`/api/bff/projects/${project.id}/results`,{script_version_id:form.script_version_id,platform:form.platform,account_alias:form.account_alias,published_at:new Date(form.published_at).toISOString(),window_hours:Number(form.window_hours),sample_status:form.sample_status,issue_category:form.issue_category,metrics,currency:form.currency,spend:form.spend===''?0:Number(form.spend),gmv:form.gmv===''?0:Number(form.gmv),notes:form.notes});onCreated()}catch(e){setError(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}}; - return
set('published_at',event.target.value)}/>set('platform',event.target.value)}/>set('account_alias',event.target.value)}/>set('window_hours',event.target.value)}/>set('impressions',event.target.value)}/>set('three_second_retention_rate',event.target.value)}/>set('completion_rate',event.target.value)}/>set('clicks',event.target.value)}/>set('conversions',event.target.value)}/>set('spend',event.target.value)}/>set('gmv',event.target.value)}/>