Compare commits

..

3 Commits

Author SHA1 Message Date
rayd1o
a54fcdbeed release: bump version to 0.74.3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
2026-09-13 02:17:55 +08:00
rayd1o
1dd2921674 release: bump version to 0.74.2
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-07-01 23:40:00 +08:00
linkong
d30f7d08c5 release: bump version to 0.74.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-06-30 18:54:34 +08:00
35 changed files with 1664 additions and 898 deletions

View File

@@ -1,139 +0,0 @@
---
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /cleanup — 垃圾代码审查与清理
分析当前工作区git diff中的未提交代码找出并修复常见垃圾代码**不得改变任何运行逻辑**。
## 检查范围
`$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
## 节省上下文规则
优先用确定性的 CLI 检查缩小范围,不要一上来把完整文件或大 diff 读入上下文:
```bash
git diff --name-only HEAD
git diff --unified=0 HEAD -- <path>
git diff --check
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
```
只有 focused diff 不足以安全判断或修改时,才读取完整文件。
## 审查清单
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
### 1. 重复逻辑 (Duplicate Logic)
- 完全相同或高度相似的代码块在多处出现
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
### 2. Magic Numbers / Magic Strings
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
### 3. 命名问题
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`
- 命名与实际用途不符
- 同一概念在不同地方用不同名字表达
### 4. 死代码 / 无效代码
- 注释掉的旧代码块3行以上
- 声明后从未使用的变量/参数/导入
- 永远不会执行的条件分支
### 5. 代码风格问题
- 尾部空白字符trailing whitespace
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
- 空行使用不一致(连续多个空行等)
### 6. 其他常见问题
- 私有辅助函数应被 export 但没有,导致调用方重复实现
- 类型/接口重复定义
- 过于冗长的条件表达式可以简化(不改逻辑)
## 执行步骤
### Step 1 — 获取待检查文件列表
```bash
# 无参数时:获取所有未提交修改
git diff HEAD --name-only
# 有参数时:用 $ARGUMENTS 过滤
```
### Step 2 — 逐文件阅读并分析
先从 focused diff 开始:
```bash
git diff --unified=0 HEAD -- <file>
```
`rg``git diff --check`、编译器或 linter 输出确认确定性问题。只有需要上下文时才用 Read 读取完整文件。对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式。
### Step 3 — 报告问题清单
在修改前,先以列表形式输出所有发现的问题:
```
发现 N 个问题:
[文件] js/foo.js
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
[文件] js/bar.js
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
...
```
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
### Step 4 — 执行修复
对每个问题,使用 Edit 工具进行**最小化修改**
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
- **命名问题**:重命名变量,更新所有使用处
- **死代码**:直接删除
- **尾部空白/风格**:修正
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
**修复原则:**
- 只改在审查清单中发现的问题,不做额外优化
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
- 改完后用 `grep` 验证旧的坏代码已消失
- 优先做精确补丁;只有仓库已有对应格式化流程时,才运行格式化工具
### Step 5 — 输出总结
```
清理完成:
修复了 N 个问题:
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 144处引用
✓ controls.js — export updateLayerButtonState移除 main.js 中的重复实现
...
未修改的问题(需人工确认):
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
```
## 约束
- **禁止**改变函数签名、接口定义、导出 API除非问题正是私有函数应被 export
- **禁止**添加新功能、新抽象、新参数
- **禁止**修改注释内容(只删除注释掉的死代码)
- **禁止**修改测试文件逻辑
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"

View File

@@ -1,104 +0,0 @@
---
description: Create or update repository documentation from current code changes
argument-hint: Optional: topic to document, or leave empty to infer from git diff
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
---
# /docs — Documentation Workflow
## Goal
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
## Repository Rules
Before deciding scope, check whether the repository has a documentation rules file:
```bash
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
```
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
## Workflow
### Step 1 — Understand The Change
```bash
git diff HEAD --stat
git diff HEAD --name-only
git log --oneline -10
rg --files docs
```
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
```bash
git diff HEAD -- <path>
rg -n "class |def |function |export |router|@router|interface |type " <path>
```
### Step 2 — Decide Scope
- Prefer updating an existing relevant document over creating a duplicate.
- Use one document for one coherent topic.
- Split documents only when the change crosses meaningful domains.
- Keep filenames lowercase and hyphenated.
- Apply the repository-specific rules file before writing.
#### Document Audience Routing (Planet)
In this repository, classify the action's performer before picking a target file:
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
### Step 3 — Write
Explain:
- Background/problem: what was wrong or missing before.
- Core design decisions and rationale.
- Operational or user-facing impact.
- Relevant code paths, only when useful for future maintainers.
Style:
- Follow the repositorys existing language and heading conventions.
- Use fenced code blocks with language tags.
- Prefer tables for comparisons or parameter lists.
- Keep snippets concise and relevant.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
### Step 4 — Verify
- Read the completed docs once for clarity and stale statements.
- Verify referenced paths exist with `test -e` or `rg --files`.
- Run applicable checks from `docs/documentation-coverage-rules.md`.
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
### Step 5 — Report
Summarize changed docs and verification:
```md
Updated:
- path/to/doc.md — what changed
Verified:
- checks that passed
- checks that could not be run, if any
```
## Hard Constraints
- Do not leave placeholder docs.
- Do not duplicate bilingual files byte-for-byte.
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
- Keep docs maintainable and concise.

View File

@@ -1,93 +0,0 @@
---
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
argument-hint: 建议填写任务目标;若同时给出成功标准更好
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /goal-driven — 目标驱动执行模式
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
适用场景:
- 长周期实现任务
- 高复杂度工程任务
- 可被明确验收的研究、实现、迁移、验证类工作
不适用场景:
- 纯脑暴
- 无法定义成功标准的模糊任务
- 很小的一次性修改
## 输入要求
`$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
启动时先输出:
```md
Goal
- ...
Criteria for success
- ...
Plan
1. ...
2. ...
3. ...
Verification
- ...
```
## 执行规则
1. 先把任务固化为两个核心块:
- `Goal`
- `Criteria for success`
2. 成功标准必须尽量客观,可验证,可落地。
优先写成:
- 需要交付什么
- 需要通过哪些测试或验证
- 如何判断结果真的完成
3. 进入持续执行循环:
- 完成一个阶段
- 检查当前结果是否满足成功标准
- 若未满足,明确剩余差距并继续推进
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
5. 如果验证失败:
- 明确指出哪条成功标准没满足
- 继续工作,不要把阶段性进展误判为完成
6. 只有在以下情况之一才能停止:
- 成功标准已满足
- 用户明确要求停止
## 执行风格
- 重证据,轻口头判断
- 优先使用确定性工具证据:`rg``git diff --stat``git diff -- <path>`、测试、构建、lint、`curl`、数据库查询等能直接证明成功标准的方式
- 不把大段命令输出粘进回复;保留在工具调用里,回复只总结关键证据
- 重验收,轻自我感觉
- 优先用测试、日志、产物、对比结果来证明完成
- 对长期任务保持“未达标就继续”的节奏
## 简版模板
```md
Goal: [[[[[在此填写最终目标]]]]]
Criteria for success: [[[[[在此填写成功标准]]]]]
循环执行:
1. 推进任务
2. 检查是否满足成功标准
3. 若未满足,继续工作
4. 直到满足标准或用户明确停止
```

View File

@@ -1,160 +0,0 @@
---
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog运行验证commit 并 push
argument-hint: 可选feature | bugfix | 或直接描述本次发布内容
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
---
# /release — Planet 发版工作流
## 版本号规则
| 变更类型 | 版本跳动 | 适用场景 |
|---------|---------|---------|
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
意图混合时以用户明确描述为准bugfix + 小 feature 混合默认判定为 `improvement``+0.0.1`)。
## 必须同步更新的文件
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
- `VERSION`
- `frontend/package.json``"version"` 字段)
- `pyproject.toml``version =` 字段)
- `uv.lock`**不要手动编辑**,通过 `uv lock` 重新生成)
- `docs/CHANGELOG.md`
- `docs/version-history.md`
## 节省上下文规则
发版判断应以确定性 CLI 证据为主,优先使用紧凑命令和定点读取:
```bash
git status --short
git diff --stat HEAD
git diff --name-only HEAD
rg -n "version|^## |^Released:|当前开发版本|current" VERSION frontend/package.json pyproject.toml docs/CHANGELOG.md docs/version-history.md
```
除非需要判断某个代码变更是否属于本次发版,否则不要读取完整 diff。
## 执行步骤
### Step 1 — 环境检查
```bash
git branch --show-current # 确认在 dev 分支
git status --short # 检查是否有无关的未暂存修改
cat VERSION # 读取当前版本
```
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
### Step 2 — 确定发版类型与新版本号
-`$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
- 否则根据 `git diff --stat HEAD``git diff --name-only HEAD`、必要的 focused diff 和 `git log` 推断
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`
- **先输出发版计划供用户确认**
```
发版计划:
类型bugfix
版本0.26.2 → 0.26.3
分支dev
将更新VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
```
### Step 3 — 更新版本号文件
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
1. `VERSION` — 直接替换全部内容为新版本号
2. `frontend/package.json` — 替换 `"version": "x.x.x"`
3. `pyproject.toml` — 替换 `version = "x.x.x"`
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
### Step 4 — 更新 CHANGELOG.md
在文件顶部插入新条目,格式:
```markdown
## [x.x.x] — YYYY-MM-DD
### ✨ Features / 🐛 Fixes / 🔧 Improvements
- ...(只列高信号条目,最多 5 条)
- ...
---
```
日期使用 `date +%Y-%m-%d` 获取今天的日期。
### Step 5 — 更新 docs/version-history.md
- 更新文件头部的"当前开发版本"字段
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
### Step 6 — 验证
针对本次变更范围做最小验证:
- Python 文件有修改:先用 `git diff --name-only HEAD -- '*.py'` 列出,再运行 `python3 -m py_compile <changed_files>`
- Frontend 文件有修改:先用 `git diff --name-only HEAD -- frontend` 判断范围,再运行项目标准检查(若无则跳过并说明)
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
```bash
cat VERSION
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
```
### Step 7 — 提交前预览
展示将要提交的文件列表:
```bash
git diff --stat HEAD
```
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
### Step 8 — Commit & Push用户确认后
```bash
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
# 若有代码变更也一并 stage
git add <code_files>
git commit -m "release: bump version to x.x.x"
git tag vx.x.x
git push origin dev
git push origin vx.x.x
```
commit message 固定格式:`release: bump version to x.x.x`
### Step 9 — 完成确认
输出摘要:
```
✓ 版本号已更新0.26.2 → 0.26.3
✓ CHANGELOG 已更新
✓ version-history 已更新
✓ uv.lock 已重新生成
✓ 验证通过
✓ commit: release: bump version to 0.26.3
✓ tag: v0.26.3
✓ 已 push 到 origin/dev
```
## 注意事项
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行

302
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
**Planet agent harness. Defines behavior for coding agents working in this repository.**
---
@@ -10,26 +10,32 @@ This file is the single authoritative agent guide for the Planet repository.
The older lowercase `agents.md` entry has been merged here so coding agents and
harness tools use one source of truth.
### Read First
### Source Of Truth
- `rules.md` is the mandatory repository rule source. Always load `core`,
`security`, and `workflow`; load only task-relevant modules after that.
- `AGENTS.md` defines the local agent operating mode and evidence gates.
- `project_context.md` is background, not a rule source. Prefer newer
implementation docs when it disagrees with current code.
- `.codex/skills/` is the active specialized workflow layer for cleanup, docs,
goal-driven work, and release.
- Do not duplicate long workflow text across harness files. Durable constraints
belong in `rules.md`; task procedures belong in skills or scripts.
Read these files before changing code:
1. `rules.md` - mandatory repository rules. Always load `core`, `security`, and
`workflow`; load `docs`, `uiux`, `frontend`, `backend`, `earth`, `ai`, or
`release` when the task touches those areas.
2. `AGENTS.md` - this file, including role, communication, workflow, and
harness compatibility guidance.
3. `project_context.md` - static project background. Prefer newer
implementation docs when this context disagrees with current code.
4. `README.md` - current architecture, startup, and toolchain summary.
5. `docs/HARNESS.md` - harness workflow, conflict policy, and validation tiers.
6. `CODEMAP.md` - codebase entry points, ownership boundaries, and deeper docs.
1. `rules.md`
2. `AGENTS.md`
3. `project_context.md`
4. `README.md`
5. `docs/HARNESS.md`
6. `CODEMAP.md`
For documentation work, also read `docs/documentation-coverage-rules.md`.
### Start Safely
Before editing:
Before broad edits:
```bash
git status --short
@@ -45,11 +51,6 @@ git diff --name-only HEAD
git diff --unified=0 HEAD -- <path>
```
When the user provides screenshots or images, inspect the actual image before
making visual claims. If the referenced path is missing, search alternate
attachment/worktree/local locations or ask for the file; never guess the image
content from text, filenames, or memory.
Preserve user changes already present in the worktree.
### Validation
@@ -66,8 +67,9 @@ Full local validation:
scripts/harness/validate.sh
```
`validate.sh` includes the quick check and the frontend Bun build. Docker image
smoke builds are intentionally opt-in:
`validate.sh` includes quick checks, frontend Bun build, and frontend smoke
unless disabled by its documented environment flags. Docker image smoke builds
are intentionally opt-in:
```bash
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
@@ -108,229 +110,71 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
---
## Identity
## Operating Mode
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
- Python FastAPI backend
- React admin console, public Docs UI, and browser Web Earth shell
- AI Provider model gateway
- Multi-source data collection
- Future physical display directions such as UE5 / Cesium remain optional
roadmap work, not the active local development loop
- Default to acting directly when the user gives a clear task.
- Ask before acting only when the missing decision is risky, cannot be
discovered from repository context, and no conservative assumption is safe.
- Read relevant files before editing.
- Prefer focused CLI evidence: `rg`, `git diff --stat`, `git diff --name-only`,
focused file reads, tests, builds, linters, and harness scripts.
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
release work, and documentation unless the task requires it.
---
## Communication Style
## Evidence Gates
### Tone
- **Professional but concise**
- Technical accuracy with clarity
- No unnecessary verbosity
- Use code comments sparingly (explain **why**, not **what**)
### When Responding
1. **Answer directly** - 1-3 sentences for simple questions
2. **Use code blocks** for all code snippets
3. **Include file:line_number** references when discussing code
4. **Never** start with "I am an AI assistant" or similar phrases
5. **Never** add unnecessary preambles/postambles
### Examples
**Good:**
```
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
```
**Bad:**
```
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
```
- Visual inputs are blocking evidence. If the user provides a screenshot, image,
mock, browser capture, or visual reference, obtain evidence from the artifact
before interpreting intent or editing code.
- Path resolution is part of the task. If the path cannot be opened, first try
reasonable local equivalents such as WSL/Windows path conversion,
workspace-relative lookup, absolute paths, and attached-file locations.
- Never guess from prompt text, filenames, previous context, logs, OCR, or
memory when a visual artifact was provided but cannot be accessed.
- OCR is acceptable evidence for text-only visual questions or non-multimodal
environments; state that OCR was used as the fallback. Layout, color, spacing,
pixel, and rendering issues need real visual inspection or a clear limitation
note.
- If a visual artifact still cannot be inspected, say so and pause that
visual-dependent part of the work.
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
diff, direct file check, or harness result.
- For UI and rendering changes, verify the rendered result when local tooling
allows it.
---
## Operational Mode
## Communication
### Plan Mode (default for complex tasks)
- Analyze requirements
- Propose architecture
- Confirm with user before execution
- **DO NOT** write code until approved
### Build Mode (after user approval)
- Execute the approved plan
- Write code, run commands
- Verify results
- Report completion concisely
### Read-Only Mode
- Analyze code
- Explain functionality
- Answer questions
- **DO NOT** modify files
- Match the user's language. Use Chinese for Chinese requests unless the user
asks otherwise.
- Keep updates short and specific: what is being inspected, edited, or verified.
- Final responses should summarize changed files and verification, with blockers
stated plainly.
- Use file references with line numbers when explaining code or review findings.
---
## Decision Framework
## Quality Bar
### When to Ask Before Acting
- Unclear requirements
- Multiple implementation approaches
- Architecture changes
- Dependency additions
- Anything that could break existing functionality
### When to Act Directly
- Clear, approved requirements
- Routine tasks (linting, formatting, running tests)
- Following established patterns
- Fixing obvious bugs
### When to Refuse
- Malicious code requests
- Security violations (secrets, credentials)
- Anything that violates `rules.md`
- Prefer existing project patterns over new abstractions.
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
a stable path exists.
- Centralize prompts, constants, defaults, and shared request/response handling.
- Do not add secrets, generated runtime output, or local environment files.
- Frontend commands use Bun only. Do not use `npm`, `pnpm`, or `yarn`.
- Run the smallest relevant verification for the changed scope and report
anything skipped.
---
## Working Principles
## Prohibited
### 1. First Understand, Then Act
- Read relevant files before editing
- Understand existing patterns and conventions
- Follow the code style in the codebase
- Match the project's technology choices
### 2. Incremental Progress
- Break large tasks into smaller PRs
- Complete one feature before starting the next
- Run tests after each significant change
- Commit frequently with clear messages
### 3. Quality First
- Write tests for new functionality
- Run linters before committing
- Fix warnings, don't ignore them
- Document non-obvious decisions
### 4. Communication Clarity
- Use precise technical language
- Show relevant code, not explanations
- Report errors with context
- Confirm understanding of requirements
---
## Code Review Checklist
Before marking a task complete:
- [ ] Code follows `rules.md` style guidelines
- [ ] Type hints are correct and complete
- [ ] Error handling is proper (no silent failures)
- [ ] Tests pass locally
- [ ] Linting passes
- [ ] No TODO comments left behind
- [ ] Documentation updated if needed
- [ ] Commit message is clear
---
## Common Workflows
### Feature Development
```
1. Understand requirements
2. Check existing patterns in codebase
3. Design solution (brief mental model)
4. Write code following rules.md
5. Write/run tests
6. Lint and format
7. Commit with clear message
8. Report completion
```
### Bug Fix
```
1. Reproduce the bug (write failing test)
2. Locate the source
3. Fix the issue
4. Verify test passes
5. Check for regressions
6. Commit fix
```
### Refactoring
```
1. Understand current behavior
2. Design target state
3. Make incremental changes
4. Preserve tests
5. Verify functionality
6. Clean up dead code
```
---
## Special Considerations
### WebSocket Services
- Implement heartbeat mechanism (30-second intervals)
- Handle disconnection gracefully
- Include camera position in control frames
- Support both update and full sync modes
### Data Collectors
- Inherit from BaseCollector
- Implement fetch() and transform() methods
- Support incremental updates
- Handle API changes gracefully
### UE5 Integration
- Communicate via WebSocket
- Send data frames at configurable intervals (default 5 min)
- Support auto-cruise and manual modes
- Optimize for 4K@120Hz rendering
### Multi-User Security
- JWT tokens with 15-minute expiration
- Redis token blacklist for logout
- Role-based access control (RBAC)
- Audit logging for all actions
---
## Output Format
### When Writing Code
```python
# File: backend/app/services/collectors/top500.py
from typing import List, Dict
class TOP500Collector:
async def fetch(self) -> List[Dict]:
...
```
### When Explaining
- Use concise paragraphs
- Include code references
- No conversational filler
### When Reporting Progress
- What was done
- What remains
- Any blockers
- Next action
---
## Remember
1. **Rules are hard constraints** - follow `rules.md` absolutely
2. **Context provides understanding** - use `project_context.md` for background
3. **Role defines behavior** - follow `AGENTS.md` for how to work
4. **Quality over speed** - Enterprise systems require precision
5. **Communicate clearly** - Precision in, precision out
- Do not skip visual evidence handling when a visual artifact was provided.
- Do not preserve obsolete harness files just because they already exist.
- Do not invent behavior not present in code, docs, or verified external
sources.
- Do not rewrite unrelated files during cleanup.
- Do not mark a task complete without checking concrete success criteria.

View File

@@ -168,10 +168,12 @@
## 快速启动
入口需要先具备 `zsh``curl` 和可访问的软件源。Ubuntu / Ubuntu WSL 上,`init` 会自动检测并补装 Docker Engine、Compose v2 和 Buildx启动 Docker 服务并配置当前用户的访问权限;需要系统权限时会提示输入 sudo 密码。其他系统请先准备可用的 Docker 环境。
```bash
# 新机器或空项目首次初始化
./planet.sh init
# 会自动安装/检查 uv、bun同步 Python/前端依赖
# 会先准备 Docker / Compose / Buildx安装/检查 uv、bun同步 Python/前端依赖
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
# 会启动 PostgreSQL/Redis并创建表、默认数据源和本地默认用户

View File

@@ -1 +1 @@
0.74.0
0.74.3

View File

@@ -8,6 +8,56 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.74.3] — 2026-09-13
Released: 2026-09-13
### Highlights
- Ubuntu / WSL 新机器初始化会自动检测并准备 Docker Engine、Compose v2、Buildx 和当前用户权限,减少手工安装步骤。
- 数据库初始化先核对容器端口与后端真实连接,连接和认证通过后才创建表和默认数据。
### Added / Fixed / Improved
- 区分 Docker CLI 缺失、服务未安装、daemon 不可用及 socket 权限不足,修正未安装 Docker 时误提示启动 socket 的诊断。
- 自动补齐缺失的 Docker 依赖并启动本地服务,以原用户身份刷新 Docker 组权限;保留参数和 PATH不依赖 sg。
- 通过 Compose 同步已有 PostgreSQL / Redis 容器配置,保留端口冲突等具体错误;端口映射异常时最多保留数据卷重建一次 PostgreSQL。
- 新增后端数据库只读连接检查,对认证、库名和网络失败给出不含密码或完整连接串的诊断。
- 将 Docker 与数据库启动隔离回归测试接入快速检查,并同步 README、harness 和中英文运维说明。
---
## [0.74.2] — 2026-07-01
Released: 2026-07-01
### Highlights
- 收敛 agent harness 到 `rules.md``AGENTS.md``docs/HARNESS.md``.codex/skills/`,删除重复维护的旧 Claude command 入口。
- 强化视觉证据规则:截图或视觉引用路径打不开时必须先处理 WSL/Windows 路径、相对路径和附件位置,而不是跳过后猜测。
- 明确 OCR 可作为文本类视觉证据或非多模态环境 fallback同时要求布局、颜色、像素和渲染类问题保留真实视觉验证或明确限制说明。
### Added / Fixed / Improved
- `AGENTS.md` 替换旧 opencode/默认 Plan Mode 内容,保留最新单一入口和 harness 验证说明。
- `rules.md``docs/HARNESS.md` 同步 Visual Evidence Gate补齐路径解析、访问失败报告和 OCR fallback 边界。
- 删除 `.claude/commands/*` 中与 `.codex/skills/*` 重复的旧 cleanup/docs/goal-driven/release 入口,并更新文档受众计划中的旧路径引用。
---
## [0.74.1] — 2026-06-30
Released: 2026-06-30
### Highlights
-`/earth-content` 的品牌标识上传收敛到 `Logo 地址``标题图地址` 字段内,移除旧的全局“选择资产/上传”工具栏。
- 新增字段级图片拖拽反馈,拖到对应字段时直接提示将图片复制为 Logo 或标题图。
- 对齐品牌上传按钮到现有 Tactile UI primary 按钮样式,并同步中英文使用手册、快速开始和控制台上下文文档。
### Added / Fixed / Improved
- `BrandAssetInput` 支持字段内选择文件、拖拽上传、单字段 loading 和上传后回写草稿 URL。
- `FieldGrid` 支持按字段注入自定义输入控件,同时复用统一草稿提交路径。
- 品牌上传拖拽态改为低饱和 tactile 配色,上传按钮保持蓝色轻立体样式,容器内上下/右侧留白对齐为 3px。
- 补齐品牌上传相关 legacy UI 英文翻译、术语对照和用户文档。
---
## [0.74.0] — 2026-06-30
Released: 2026-06-30

View File

@@ -135,11 +135,18 @@ PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
- Treat user-provided screenshots and images as primary visual evidence. If a
screenshot contradicts written text, inspect the image first and explicitly
call out the mismatch before deciding what to change.
- If a referenced screenshot path cannot be opened, do not infer image content
from the filename, alt text, surrounding prose, or memory. Search the current
thread attachments, repository, and obvious local attachment/download
locations; if the image still cannot be found, ask for the file or report the
blocker instead of guessing.
- Path resolution is part of visual evidence handling. If a referenced
screenshot path cannot be opened, try reasonable local equivalents first:
WSL/Windows path conversion, workspace-relative paths, absolute paths, current
thread attachments, repository files, and obvious local attachment/download
locations.
- If the image still cannot be found or opened, report the exact path/access
blocker instead of guessing. Do not infer image content from the filename, alt
text, surrounding prose, logs, or memory.
- OCR is acceptable evidence for text-only questions or non-multimodal
environments; state when OCR was the fallback. Layout, color, spacing, pixel,
and rendering issues still require a real visual inspection or an explicit
"could not verify visually" note.
- Same-category UI surfaces must use one visual system per product area. Badges,
chips, pills, tags, status labels, small buttons, cards, panels, and toolbar
controls should reuse the shared component, shared token, or established CSS
@@ -213,9 +220,26 @@ Optional for delivery smoke:
- Docker daemon for image builds
- Helm for chart lint/template checks
If a required local tool is missing, do not install system software
automatically. Report the gap and point to `./planet.sh init` or
`scripts/bootstrap-dev.sh` as the existing bootstrap path.
For routine harness validation, do not install missing system software
automatically. Report the gap and point to the explicit bootstrap entry points.
`./planet.sh init` can install missing Docker Engine, Compose v2, and Buildx on
Ubuntu / Ubuntu WSL, start the local service, and configure Docker group access.
This bootstrap behavior is intentional; do not invoke it merely to make harness
checks pass. `scripts/bootstrap-dev.sh` only prepares application dependencies.
Docker bootstrap regression checks use isolated command stubs and never install
packages or modify the host daemon:
```bash
uv run --frozen --project . python scripts/harness/test_docker_bootstrap.py
uv run --frozen --project . python scripts/harness/test_database_startup.py
```
Database startup regressions also run in quick-check. They cover Compose
reconciliation of existing containers, visible startup errors, published-port
checks, bounded recreation that preserves volumes, and the backend connection
gate before schema initialization. Their command stubs and driver mocks do not
modify the host Docker environment.
## What Agents Must Not Change Automatically

View File

@@ -3,7 +3,7 @@
**状态**:待实施
**创建日期**2026-05-12
**校正日期**2026-06-26控制台深链已从旧 tab 查询口径更新为当前 `?section=` 口径。
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md``.claude/commands/docs.md`,让以后写文档时自动按受众归档。
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md``.codex/skills/docs/SKILL.md`,让以后写文档时自动按受众归档。
## 背景
@@ -80,7 +80,7 @@
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
## .claude/commands/docs.md 增量
## `.codex/skills/docs/SKILL.md` 增量
在 "Step 2 — Decide Scope" 后插一段:
@@ -100,7 +100,7 @@
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
- `docs/documentation-coverage-rules.md` — 加受众分层段
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
- `.codex/skills/docs/SKILL.md` — 加 Document Audience Routing 段
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook``DOCS_METADATA``docs_admin` 组)
## 依赖

View File

@@ -387,6 +387,7 @@ Current boundary:
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx), but its ownership is separate from System Settings:
- `TV Livestream` owns the Earth media-panel source configuration.
- `Branding` owns Earth HUD brand assets. `Logo URL` and `Title Image URL` use inline upload controls inside the fields; the upload buttons keep the primary `TactileButton` style, and dropping an image onto the matching field shows a low-saturation drag reaction instead of the old global asset-picker toolbar.
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.

View File

@@ -192,7 +192,7 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately.
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. The `Logo URL` and `Title Image URL` fields each include their own Upload button, and image files can be dropped directly onto the matching field. After upload, the field receives the new asset URL; save the brand configuration to make the Earth page use it.
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
- **TV Livestream**: manages sources shown in the Earth media panel.
- **News Content**: browses news grouped by RSS source and manual group. RSS items remain read-only; manual groups support create, JSON import, edit, delete, and reprocess.

View File

@@ -28,6 +28,9 @@ This document standardizes terms used across Intelligent Planet, the console, ba
| AI Provider | AI Provider | Service name |
| tool | 工具 | Web Search, OCR, and similar integrations |
| Playground | Playground | Interactive debugging entry |
| branding | 品牌标识 | Earth HUD brand configuration section under `/earth-content` |
| brand assets | 品牌资源 | Logo, title image, and related HUD copy assets |
| title image | 标题图 | Earth HUD title image |
## Data Types

View File

@@ -2,6 +2,48 @@
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
## Docker Initialization and Access
Initialize a new machine before starting the application services:
```bash
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
```
The entry point still requires `zsh`, `curl`, and reachable package repositories. Before synchronizing Python and frontend dependencies, `init` prepares Docker:
- Reuse working Docker, Compose v2, and Buildx (at least 0.17.0).
- On Ubuntu / Ubuntu WSL, use apt to install the missing parts of `docker.io`, `docker-compose-v2`, and `docker-buildx`. When Docker CE CLI is already installed, use the configured Docker CE repository and corresponding plugin packages to keep the package family consistent.
- If the local daemon is unavailable, check that `docker.service` exists, then enable and start it. WSL must have systemd enabled; unavailable service management produces an explicit Docker preparation error.
- If the current user cannot read and write the Docker socket, check for `usermod`, install its `passwd` package when needed, and add the user to the `docker` group. This group grants privileged control of the local Docker engine. The script uses `sudo` to refresh group access as the original user and continue the original command with its arguments preserved. It does not depend on `sg` or run application processes as root.
When elevation is needed, sudo authentication runs in the foreground. Missing sudo for an unprivileged user, authentication failure, repository errors, or insufficient versions after installation stop initialization with a specific error.
Subsequent `planet.sh start` and other service commands in the same old terminal also refresh Docker group membership when it has been granted but is not yet active. Open a new Ubuntu session to use `docker` directly in the terminal.
When Docker Desktop is present but its WSL integration is unavailable, the script asks the operator to start Desktop and enable WSL Integration for the distribution. Unreachable remote or rootless endpoints produce a diagnostic for that environment; neither case installs a second local engine automatically. Automatic installation on other operating systems is not currently supported.
`planet.sh` calls `scripts/lib/docker-bootstrap.zsh` for preparation. Missing CLI, missing service units, socket permissions, and stopped daemons receive separate diagnostics. Advice to start `docker.socket` is shown only after confirming that the unit exists. Verify the result with:
```bash
docker info
docker compose version
docker buildx version
```
## Database Initialization and Connection Checks
`init` reconciles PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, `init` runs `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1` before reporting database readiness or creating tables and seed data.
- If the actual local port mapping is still missing or mismatched, the script recreates PostgreSQL once from Compose while preserving its data volume, then checks again. A second failure stops initialization.
- Authentication, database-name, and network failures stop before schema changes. Diagnostics show the host, port, and database name without passwords, full connection strings, or raw driver exceptions.
- A process-level `DATABASE_URL` overrides `backend/.env`. Changing `POSTGRES_PASSWORD` alone updates neither the connection string nor the password stored in an existing data volume. Existing environment files are retained and their effective configuration must be checked.
- Explicit external databases do not require a local container mapping. Host networking also does not require published ports. Both still require the real connection check.
For `port is already allocated` or `address already in use`, inspect `docker ps` port information and `ss -ltnp '( sport = :5432 )'`. With WSL mirrored networking, also inspect Windows listeners. Initialization does not kill other database services to acquire a port, delete data volumes, or reset passwords.
## First Startup
```bash

View File

@@ -34,9 +34,10 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
3. `/earth-content?section=brand`: maintain the Earth HUD logo and title image in Branding. The Upload button inside each URL field opens a file picker, and image files can also be dropped directly onto the matching field. Save the brand configuration after upload
4. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
5. `/alerts/system`: verify system alerts look right
6. `/users` (super_admin only): open accounts for teammates or adjust their groups
## 4. Open Earth

View File

@@ -418,6 +418,7 @@ AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
- `品牌标识` 管理 Earth HUD 的品牌资源。`Logo 地址``标题图地址` 使用字段内上传控件,上传按钮保持 `TactileButton` 的 primary 样式;图片拖到对应字段时显示低饱和拖拽反应,避免回到旧的全局“选择资产/上传”工具栏。
- `国界精度` 管理 Earth 静态国界资产provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
- `地球底图``图层资源``三维素材``新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。

View File

@@ -191,7 +191,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
`/earth-content` 位于控制台“运维与配置”下,面向智能星球前端体验资源:
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为智能星球品牌资产并立即供智能星球页面读取。
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述`Logo 地址``标题图地址` 字段内各有独立的“上传”按钮,也可以把图片直接拖到对应字段;上传成功后字段会写入新的资产地址,保存品牌配置后供智能星球页面读取。
- **关于**:维护智能星球设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
- **电视直播**:维护智能星球媒体面板里的直播源。
- **新闻内容**:按 RSS 来源和手动新闻组查看新闻。RSS 新闻保持只读;手动新闻组可以新增、批量导入 JSON、编辑、删除和重新处理。

View File

@@ -28,6 +28,9 @@
| AI Provider | AI Provider | 服务名,保留英文 |
| tool | 工具 | Web Search、OCR 等工具配置 |
| Playground | Playground | 交互调试入口,保留英文 |
| branding | 品牌标识 | `/earth-content` 中的 Earth HUD 品牌配置分区 |
| brand assets | 品牌资源 | Logo、标题图和相关 HUD 文案资源 |
| title image | 标题图 | Earth HUD 标题图片,不写作“标题图片地址”以外的混合名 |
## 数据类型

View File

@@ -2,6 +2,48 @@
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
## Docker 初始化与访问权限
新机器应先执行初始化,再启动应用服务:
```bash
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
```
脚本入口仍需先安装 `zsh``curl`,并保证软件源可访问。`init` 在同步 Python 和前端依赖之前准备 Docker
- 已有可用的 Docker、Compose v2 和 Buildx至少 0.17.0)时直接复用。
- Ubuntu / Ubuntu WSL 缺少依赖时,通过 apt 安装 `docker.io``docker-compose-v2``docker-buildx` 中缺失的部分。若已安装 Docker CE CLI则使用已配置的 Docker CE 软件源和对应插件包,避免混用软件包系列。
- 本地 Docker daemon 未运行时,确认 `docker.service` 存在后启用并启动它。WSL 必须启用 systemd如果服务管理不可用脚本会在 Docker 准备阶段明确报错。
- 当前用户不能读写 Docker socket 时,检查并补装提供 `usermod``passwd` 包,将用户加入 `docker` 组。该组拥有管理本机 Docker 的高权限。脚本使用 `sudo` 以原用户身份刷新组权限并继续原命令,保留参数,不依赖 `sg`,也不会把应用进程改为 root 用户运行。
需要提权时,脚本会在前台请求 sudo 认证。普通用户缺少 sudo、认证失败、软件源不可用或安装后版本仍不满足要求时初始化会停止并报告具体原因。
同一旧终端随后执行 `planet.sh start` 等命令时,也会检测已加入但尚未生效的 Docker 组权限并刷新。若要在终端直接使用 `docker`,重新打开 Ubuntu 会话即可。
Docker Desktop 已存在但 WSL 集成不可用时,脚本提示启动 Desktop 并启用当前发行版的 WSL Integration。已有远程或 rootless endpoint 无法连接时,提示检查当前环境;这些情况不会自动安装另一套本地引擎。其他操作系统的自动安装暂未支持。
安装逻辑由 `planet.sh` 调用 `scripts/lib/docker-bootstrap.zsh`;缺少 CLI、没有服务单元、socket 权限不足和 daemon 未启动会分别诊断。仅在确认 `docker.socket` 单元存在时才给出启动该单元的建议。验证准备结果可执行:
```bash
docker info
docker compose version
docker buildx version
```
## 数据库初始化与连接检查
`init` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`;通过后才显示“数据库服务已就绪”并创建表和默认数据。
- 如果本地实际端口映射仍缺失或不匹配,脚本会保留数据卷,按 Compose 配置重建一次 PostgreSQL 并重新检查;再次失败就停止。
- 认证、库名或网络错误会在建表前停止,诊断只显示目标主机、端口和库名,不输出密码、完整连接串或驱动异常原文。
- 进程环境变量中的 `DATABASE_URL` 优先于 `backend/.env`。单独修改 `POSTGRES_PASSWORD` 不会自动更新连接串,也不会改变已有数据卷内的密码。已有环境文件会保留,需要核对其有效配置。
- 显式配置的外部数据库不要求本地容器端口匹配host 网络模式也不要求发布端口,两者仍须通过实际连接检查。
出现 `port is already allocated``address already in use` 时,检查 `docker ps` 的端口信息和 `ss -ltnp '( sport = :5432 )'`WSL 镜像网络下还需检查 Windows 侧监听。初始化不会为了占用数据库端口而自动结束其他数据库服务,也不会删除数据卷或重设密码。
## 首次启动
```bash

View File

@@ -34,9 +34,10 @@
1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?section=integrations`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
3. `/earth-content?section=brand`:在“品牌标识”里维护智能星球的 Logo 和标题图;对应地址字段内的“上传”按钮支持选择文件,也支持把图片直接拖到字段上,保存后会应用到智能星球 HUD
4. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
5. `/alerts/system`:看系统告警是否正常
6. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
## 4. 打开智能星球

View File

@@ -16,12 +16,15 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.74.0`
- `dev` 当前开发分支历史推导到:`0.74.3`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 |
| `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 |
| `0.74.1` | improvement | `dev` | `pending` | 将品牌标识上传收敛到 Logo/标题图字段内,新增字段级拖拽反馈和 Tactile UI primary 上传按钮,并同步中英文使用文档 |
| `0.74.0` | feature | `dev` | `pending` | 扩展统一 i18n 到 Web Earth 动态入口、控制台/API 错误和公开页面,修复 Earth 通知胶囊、语言 switch、品牌栏、legend、tooltip、新闻/TV 英文态裁切与中文残留,并加入 harness 回归覆盖 |
| `0.73.0` | feature | `dev` | `pending` | 新增前端统一 i18n、控制台语言/主题偏好入口、英文态 legacy 过渡翻译和 admin 一屏/状态指示器布局验证 |
| `0.72.0` | feature | `dev` | `pending` | 新增完整 agent harness、单一 AGENTS 入口、Earth News smoke 覆盖和 collector 结构化日志清理,并同步控制台/Earth/Docs 响应式维护文档 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.74.0",
"version": "0.74.3",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -27,7 +27,7 @@ import {
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
@@ -132,6 +132,8 @@ type DatasourceMetricBaseline = {
count: number
}
type BrandAssetTargetKey = 'logo_src' | 'title_src'
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
product: '',
module: '',
@@ -871,6 +873,14 @@ type FieldConfig = {
placeholder?: string
disabled?: boolean
help?: string
renderInput?: (props: {
disabled?: boolean
displayValue: unknown
fieldKey: string
onChange: (value: unknown) => void
placeholder?: string
value: unknown
}) => ReactNode
inputAction?: {
ariaLabel?: string
disabled?: boolean
@@ -2301,6 +2311,9 @@ function FieldGrid({
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
const searchText = [label, field.key, text(displayValue, '')].filter(Boolean).join(' ')
const commitFieldValue = (nextValue: unknown) => {
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
}
if (field.type === 'boolean') {
return (
<label key={field.key} className="an-checkbox-row an-field--wide" data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
@@ -2308,7 +2321,7 @@ function FieldGrid({
type="checkbox"
checked={Boolean(value)}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.checked))}
onChange={(event) => commitFieldValue(event.target.checked)}
/>
{label}
{help ? <small>{help}</small> : null}
@@ -2323,7 +2336,7 @@ function FieldGrid({
className="an-input"
value={text(value, '')}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.value))}
onChange={(event) => commitFieldValue(event.target.value)}
>
{(field.options || []).map((option) => (
<option key={option.value} value={option.value}>
@@ -2346,9 +2359,9 @@ function FieldGrid({
onChange={(event) => {
const nextValue = event.target.value
try {
onDraftChange(setNestedDraftField(draft, record, field.key, JSON.parse(nextValue)))
commitFieldValue(JSON.parse(nextValue))
} catch {
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
commitFieldValue(nextValue)
}
}}
spellCheck={false}
@@ -2401,6 +2414,22 @@ function FieldGrid({
</label>
)
}
if (field.renderInput) {
return (
<div key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{label}</span>
{field.renderInput({
disabled: field.disabled,
displayValue,
fieldKey: field.key,
onChange: commitFieldValue,
placeholder,
value,
})}
{help ? <small>{help}</small> : null}
</div>
)
}
const inputAction = field.inputAction
? {
...field.inputAction,
@@ -2421,12 +2450,7 @@ function FieldGrid({
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
record,
field.key,
field.type === 'number' ? Number(event.target.value) : event.target.value,
))}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
</ConnectionTestInput>
) : (
@@ -2436,12 +2460,7 @@ function FieldGrid({
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
record,
field.key,
field.type === 'number' ? Number(event.target.value) : event.target.value,
))}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
)}
{help ? <small>{help}</small> : null}
@@ -2545,6 +2564,25 @@ function GroupList({
const PLAYGROUND_SESSION_KEY = 'default'
const BRAND_ASSET_ACCEPT = '.png,.jpg,.jpeg,.webp,.svg'
const BRAND_ASSET_SUFFIXES = ['png', 'jpg', 'jpeg', 'webp', 'svg']
const BRAND_ASSET_TARGET_META: Record<BrandAssetTargetKey, {
copyLabel: string
dropLabel: string
uploadLabel: string
uploadTitle: string
}> = {
logo_src: {
copyLabel: '复制为 Logo',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传 Logo',
},
title_src: {
copyLabel: '复制为标题图',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传标题图片',
},
}
const PLAYGROUND_PRESETS = [
{
key: 'bgp-brief',
@@ -2572,6 +2610,121 @@ const PLAYGROUND_PRESETS = [
},
]
function isBrandAssetTargetKey(value: string): value is BrandAssetTargetKey {
return value === 'logo_src' || value === 'title_src'
}
function isFileDrag(event: ReactDragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes('Files')
}
function BrandAssetInput({
disabled,
onChange,
onFile,
placeholder,
target,
uploading,
value,
}: {
disabled?: boolean
onChange: (value: string) => void
onFile: (file: File) => void
placeholder?: string
target: BrandAssetTargetKey
uploading: boolean
value: string
}) {
const { locale } = useLocale()
const [dragging, setDragging] = useState(false)
const dragCounter = useRef(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const meta = BRAND_ASSET_TARGET_META[target]
const suffixHelp = BRAND_ASSET_SUFFIXES.join(' / ')
const resetDrag = () => {
dragCounter.current = 0
setDragging(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current += 1
setDragging(true)
}
const handleDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
}
const handleDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current = Math.max(0, dragCounter.current - 1)
if (dragCounter.current === 0) setDragging(false)
}
const handleDrop = (event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault()
resetDrag()
const file = event.dataTransfer.files?.[0]
if (file) onFile(file)
}
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) onFile(file)
}
return (
<div
className={dragging ? 'an-brand-asset-input is-dragging' : 'an-brand-asset-input'}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
className="an-brand-asset-input__control"
disabled={disabled || uploading}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value}
/>
<input
ref={fileInputRef}
accept={BRAND_ASSET_ACCEPT}
className="an-brand-asset-input__file"
disabled={disabled || uploading}
onChange={handleFileChange}
type="file"
/>
<Button
className="an-brand-asset-input__upload"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
size="sm"
tactile={{ height: 28, radius: 5, shadowSize: 0.55 }}
title={`${localizeAdminText(meta.uploadTitle, locale)} · ${suffixHelp}`}
type="button"
variant="primary"
>
{uploading ? localizeAdminText('上传中', locale) : localizeAdminText(meta.uploadLabel, locale)}
</Button>
{dragging ? (
<div className="an-brand-asset-input__drop-overlay" aria-hidden="true">
<span>{localizeAdminText(meta.dropLabel, locale)}</span>
<strong>{localizeAdminText(meta.copyLabel, locale)}</strong>
</div>
) : null}
</div>
)
}
function PlaygroundLite() {
const { toast } = useToast()
const { locale } = useLocale()
@@ -2848,7 +3001,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [revealedSecrets, setRevealedSecrets] = useState<Record<string, AnyRecord>>({})
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
const [brandAssetUploadingTarget, setBrandAssetUploadingTarget] = useState<BrandAssetTargetKey | null>(null)
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
const newsImportInputRef = useRef<HTMLInputElement>(null)
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
@@ -4002,51 +4155,46 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setResolveTarget(null)
}
const uploadBrandAsset = async () => {
if (!brandUploadFile) {
toast({ title: '请选择上传文件', tone: 'error' })
return
}
const suffix = brandUploadFile.name.split('.').pop()?.toLowerCase() || ''
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`, tone: 'error' })
return
}
const formData = new FormData()
formData.append('file', brandUploadFile)
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
...response.data,
__title: '品牌资产上传结果',
__module: '品牌资产',
__status: '已上传',
__metric: brandUploadFile.name,
}])
setBrandUploadFile(null)
toast({ title: '品牌资产已上传', tone: 'success' })
await load()
} catch (error) {
toast({ title: '品牌资产上传失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
} finally {
setActionLoading(false)
}
}
const selectBrandUploadFile = (file: File | null | undefined) => {
const uploadBrandAssetFile = async (file: File | null | undefined, target: BrandAssetTargetKey) => {
if (!file) {
setBrandUploadFile(null)
return
toast({ title: '请拖入图片文件', tone: 'error' })
return null
}
const suffix = file.name.split('.').pop()?.toLowerCase() || ''
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`, tone: 'error' })
return
return null
}
const formData = new FormData()
formData.append('file', file)
setActionLoading(true)
setBrandAssetUploadingTarget(target)
try {
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const assetUrl = text(response.data?.url, '')
if (!assetUrl) throw new Error('上传接口未返回资产 URL')
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
...response.data,
__title: '品牌资产上传结果',
__module: target === 'logo_src' ? 'Logo' : '标题图片',
__status: '已上传',
__metric: file.name,
}])
toast({
title: target === 'logo_src' ? 'Logo 图片已上传' : '标题图片已上传',
description: assetUrl,
tone: 'success',
})
return assetUrl
} catch (error) {
toast({ title: '品牌资产上传失败', description: actionErrorMessage(error), tone: 'error' })
return null
} finally {
setActionLoading(false)
setBrandAssetUploadingTarget(null)
}
setBrandUploadFile(file)
}
const createManualNewsGroup = async () => {
@@ -5413,6 +5561,32 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{ key: 'location_label', label: '位置标签' },
]
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
const uploadBrandAssetToDraft = async (file: File, target: BrandAssetTargetKey) => {
if (!activeGroup) return
const assetUrl = await uploadBrandAssetFile(file, target)
if (!assetUrl) return
setHierarchyDraft((current) => setNestedDraftField(current, activeGroup.record, target, assetUrl))
}
const editableFields = config === configs.earthContent && activeSection.key === 'brand'
? fields.map((field): FieldConfig => {
if (!isBrandAssetTargetKey(field.key)) return field
const target = field.key
return {
...field,
renderInput: ({ disabled, displayValue, onChange, placeholder }) => (
<BrandAssetInput
disabled={disabled}
onChange={(nextValue) => onChange(nextValue)}
onFile={(file) => void uploadBrandAssetToDraft(file, target)}
placeholder={placeholder}
target={target}
uploading={brandAssetUploadingTarget === target}
value={text(displayValue, '')}
/>
),
}
})
: fields
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5951,20 +6125,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<div className="an-toolbar">
{config === configs.earthContent && activeSection.key === 'brand' ? (
<>
<label
className="an-file-action an-file-action--drop"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
selectBrandUploadFile(event.dataTransfer.files?.[0])
}}
title={`支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`}
>
<input type="file" accept={BRAND_ASSET_ACCEPT} onChange={(event) => selectBrandUploadFile(event.target.files?.[0])} />
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择/拖入资产'}</span>
<small>{BRAND_ASSET_SUFFIXES.join(' / ')}</small>
</label>
<Button variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} /></Button>
<Button size="icon" variant="subtle" title="重置品牌配置" aria-label="重置品牌配置" onClick={() => setConfirmAction({
title: '重置品牌配置',
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
@@ -6309,7 +6469,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</section>
</>
) : (
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={editableFields} searchGroupKey={activeGroup.key} />
)}
{config === configs.collection && activeSection.key === 'collection_history' ? null : (
<details className="an-advanced-editor">
@@ -6463,11 +6623,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
if (config === configs.earthContent && endpointKey === 'brand') {
actions.push(
<label key="brand-upload" className="an-file-action">
<input type="file" onChange={(event) => setBrandUploadFile(event.target.files?.[0] ?? null)} />
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择资产'}</span>
</label>,
<Button key="brand-upload-run" variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} /></Button>,
<Button key="delete-brand" variant="danger" onClick={() => setConfirmAction({
title: '删除品牌配置',
description: '确认删除智能星球品牌配置?',

View File

@@ -1697,6 +1697,111 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
border-style: dashed;
}
.an-brand-asset-input {
position: relative;
min-width: 0;
height: 34px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
overflow: hidden;
border: 1px solid var(--an-border);
border-radius: 6px;
background: var(--an-surface);
color: var(--an-text);
transition: border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
}
.an-brand-asset-input:focus-within {
border-color: color-mix(in srgb, var(--an-accent) 24%, var(--an-border-strong));
}
.an-brand-asset-input.is-dragging {
border-color: var(--an-border-strong);
background: color-mix(in srgb, var(--an-soft) 46%, var(--an-surface));
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1), 0 8px 18px rgba(15, 23, 42, 0.06);
}
.an-brand-asset-input__control {
width: 100%;
min-width: 0;
height: 100%;
border: 0;
outline: 0;
background: transparent;
color: inherit;
padding: 0 10px;
font: inherit;
line-height: 32px;
}
.an-brand-asset-input__control:disabled {
color: var(--an-muted);
}
.an-brand-asset-input__file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.an-brand-asset-input__upload {
position: relative;
z-index: 1;
min-width: 72px;
height: var(--tui-control-height, 28px);
margin-right: 3px;
padding-inline: 10px;
white-space: nowrap;
}
.an-brand-asset-input__drop-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
border: 1px dashed var(--an-border-strong);
border-radius: 5px;
background: color-mix(in srgb, var(--an-soft) 82%, var(--an-surface));
color: var(--an-text);
pointer-events: none;
}
.an-brand-asset-input__drop-overlay > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 800;
}
.an-brand-asset-input__drop-overlay > strong {
min-width: max-content;
height: 28px;
padding: 0 10px;
border: 1px solid var(--an-border);
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--an-surface);
color: var(--an-text);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
font-size: 13px;
font-weight: 800;
}
.admin-theme-root[data-theme='dark'] .an-brand-asset-input__drop-overlay > strong {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.34);
}
.an-mapping-form {
display: grid;
gap: 12px;

View File

@@ -556,6 +556,15 @@ export const legacyUiTextEnUS: Record<string, string> = {
'描述': 'Description',
'每日摘要': 'Daily digest',
'Logo 地址': 'Logo URL',
'上传 Logo': 'Upload logo',
'上传标题图片': 'Upload title image',
'上传中': 'Uploading',
'将图片拖到这里': 'Drop file here',
'复制为 Logo': 'Copy as logo',
'复制为标题图': 'Copy as title image',
'Logo 图片已上传': 'Logo image uploaded',
'标题图片已上传': 'Title image uploaded',
'请拖入图片文件': 'Drop an image file',
'API 基础地址': 'API base URL',
'Firecrawl 抓取路径': 'Firecrawl scrape path',
'Firecrawl 搜索路径': 'Firecrawl search path',

117
planet.sh
View File

@@ -229,6 +229,8 @@ prepare_uv_build_config() {
prepare_uv_build_config
source "$SCRIPT_DIR/scripts/lib/docker-bootstrap.zsh"
# Shell / Docker helpers
is_pid() {
[[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
@@ -373,14 +375,6 @@ write_port_state() {
chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true
}
compose_available() {
docker compose version >/dev/null 2>&1
}
compose_v1_available() {
command -v docker-compose >/dev/null 2>&1
}
report_missing_compose() {
clear_wait_spinner
log_error "未检测到可用的 Docker Compose"
@@ -394,27 +388,9 @@ report_missing_compose() {
return 1
}
buildx_version() {
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
}
buildx_meets_minimum() {
local current_version="$1"
local required_version="$2"
[ -n "$current_version" ] || return 1
[ -n "$required_version" ] || return 1
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
return 0
fi
return 1
}
log_buildx_diagnostics_if_needed() {
local current_buildx_version=""
local required_buildx_version="0.17.0"
local required_buildx_version="$DOCKER_MIN_BUILDX_VERSION"
current_buildx_version="$(buildx_version)"
@@ -440,6 +416,10 @@ compose_up() {
if docker compose "${args[@]}"; then
return 0
fi
if ! compose_v1_available; then
log_error "Docker Compose 执行失败: docker compose ${arg_text}"
return 1
fi
log_warn "docker compose 执行失败,回退到 docker-compose v1"
fi
@@ -1298,47 +1278,6 @@ format_wait_elapsed_seconds() {
'
}
docker_daemon_available() {
docker info >/dev/null 2>&1
}
log_docker_daemon_diagnostics() {
local docker_service_status=""
local docker_socket_status=""
local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker"
docker_service_status="$(systemctl is-active docker 2>/dev/null || true)"
docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)"
if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then
log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。"
log_note "请确认当前用户已加入 docker 组,并重新登录会话。"
return 0
fi
if [ "$docker_service_status" = "active" ]; then
log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。"
log_note "请先执行: docker info"
return 0
fi
if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then
log_note "检测到 Docker daemon 当前未正常运行。"
log_note "可先执行: ${service_hint}"
log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager"
return 0
fi
if [ "$docker_socket_status" != "active" ]; then
log_note "检测到 Docker socket 未激活daemon 可能无法通过 systemd socket activation 启动。"
log_note "可先执行: ${service_hint}"
return 0
fi
log_note "当前无法连接 Docker daemon请先执行: docker info"
log_note "若 daemon 未启动,可尝试: ${service_hint}"
}
print_database_failure_diagnostics() {
if ! docker_daemon_available; then
log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
@@ -2505,15 +2444,16 @@ wait_for_postgres_health() {
}
start_database_services() {
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
# Reconcile existing containers too; docker start cannot apply changed port bindings.
compose_up up -d --no-deps postgres redis
}
restart_database_services() {
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
start_database_services && docker restart planet_postgres planet_redis
}
start_postgres_service() {
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
compose_up up -d --no-deps postgres
}
# Backend lifecycle helpers
@@ -4046,6 +3986,21 @@ asyncio.run(init_db())
PY
}
verify_backend_database_connection() {
local check_status=0
local check_command=("$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/scripts/check_database_connection.py")
set_wait_detail "验证后端实际 PostgreSQL 地址、端口与认证"
run_command_with_spinner "验证后端数据库连接" "${check_command[@]}" || check_status=$?
# Exit 2 means missing published ports; credentials and other failures must not recreate data services.
[ "$check_status" -eq 2 ] || return "$check_status"
log_warn "PostgreSQL 实际端口映射不匹配,保留数据卷并按 Compose 配置重建一次"
compose_up up -d --no-deps --force-recreate postgres || return 1
wait_for_postgres_health || return 1
run_command_with_spinner "重新验证后端数据库连接" "${check_command[@]}"
}
planet_app_services_running() {
local backend_port=""
local frontend_port=""
@@ -4109,6 +4064,14 @@ init() {
print_splash
start_wait_session "准备 Docker 运行环境"
if ! ensure_docker_runtime "${PLANET_COMMAND_ARGS[@]}"; then
stop_wait_session
exit 1
fi
stop_wait_session
log_success "Docker / Compose / Buildx 已就绪"
start_wait_session "准备 Python 运行时"
ensure_python_runtime
stop_wait_session
@@ -4132,6 +4095,11 @@ init() {
start_wait_session "启动数据库服务"
ensure_database_services_healthy
if ! verify_backend_database_connection; then
stop_wait_session
log_error "后端数据库连接检查失败,尚未执行建表和默认数据初始化"
exit 1
fi
stop_wait_session
log_success "数据库服务已就绪"
@@ -4677,9 +4645,16 @@ parse_global_args() {
trap cleanup_failed_start EXIT
PLANET_COMMAND_ARGS=("$@")
parse_global_args "$@"
set -- "${GLOBAL_ARG_REMAINDER[@]}"
case "$1" in
init|start|restart|stop|health|log|createuser)
refresh_docker_group "${PLANET_COMMAND_ARGS[@]}" || exit 1
;;
esac
case "$1" in
init)
shift

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.74.0"
version = "0.74.3"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

View File

@@ -161,6 +161,13 @@ rg -n "api[_-]?key|client_secret|BEGIN .*PRIVATE KEY|AKIA[0-9A-Z]" .
Always.
### Harness
- `rules.md` is the active repository rule source. Keep durable constraints here instead of duplicating them across prompt files.
- Use `.codex/skills/` for specialized Codex workflows such as cleanup, docs, goal-driven work, and release.
- Do not add new legacy harness entry points when an existing skill or rule module can carry the same instruction.
- If a harness rule is no longer true for the current toolchain, update or delete it in the same cleanup pass.
### Git
- Do not revert user changes unless explicitly requested.
@@ -173,6 +180,16 @@ Always.
- Prefer maintained, widely used libraries.
- Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json`.
### Visual Evidence Gate
- If the user provides a screenshot, image, video frame, visual mock, browser capture, or says something like "as shown", obtain evidence from that artifact before interpreting the request or changing code.
- Path resolution is part of the task. If a provided path does not open, try reasonable local equivalents first, such as WSL/Windows path conversion, workspace-relative paths, absolute paths, and attached-file locations.
- If the artifact still cannot be found or opened, stop that visual-dependent work and report the exact path/access problem instead of guessing. Ask for an accessible file/path or a fresh screenshot.
- Do not infer visual intent from the filename, surrounding text, alt text, logs, or prior assumptions.
- OCR is acceptable evidence for text-only questions or when the active environment lacks multimodal image viewing, but state that OCR was the fallback. Layout, color, spacing, pixel, and rendering issues still require a real visual inspection or an explicit "could not verify visually" note.
- After inspecting the artifact, ground the work in at least one concrete observed detail when it affects the task.
- For UI or rendering fixes that depend on appearance, verify with a real screenshot or browser render when the project can be run locally.
### Deterministic Context
- Prefer compact CLI evidence over reading large files or full diffs:

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Check the backend's PostgreSQL target before init is allowed to change its schema."""
import asyncio
import json
import os
from pathlib import Path
import subprocess
import sys
from sqlalchemy import text
from sqlalchemy.engine import URL, make_url
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
ROOT = Path(__file__).resolve().parents[1]
CONNECT_TIMEOUT_SECONDS = 10
DEFAULT_POSTGRES_PORT = 5432
POSTGRES_CONTAINER = "planet_postgres"
POSTGRES_CONTAINER_PORT = "5432/tcp"
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
class DatabaseReadinessError(RuntimeError):
"""A safe, actionable diagnostic that does not include connection credentials."""
class MissingPortBindingError(DatabaseReadinessError):
"""Exit 2 asks planet.sh to reconcile the managed container once, preserving its volume."""
def backend_database_url() -> str:
sys.path.insert(0, str(ROOT / "backend"))
from app.core.config import settings
return settings.DATABASE_URL
def check_published_port(url: URL) -> None:
if url.host not in LOOPBACK_HOSTS:
return # An explicitly configured external database has no local container mapping.
result = subprocess.run(
[
"docker",
"inspect",
"--format",
"{{.HostConfig.NetworkMode}}\n{{json .NetworkSettings.Ports}}",
POSTGRES_CONTAINER,
],
capture_output=True,
text=True,
timeout=CONNECT_TIMEOUT_SECONDS,
check=False,
)
if result.returncode:
raise DatabaseReadinessError(
"无法读取 planet_postgres请检查当前 Docker context 和容器状态。"
)
network_mode, ports_json = result.stdout.strip().split("\n", 1)
if network_mode == "host":
return # Host networking intentionally has no published-port table.
ports = json.loads(ports_json) or {}
bindings = ports.get(POSTGRES_CONTAINER_PORT) or []
port = str(url.port or DEFAULT_POSTGRES_PORT)
if not any(binding.get("HostPort") == port for binding in bindings):
raise MissingPortBindingError(
f"planet_postgres 未向宿主机发布后端配置的端口 {port}"
"请核对 Compose ports、DATABASE_URL 和 Docker context。"
"容器内部健康不代表宿主机可连接。"
)
async def check_connection(url: URL) -> None:
engine = create_async_engine(url, poolclass=NullPool, echo=False)
try:
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
async with engine.connect() as connection:
await connection.execute(text("SELECT 1"))
finally:
await engine.dispose()
def connection_diagnostic(error: BaseException) -> str:
pending = [error]
visited: set[int] = set()
while pending:
current = pending.pop()
if id(current) in visited:
continue
visited.add(id(current))
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
if sqlstate in {"28P01", "28000"}:
return (
"PostgreSQL 认证失败:核对 DATABASE_URL 的账号密码及连接目标。"
"只改 POSTGRES_PASSWORD 不会更新 DATABASE_URL也不会重设已有数据卷的密码。"
)
if sqlstate == "3D000":
return "目标数据库不存在:核对 DATABASE_URL 的库名与已有数据库。"
if isinstance(current, (TimeoutError, OSError)):
return "数据库连接被拒绝、超时或地址不可达:核对端口映射、监听服务和 Docker endpoint。"
for nested in (getattr(current, "orig", None), current.__cause__, current.__context__):
if isinstance(nested, BaseException):
pending.append(nested)
return "数据库连接检查失败:核对 backend/.env、进程环境变量和目标 PostgreSQL 服务日志。"
def main() -> int:
try:
url = make_url(backend_database_url())
print(
f"后端数据库目标: host={url.host!r} port={url.port or DEFAULT_POSTGRES_PORT} database={url.database!r}"
)
print(
"DATABASE_URL 来源: "
+ ("进程环境变量" if "DATABASE_URL" in os.environ else "backend 配置")
)
check_published_port(url)
asyncio.run(check_connection(url))
except MissingPortBindingError as error:
print(str(error), file=sys.stderr)
return 2
except DatabaseReadinessError as error:
print(str(error), file=sys.stderr)
return 1
except Exception as error:
# Driver/config exceptions can contain DSNs and passwords. Never echo their raw text.
print(connection_diagnostic(error), file=sys.stderr)
return 1
print("后端 PostgreSQL 连接与认证已通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -76,6 +76,8 @@ main() {
check_file docs/harness-audit.md
check_file docs/documentation-coverage-rules.md
check_file planet.sh
check_file scripts/lib/docker-bootstrap.zsh
check_file scripts/harness/test_docker_bootstrap.py
check_file pyproject.toml
check_file frontend/package.json
check_file scripts/harness/security-check.sh

View File

@@ -13,6 +13,9 @@ main() {
harness_run scripts/harness/doctor.sh
harness_run git diff --check
harness_run zsh -n planet.sh
harness_run zsh -n scripts/lib/docker-bootstrap.zsh
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_bootstrap.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_database_startup.py
harness_run bash -n scripts/bootstrap-dev.sh
harness_run bash -n scripts/harness/lib.sh
harness_run bash -n scripts/harness/doctor.sh

View File

@@ -0,0 +1,274 @@
"""Database lifecycle regressions without starting or changing host containers."""
import asyncio
from contextlib import redirect_stderr, redirect_stdout
import io
import json
from pathlib import Path
import re
import subprocess
import sys
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from asyncpg import InvalidCatalogNameError, InvalidPasswordError
from sqlalchemy.engine import make_url
from sqlalchemy.exc import DBAPIError
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
import check_database_connection as probe # noqa: E402
TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/planet_db")
def shell_function(name: str) -> str:
source = (ROOT / "planet.sh").read_text()
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
if match is None:
raise AssertionError(f"missing shell function: {name}")
return match.group()
def run_shell(functions: list[str], setup: str, action: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["zsh", "-f"],
input="\n".join(["set -e", *(shell_function(name) for name in functions), setup, action]),
capture_output=True,
text=True,
timeout=10,
check=False,
)
class DatabaseLifecycleTests(unittest.TestCase):
def test_existing_container_gets_current_compose_configuration(self) -> None:
for function in ("start_database_services", "start_postgres_service"):
with self.subTest(function=function):
result = run_shell(
[function],
"""
mapped=0
docker() { return 0; }
compose_up() { mapped=1; }
""",
f"{function}\n[ $mapped -eq 1 ]",
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_port_conflict_is_visible_and_cannot_fall_back_to_old_container(self) -> None:
result = run_shell(
["start_database_services"],
"""
docker() { return 0; }
compose_up() { echo 'port is already allocated' >&2; return 1; }
""",
"start_database_services",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("port is already allocated", result.stderr)
def test_installed_compose_failure_is_not_reported_as_missing_compose(self) -> None:
result = run_shell(
["compose_up"],
"""
compose_available() { return 0; }
compose_v1_available() { return 1; }
docker() { echo 'address already in use' >&2; return 1; }
set_wait_detail() { :; }
log_warn() { echo "$*"; }
log_error() { echo "$*"; }
report_missing_compose() { echo MISSING_COMPOSE; return 1; }
""",
"compose_up up -d postgres",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("address already in use", result.stderr)
self.assertNotIn("MISSING_COMPOSE", result.stdout)
def run_init(self, connection_status: int) -> subprocess.CompletedProcess[str]:
return run_shell(
["init"],
f"""
for fn in parse_service_args guard_init_when_services_running print_splash \
start_wait_session stop_wait_session ensure_docker_runtime ensure_python_runtime \
sync_python_deps sync_frontend_deps ensure_planet_env_files \
prepare_motion_agent_host_dependencies set_wait_detail; do
functions[$fn]='return 0'
done
log_success() {{ echo "$*"; }}
log_error() {{ echo "$*"; }}
log_step() {{ :; }}
log_note() {{ :; }}
ensure_database_services_healthy() {{ echo CONTAINERS_HEALTHY; }}
verify_backend_database_connection() {{ echo CONNECTION_CHECK; return {connection_status}; }}
run_command_with_spinner() {{ shift; "$@"; }}
initialize_backend_database() {{ echo SCHEMA_INITIALIZED; }}
""",
"init --non-motion-agent",
)
def test_healthy_containers_do_not_allow_schema_changes_on_bad_connection(self) -> None:
result = self.run_init(1)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("CONNECTION_CHECK", result.stdout)
self.assertNotIn("SCHEMA_INITIALIZED", result.stdout)
self.assertNotIn("数据库服务已就绪", result.stdout)
def test_real_connection_is_checked_before_reporting_ready_and_creating_tables(self) -> None:
result = self.run_init(0)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("CONNECTION_CHECK", result.stdout)
self.assertLess(
result.stdout.index("CONNECTION_CHECK"), result.stdout.index("数据库服务已就绪")
)
self.assertLess(
result.stdout.index("数据库服务已就绪"), result.stdout.index("SCHEMA_INITIALIZED")
)
def test_only_missing_ports_trigger_one_volume_preserving_recreation(self) -> None:
for first_status, retry_status, expected_status, expected_recreates in (
(0, 0, 0, 0),
(1, 0, 1, 0),
(2, 0, 0, 1),
(2, 2, 2, 1),
):
with self.subTest(first_status=first_status, retry_status=retry_status):
result = run_shell(
["verify_backend_database_connection"],
f"""
checks=0
set_wait_detail() {{ :; }}
log_warn() {{ :; }}
compose_up() {{ echo "COMPOSE $*"; }}
wait_for_postgres_health() {{ return 0; }}
run_command_with_spinner() {{
checks=$((checks + 1))
if [ $checks -eq 1 ]; then return {first_status}; fi
return {retry_status}
}}
""",
"verify_backend_database_connection",
)
self.assertEqual(result.returncode, expected_status, result.stderr)
self.assertEqual(result.stdout.count("COMPOSE"), expected_recreates)
if expected_recreates:
self.assertIn("up -d --no-deps --force-recreate postgres", result.stdout)
self.assertNotIn("--renew-anon-volumes", result.stdout)
class DatabaseProbeTests(unittest.TestCase):
def docker_result(
self, ports: dict | None, mode: str = "bridge"
) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess([], 0, f"{mode}\n{json.dumps(ports)}\n", "")
def test_missing_port_blocks_sql_even_if_container_is_healthy(self) -> None:
output = io.StringIO()
with (
patch.object(probe, "backend_database_url", return_value=TEST_URL),
patch.object(probe.subprocess, "run", return_value=self.docker_result({})),
patch.object(probe, "check_connection", new_callable=AsyncMock) as connect,
redirect_stderr(output),
redirect_stdout(output),
):
self.assertEqual(probe.main(), 2)
connect.assert_not_called()
self.assertIn("未向宿主机发布", output.getvalue())
self.assertNotIn("test-secret", output.getvalue())
def test_actual_backend_port_must_match_published_port(self) -> None:
result = self.docker_result({"5432/tcp": [{"HostIp": "0.0.0.0", "HostPort": "15432"}]})
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError):
probe.check_published_port(TEST_URL)
probe.check_published_port(TEST_URL.set(port=15432))
def test_host_networking_does_not_require_a_port_mapping(self) -> None:
with patch.object(probe.subprocess, "run", return_value=self.docker_result(None, "host")):
probe.check_published_port(TEST_URL)
def test_external_database_does_not_require_a_local_mapping(self) -> None:
with patch.object(probe.subprocess, "run") as inspect:
probe.check_published_port(TEST_URL.set(host="configured-db.example"))
inspect.assert_not_called()
def test_docker_failure_does_not_echo_raw_output(self) -> None:
result = subprocess.CompletedProcess([], 1, "", "sensitive test-secret")
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError) as error:
probe.check_published_port(TEST_URL)
self.assertIn("Docker context", str(error.exception))
self.assertNotIn("test-secret", str(error.exception))
def test_authentication_error_is_actionable_without_leaking_dsn_or_query_secrets(self) -> None:
output = io.StringIO()
url = TEST_URL.update_query_dict({"sslpassword": "query-secret"})
error = DBAPIError(
None, None, InvalidPasswordError(url.render_as_string(hide_password=False))
)
with (
patch.object(probe, "backend_database_url", return_value=url),
patch.object(probe, "check_published_port"),
patch.object(probe, "check_connection", new_callable=AsyncMock, side_effect=error),
redirect_stderr(output),
redirect_stdout(output),
):
self.assertEqual(probe.main(), 1)
self.assertIn("认证失败", output.getvalue())
self.assertNotIn("test-secret", output.getvalue())
self.assertNotIn("query-secret", output.getvalue())
self.assertNotIn("Traceback", output.getvalue())
def test_missing_database_and_network_errors_have_distinct_diagnostics(self) -> None:
self.assertIn("数据库不存在", probe.connection_diagnostic(InvalidCatalogNameError()))
for error in (ConnectionRefusedError(), TimeoutError()):
self.assertIn("地址不可达", probe.connection_diagnostic(error))
def test_invalid_config_never_echoes_validation_input(self) -> None:
output = io.StringIO()
with (
patch.object(probe, "backend_database_url", side_effect=ValueError("test-secret")),
redirect_stderr(output),
):
self.assertEqual(probe.main(), 1)
self.assertNotIn("test-secret", output.getvalue())
class DatabaseConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_connection_deadline_stops_a_stalled_probe(self) -> None:
async def stall() -> None:
await asyncio.sleep(1)
engine = MagicMock()
engine.dispose = AsyncMock()
engine.connect.return_value.__aenter__.side_effect = stall
with (
patch.object(probe, "create_async_engine", return_value=engine),
patch.object(probe, "CONNECT_TIMEOUT_SECONDS", 0.001),
):
with self.assertRaises(TimeoutError):
await probe.check_connection(TEST_URL)
engine.dispose.assert_awaited_once()
async def test_probe_only_selects_and_always_disposes_the_engine(self) -> None:
for error in (None, InvalidPasswordError("test-secret")):
with self.subTest(error=type(error).__name__):
engine = MagicMock()
engine.dispose = AsyncMock()
connection = AsyncMock()
engine.connect.return_value.__aenter__.return_value = connection
connection.execute.side_effect = error
with patch.object(probe, "create_async_engine", return_value=engine):
if error:
with self.assertRaises(InvalidPasswordError):
await probe.check_connection(TEST_URL)
else:
await probe.check_connection(TEST_URL)
self.assertEqual(str(connection.execute.call_args.args[0]), "SELECT 1")
engine.dispose.assert_awaited_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,332 @@
"""Exercise Docker bootstrap with isolated command stubs, never the host daemon."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
MODULE = ROOT / "scripts/lib/docker-bootstrap.zsh"
COMMAND_STUB = r"""
import json
import os
from pathlib import Path
import subprocess
import sys
state_path = Path(os.environ["DOCKER_TEST_STATE"])
state = json.loads(state_path.read_text())
name = Path(sys.argv[0]).name
args = sys.argv[1:]
state["calls"].append([name, args])
def save() -> None:
state_path.write_text(json.dumps(state))
def finish(code: int = 0, output: str = "") -> None:
save()
if output:
print(output)
sys.exit(code)
def install_command(name: str) -> None:
target = state_path.parent / "bin" / name
if not target.exists():
target.symlink_to("mock-command")
if name == "id":
if args == ["-u"]:
finish(output=str(state["uid"]))
if args == ["-un"]:
finish(output="planet-test")
finish(output="planet-test" + (" docker" if state["member"] else ""))
if name == "sudo":
if state.get("sudo_denied"):
finish(1)
if args == ["-v"]:
finish()
if "-E" in args:
finish() # Record re-exec argv without executing planet.sh on this machine.
save()
sys.exit(subprocess.call(args[1:] if args[0] == "--" else args))
if name == "apt-get":
if state.get("apt_fail"):
finish(1, "simulated apt failure")
if args[0] == "install":
for package in args[2:]:
if package in ("docker.io", "docker-ce"):
state["engine"] = True
install_command("docker")
install_command("dockerd")
if package in ("docker-compose-v2", "docker-compose-plugin"):
state["compose"] = True
if package in ("docker-buildx", "docker-buildx-plugin"):
state["buildx"] = "0.20.0"
if package == "passwd":
install_command("usermod")
finish()
if name == "dpkg-query":
finish(0 if state.get("ce") else 1, "install ok installed" if state.get("ce") else "")
if name == "systemctl":
if args[0] == "show":
unit_exists = state["engine"] and (args[-1] != "docker.socket" or state["socket_unit"])
finish(output="loaded" if unit_exists else "not-found")
if args[0] == "is-active":
finish(output="active" if state["running"] else "inactive")
if state.get("service_fail"):
finish(1, "simulated service failure")
state["running"] = True
finish()
if name == "usermod":
state["member"] = True
finish()
if name == "mock-socket-access":
finish(0 if state["running"] and state["uid"] != 0 and not state["access"] else 1)
if name == "docker":
if args[0] == "info":
finish(0 if state["running"] and (state["uid"] == 0 or state["access"]) else 1)
if args[:2] == ["context", "inspect"]:
finish(output=state["endpoint"])
if args[:2] == ["compose", "version"]:
finish(0 if state["compose"] else 1)
if args[:2] == ["buildx", "version"]:
finish(0 if state["buildx"] else 1, "github.com/docker/buildx v" + state["buildx"])
finish(1)
"""
class DockerBootstrapTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory(prefix="planet-docker-test-")
self.addCleanup(self.temporary.cleanup)
self.folder = Path(self.temporary.name)
self.bin = self.folder / "bin"
self.bin.mkdir()
self.state_file = self.folder / "state.json"
self.state = {
"calls": [],
"uid": 1000,
"member": False,
"access": False,
"engine": False,
"running": False,
"compose": False,
"buildx": "",
"endpoint": "unix:///var/run/docker.sock",
"socket_unit": True,
}
stub = self.bin / "mock-command"
stub.write_text(f"#!{sys.executable}\n" + COMMAND_STUB)
stub.chmod(0o755)
for name in ("id", "sudo", "apt-get", "systemctl", "dpkg-query", "mock-socket-access"):
(self.bin / name).symlink_to("mock-command")
for name in ("zsh", "env", "sort", "head", "tail", "readlink", "awk", "sed"):
executable = shutil.which(name)
self.assertIsNotNone(executable, f"test prerequisite missing: {name}")
(self.bin / name).symlink_to(executable)
def existing_engine(self, *, running: bool = True, access: bool = True) -> None:
for name in ("docker", "dockerd"):
(self.bin / name).symlink_to("mock-command")
self.state.update(
engine=True, running=running, access=access, compose=True, buildx="0.20.0"
)
def run_bootstrap(
self,
action: str = "ensure_docker_runtime",
*,
extra_env: dict[str, str] | None = None,
arguments: list[str] | None = None,
) -> subprocess.CompletedProcess[str]:
self.state_file.write_text(json.dumps(self.state))
env = os.environ.copy()
for name in ("DOCKER_HOST", "DOCKER_CONTEXT", "PLANET_DOCKER_GROUP_REEXEC"):
env.pop(name, None)
env.update(
PATH=str(self.bin),
DOCKER_TEST_STATE=str(self.state_file),
TEST_OS="ubuntu",
TEST_DESKTOP="0",
PLANET_STATE_DIR=str(self.folder),
)
env.update(extra_env or {})
arguments = arguments or ["init", "--non-motion-agent"]
prelude = f"""
set -e
SCRIPT_DIR={shlex.quote(str(self.folder / "repo with ' quotes"))}
source {shlex.quote(str(MODULE))}
log_note() {{ print -r -- "$*"; }}
log_error() {{ print -r -- "$*"; }}
log_step() {{ print -r -- "$*"; }}
docker_bootstrap_os_id() {{ print -r -- "$TEST_OS"; }}
docker_desktop_present() {{ [[ "$TEST_DESKTOP" == 1 ]]; }}
docker_socket_needs_group_access() {{ mock-socket-access; }}
run_command_quiet_unless_verbose() {{ local output="$1"; shift; "$@" > "$output" 2>&1; }}
ensure_system_command() {{ command -v "$1" >/dev/null || docker_as_root apt-get install -y "$2"; }}
{action} {shlex.join(arguments)}
"""
result = subprocess.run(
[str(self.bin / "zsh"), "-f", "-c", prelude],
env=env,
text=True,
capture_output=True,
timeout=20,
)
self.state = json.loads(self.state_file.read_text())
return result
def calls(self, name: str) -> list[list[str]]:
return [args for command, args in self.state["calls"] if command == name]
def test_working_environment_is_reused_without_privilege_or_installation(self) -> None:
self.existing_engine()
result = self.run_bootstrap()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.calls("apt-get"), [])
self.assertEqual(self.calls("sudo"), [])
self.assertEqual(self.calls("systemctl"), [])
def test_fresh_root_install_starts_and_validates_engine(self) -> None:
self.state["uid"] = 0
result = self.run_bootstrap()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(
self.calls("apt-get"),
[
["update", "--error-on=any"],
["install", "-y", "docker.io", "docker-compose-v2", "docker-buildx"],
],
)
self.assertIn(["enable", "--now", "docker.service"], self.calls("systemctl"))
self.assertTrue(self.state["running"])
def test_fresh_user_installs_missing_usermod_and_reexecutes_without_sg(self) -> None:
args = ["--verbose", "init", "--non-motion-agent", "a 'quoted' $(argument)"]
result = self.run_bootstrap(arguments=args)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(["install", "-y", "passwd"], self.calls("apt-get"))
self.assertEqual(self.calls("usermod"), [["-aG", "docker", "planet-test"]])
reexec = self.calls("sudo")[-1]
self.assertEqual(reexec[:7], ["-E", "-u", "planet-test", "-g", "docker", "--", "env"])
self.assertEqual(reexec[7], "PLANET_DOCKER_GROUP_REEXEC=1")
self.assertEqual(reexec[8], f"PATH={self.bin}")
self.assertEqual(reexec[10], str(self.folder / "repo with ' quotes/planet.sh"))
self.assertEqual(reexec[11:], args)
self.assertFalse((self.bin / "sg").exists())
def test_existing_group_membership_refreshes_start_without_installing(self) -> None:
self.existing_engine(access=False)
self.state["member"] = True
result = self.run_bootstrap("refresh_docker_group", arguments=["start", "--allow-lan"])
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.calls("sudo")[-1][-2:], ["start", "--allow-lan"])
self.assertEqual(self.calls("apt-get"), [])
def test_failed_group_refresh_does_not_loop(self) -> None:
self.existing_engine(access=False)
self.state["member"] = True
result = self.run_bootstrap(
"refresh_docker_group", extra_env={"PLANET_DOCKER_GROUP_REEXEC": "1"}
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.calls("sudo"), [])
def test_stopped_existing_engine_is_started_without_installation(self) -> None:
self.existing_engine(running=False)
result = self.run_bootstrap()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.calls("apt-get"), [])
self.assertTrue(self.state["running"])
def test_missing_or_outdated_plugins_are_installed_without_replacing_engine(self) -> None:
self.existing_engine()
self.state.update(compose=False, buildx="0.16.0")
result = self.run_bootstrap()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(
self.calls("apt-get")[-1], ["install", "-y", "docker-compose-v2", "docker-buildx"]
)
def test_existing_docker_ce_keeps_its_package_family(self) -> None:
self.existing_engine()
self.state.update(compose=False, buildx="", ce=True)
result = self.run_bootstrap()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(
self.calls("apt-get")[-1],
["install", "-y", "docker-compose-plugin", "docker-buildx-plugin"],
)
def test_install_failure_stops_before_daemon_start(self) -> None:
self.state["apt_fail"] = True
result = self.run_bootstrap()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.calls("systemctl"), [])
self.assertIn("simulated apt failure", result.stdout)
def test_missing_sudo_reports_requirement_without_attempting_install(self) -> None:
(self.bin / "sudo").unlink()
result = self.run_bootstrap()
self.assertNotEqual(result.returncode, 0)
self.assertIn("缺少 sudo", result.stdout)
self.assertEqual(self.calls("apt-get"), [])
def test_desktop_and_nonlocal_contexts_are_not_replaced(self) -> None:
result = self.run_bootstrap(extra_env={"TEST_DESKTOP": "1"})
self.assertNotEqual(result.returncode, 0)
self.assertIn("Docker Desktop", result.stdout)
self.assertEqual(self.calls("apt-get"), [])
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.calls("apt-get"), [])
def test_working_remote_environment_is_reused(self) -> None:
self.existing_engine()
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.calls("sudo"), [])
def test_unsupported_os_has_an_explicit_error(self) -> None:
result = self.run_bootstrap(extra_env={"TEST_OS": "debian"})
self.assertNotEqual(result.returncode, 0)
self.assertIn("自动安装目前支持 Ubuntu", result.stdout)
self.assertEqual(self.calls("apt-get"), [])
def test_missing_cli_diagnostic_never_suggests_a_socket_unit(self) -> None:
result = self.run_bootstrap("log_docker_daemon_diagnostics")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("未找到 Docker CLI", result.stdout)
self.assertNotIn("systemctl", result.stdout)
self.assertEqual(self.calls("systemctl"), [])
def test_missing_service_diagnostic_never_suggests_a_socket_unit(self) -> None:
(self.bin / "docker").symlink_to("mock-command")
result = self.run_bootstrap("log_docker_daemon_diagnostics")
self.assertIn("未检测到可用的 docker.service", result.stdout)
self.assertNotIn("enable --now docker.socket", result.stdout)
def test_service_without_socket_unit_gets_service_only_hint(self) -> None:
self.existing_engine(running=False)
self.state["socket_unit"] = False
result = self.run_bootstrap("log_docker_daemon_diagnostics")
self.assertIn("enable --now docker.service", result.stdout)
self.assertNotIn("enable --now docker.socket", result.stdout)
def test_daemon_start_failure_is_not_reported_as_database_failure(self) -> None:
self.existing_engine(running=False)
self.state["service_fail"] = True
result = self.run_bootstrap()
self.assertNotEqual(result.returncode, 0)
self.assertIn("Docker Engine 启动失败", result.stdout)
self.assertNotIn("数据库启动失败", result.stdout)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env zsh
# Docker prerequisites for planet.sh; lifecycle and logging remain in planet.sh.
DOCKER_MIN_BUILDX_VERSION="0.17.0"
compose_available() {
docker compose version >/dev/null 2>&1
}
compose_v1_available() {
command -v docker-compose >/dev/null 2>&1
}
buildx_version() {
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
}
buildx_meets_minimum() {
local current_version="$1"
local required_version="$2"
[ -n "$current_version" ] || return 1
[ -n "$required_version" ] || return 1
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
return 0
fi
return 1
}
docker_daemon_available() {
command -v docker >/dev/null 2>&1 && command docker info >/dev/null 2>&1
}
docker_uses_local_engine() {
local endpoint="${DOCKER_HOST:-}"
if [ -n "${DOCKER_CONTEXT:-}" ] || [ -z "$endpoint" ]; then
if command -v docker >/dev/null 2>&1; then
endpoint="$(command docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null)" || return 1
elif [ -n "${DOCKER_CONTEXT:-}" ]; then
return 1
else
endpoint="unix:///var/run/docker.sock"
fi
fi
case "$endpoint" in
unix:///var/run/docker.sock|unix:///run/docker.sock) return 0 ;;
*) return 1 ;;
esac
}
docker_desktop_present() {
local docker_path=""
[ -d /mnt/wsl/docker-desktop ] && return 0
docker_path="$(command -v docker 2>/dev/null || true)"
[ -n "$docker_path" ] || return 1
docker_path="$(readlink -f "$docker_path" 2>/dev/null || true)"
[[ "$docker_path" == */docker-desktop/* ]]
}
docker_systemd_unit_exists() {
command -v systemctl >/dev/null 2>&1 || return 1
[ "$(systemctl show --property=LoadState --value "$1" 2>/dev/null)" = "loaded" ]
}
docker_socket_needs_group_access() {
[ -S /var/run/docker.sock ] && { [ ! -r /var/run/docker.sock ] || [ ! -w /var/run/docker.sock ]; }
}
docker_require_sudo() {
[ "$(id -u)" -eq 0 ] && return 0
if ! command -v sudo >/dev/null 2>&1; then
log_error "缺少 sudo无法安装或配置 Docker请先由管理员安装 sudo 并授予当前用户权限。"
return 1
fi
# Authenticate in the foreground, before command output is redirected to a log.
sudo -v
}
docker_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo -- "$@"
fi
}
refresh_docker_group() {
[ "$(id -u)" -ne 0 ] || return 0
command -v docker >/dev/null 2>&1 || return 0
docker_uses_local_engine || return 0
docker_socket_needs_group_access || return 0
local username="$(id -un)"
local groups="$(id -nG "$username")"
[[ " $groups " == *" docker "* ]] || return 0
if [ "${PLANET_DOCKER_GROUP_REEXEC:-0}" = "1" ]; then
log_error "刷新组权限后仍无法访问 Docker socket请检查 socket 的属组和权限。"
return 1
fi
docker_require_sudo || return 1
log_note "刷新 Docker 组权限,以当前用户继续执行。"
# Preserve WSL interop/tool paths even when sudo applies secure_path.
# argv stays an array: repository paths and arguments are never evaluated as shell text.
exec sudo -E -u "$username" -g docker -- env PLANET_DOCKER_GROUP_REEXEC=1 "PATH=$PATH" \
"$(command -v zsh)" "$SCRIPT_DIR/planet.sh" "$@"
}
docker_bootstrap_os_id() {
[ -r /etc/os-release ] || return 1
(. /etc/os-release; printf '%s\n' "$ID")
}
install_docker_prerequisites() {
local log_file="$PLANET_STATE_DIR/docker_install.log"
local engine_package="docker.io"
local compose_package="docker-compose-v2"
local buildx_package="docker-buildx"
local -a packages=()
if [ "$(docker_bootstrap_os_id)" != "ubuntu" ] || ! command -v apt-get >/dev/null 2>&1; then
log_error "Docker 自动安装目前支持 Ubuntu / Ubuntu WSL请先安装本系统的 Docker Engine、Compose v2 和 Buildx。"
return 1
fi
if command -v dpkg-query >/dev/null 2>&1 &&
[ "$(dpkg-query -W -f='${Status}' docker-ce-cli 2>/dev/null)" = "install ok installed" ]; then
engine_package="docker-ce"
compose_package="docker-compose-plugin"
buildx_package="docker-buildx-plugin"
fi
if ! command -v docker >/dev/null 2>&1 ||
{ ! docker_daemon_available && ! command -v dockerd >/dev/null 2>&1; }; then
packages+=("$engine_package")
fi
compose_available || packages+=("$compose_package")
buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION" || packages+=("$buildx_package")
[ "${#packages[@]}" -gt 0 ] || return 0
docker_require_sudo || return 1
log_step "安装 Docker 依赖: ${packages[*]}"
if ! run_command_quiet_unless_verbose "$log_file" docker_as_root apt-get update --error-on=any ||
! run_command_quiet_unless_verbose "$log_file" docker_as_root apt-get install -y "${packages[@]}"; then
log_error "Docker 依赖安装失败,请检查软件源、网络和 apt 错误。"
tail -20 "$log_file" 2>/dev/null || true
return 1
fi
rehash
}
start_local_docker_engine() {
local log_file="$PLANET_STATE_DIR/docker_service.log"
if ! docker_systemd_unit_exists docker.service; then
log_error "没有可用的 docker.service请确认 Docker Engine 已安装且 systemd 正常运行。"
log_note "Ubuntu WSL 可在 /etc/wsl.conf 的 [boot] 下启用 systemd=true再重启 WSL。"
return 1
fi
docker_require_sudo || return 1
if ! run_command_quiet_unless_verbose "$log_file" docker_as_root systemctl enable --now docker.service; then
log_error "Docker Engine 启动失败。"
tail -20 "$log_file" 2>/dev/null || true
log_note "查看原因: journalctl -u docker.service -n 50 --no-pager"
return 1
fi
}
ensure_docker_user_access() {
[ "$(id -u)" -ne 0 ] || return 0
docker_socket_needs_group_access || return 0
docker_require_sudo || return 1
ensure_system_command usermod passwd "usermod" || return 1
log_note "将当前用户加入 docker 组(该组可管理本机 Docker。"
docker_as_root usermod -aG docker "$(id -un)" || return 1
refresh_docker_group "$@"
}
ensure_docker_runtime() {
if docker_daemon_available && compose_available &&
buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION"; then
return 0
fi
if docker_desktop_present; then
log_error "检测到 Docker Desktop但当前 WSL 的 Docker / Compose / Buildx 尚不可用。"
log_note "请启动 Docker Desktop并为当前发行版启用 WSL Integration。"
return 1
fi
if ! docker_uses_local_engine; then
log_error "当前 Docker 使用其他 context 或远程/rootless endpoint请先检查该环境的连接和插件。"
return 1
fi
install_docker_prerequisites || return 1
if ! command -v docker >/dev/null 2>&1 || ! compose_available ||
! buildx_meets_minimum "$(buildx_version)" "$DOCKER_MIN_BUILDX_VERSION"; then
log_error "安装后 Docker CLI、Compose v2 或 Buildx >= ${DOCKER_MIN_BUILDX_VERSION} 仍不可用。"
return 1
fi
if ! docker_daemon_available; then
start_local_docker_engine || return 1
ensure_docker_user_access "$@" || return 1
fi
if ! docker_daemon_available; then
log_error "Docker 已安装,但当前用户仍无法连接 daemon。"
log_docker_daemon_diagnostics
return 1
fi
}
log_docker_daemon_diagnostics() {
if ! command -v docker >/dev/null 2>&1; then
log_note "未找到 Docker CLI尚未安装 DockerUbuntu / WSL 请先执行 ./planet.sh init。"
return 0
fi
if docker_desktop_present; then
log_note "请启动 Docker Desktop并确认当前 WSL 已启用 WSL Integration。"
return 0
fi
if ! docker_uses_local_engine; then
log_note "当前使用其他 Docker endpoint请检查 docker context ls 和 docker info。"
return 0
fi
if docker_socket_needs_group_access; then
log_note "Docker socket 存在,但当前用户没有读写权限;请执行 ./planet.sh init 配置 docker 组。"
return 0
fi
if ! docker_systemd_unit_exists docker.service; then
log_note "未检测到可用的 docker.serviceUbuntu / WSL 请执行 ./planet.sh init 检查安装。"
return 0
fi
if [ "$(systemctl is-active docker.service 2>/dev/null)" = "active" ]; then
log_note "Docker 服务已启动,但 CLI 无法连接;请检查 docker context ls 和 docker info。"
return 0
fi
if docker_systemd_unit_exists docker.socket; then
log_note "可先执行: sudo systemctl enable --now docker.socket && sudo systemctl restart docker.service"
else
log_note "可先执行: sudo systemctl enable --now docker.service"
fi
log_note "如仍失败,查看: journalctl -u docker.service -n 50 --no-pager"
}

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.74.0"
version = "0.74.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },