From 4e348a93b4034fdbb8089fab79873b56d24639b4 Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 27 Jul 2026 00:24:36 +0800 Subject: [PATCH 1/2] feat: complete the V2 content delivery workflow --- CLAUDE.md | 3 + DESIGN.md | 100 +++++ IMPLEMENTATION_PLAN.md | 9 +- Makefile | 7 +- README.md | 11 +- REDESIGN_PLAN.md | 74 ++++ contracts/openapi.yaml | 296 ++++++++++++- docs/roadmap/v2/03-domain-and-data-model.md | 2 +- docs/roadmap/v2/14-implementation-status.md | 76 +++- internal/app/artifacts.go | 7 +- internal/app/lineage.go | 34 +- internal/app/performance_results.go | 120 ++++-- internal/app/review_export.go | 397 +++++++++++++++--- internal/app/service_test.go | 30 +- internal/app/submissions.go | 67 ++- internal/app/submissions_test.go | 6 +- internal/app/v2_full_chain_test.go | 209 +++++++++ internal/cli/artifacts.go | 48 ++- internal/cli/business_commands.go | 20 +- internal/cli/result_import.go | 11 +- internal/cli/root.go | 6 +- internal/cli/v2_commands.go | 12 +- internal/domain/content.go | 56 ++- internal/domain/model.go | 1 + internal/domain/submission.go | 2 + internal/httpapi/bootstrap.md | 4 +- internal/httpapi/cli_dispatch.go | 52 ++- internal/httpapi/content_handlers.go | 73 ++++ internal/httpapi/server.go | 10 +- internal/httpapi/submission_handlers_test.go | 100 +++++ internal/localworkspace/script.go | 80 ++-- internal/store/memory/content.go | 82 +++- internal/store/memory/memory.go | 6 +- internal/store/memory/submissions.go | 100 +++++ .../postgres/content_integration_test.go | 10 +- internal/store/postgres/delivery.go | 109 +++++ .../store/postgres/performance_results.go | 6 +- .../performance_results_integration_test.go | 45 +- internal/store/postgres/production.go | 13 +- internal/store/postgres/review.go | 94 ++++- internal/store/postgres/submissions.go | 111 ++++- .../postgres/submissions_integration_test.go | 60 ++- internal/store/store.go | 11 +- migrations/00014_v2_single_track_delivery.sql | 175 ++++++++ .../00015_v1_shadow_backfill_report.sql | 47 +++ scripts/dev.sh | 324 ++++++++++++++ web/index.html | 3 +- web/public/favicon.svg | 6 + web/src/admin/routes.test.ts | 16 +- .../components/InitializeWorkspaceModal.tsx | 20 +- web/src/connectBootstrap.test.ts | 2 +- web/src/connectBootstrap.ts | 2 +- web/src/consoleRoutes.ts | 12 + web/src/router.tsx | 58 +-- web/src/styles.css | 58 +-- web/src/types.ts | 14 +- web/src/views/AssetViews.tsx | 43 +- web/src/views/PublicViews.tsx | 18 +- web/src/views/SubmissionsView.tsx | 111 ++++- web/src/views/auth/AuthRoutes.tsx | 5 +- web/src/workspace/WorkspaceShell.tsx | 13 +- web/src/workspace/pages.tsx | 15 +- web/vite.config.ts | 28 +- 63 files changed, 3109 insertions(+), 431 deletions(-) create mode 100644 CLAUDE.md create mode 100644 DESIGN.md create mode 100644 REDESIGN_PLAN.md create mode 100644 internal/app/v2_full_chain_test.go create mode 100644 internal/store/postgres/delivery.go create mode 100644 migrations/00014_v2_single_track_delivery.sql create mode 100644 migrations/00015_v1_shadow_backfill_report.sql create mode 100755 scripts/dev.sh create mode 100644 web/public/favicon.svg create mode 100644 web/src/consoleRoutes.ts 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/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/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/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/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/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)}/>