diff --git a/skills/ack/README.md b/skills/ack/README.md index 79214f2..a861e5d 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -51,8 +51,8 @@ docs/ack/ 目录读取。 ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在 -`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录仍由 -`--worktree` 指定,并且必须命中绝对路径白名单 `allowedWorktrees`。 +`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由 +`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。 ## Skill 结构 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index a5a68b1..2edee92 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -58,8 +58,9 @@ description: >- 6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时, 删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。 项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`; - worker 的绝对路径只保存在 `project.orchestration.allowedWorktrees` 并在派发时通过 - `--worktree` 显式选择。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。 + worker 默认在 `--project-root`(权威状态目录)工作,不再配置 + `allowedWorktrees` 白名单(v0.19 起废弃);需要隔离 worktree 时由 Coordinator 在 + 派发时显式指定。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。 7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留 `verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中 猜测并激活知识。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 9ff9da9..2389bc2 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -13,8 +13,7 @@ project: orchestration: profileVersion: 1 mode: "manual" - allowedWorktrees: - - "/home/dev/notes-web-wt/fix-preview" + # allowedWorktrees 已废弃(v0.19 起);worker 默认在 --project-root 工作。 modelAllowlist: codex: developer: diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index f42d4d8..e1162d4 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -23,10 +23,10 @@ - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 allowlist,默认 profile 与角色/档位一致。 -- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。 +- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在 + `--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。 - [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前 - `--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 与 - `allowedWorktrees` 共同约束。 + `--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 显式指定。 ## 路径权限 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 742cecc..d237b50 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -141,9 +141,9 @@ python3 /scripts/launch_worker.py launch \ **方式 2:新建隔离 worktree,再在其中起子 agent** -创建成功后,先把 Orca 返回的绝对路径加入权威任务板的 -`project.orchestration.allowedWorktrees` 并重新运行 `validate_tasks.py`;未进入 -allowlist 的路径不能交给 launcher。 +创建成功后,确认新 worktree 与权威项目根属于同一 Git 仓库(launcher 按 +`git worktree list` 注册表 + 同 common-dir 约束校验,v0.19 起不再需要 +`allowedWorktrees` 白名单登记)。 ```bash orca worktree create --name --base-branch --json diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index ab2e507..cbcb230 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -89,9 +89,9 @@ docs/ack/ 不再参与路径绑定。 - 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层 `deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。 -- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worker worktree;模型 - allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、 - `extraArgs`、`env` 或任意 executable 写进任务板。 +- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在 + `--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。 + 不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。 - 非服务项目的 `baseUrl` 写为 `n/a`。 - 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index 2b8179d..1aad7ed 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -141,8 +141,8 @@ receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行 新 worktree 时可先运行 `orca worktree create --name --base-branch --json`,再对返回的绝对 worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher -前,先把新 -路径加入 `project.orchestration.allowedWorktrees` 并重新运行任务板校验。profile +前,确认新 worktree 与权威项目根同一 Git 仓库即可(v0.19 起不再需要登记 +`allowedWorktrees`)。profile 只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现, 任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。 选型与升级见 `model-routing.md`。 diff --git a/skills/ack/references/model-routing.md b/skills/ack/references/model-routing.md index 98a7c8e..8f5298f 100644 --- a/skills/ack/references/model-routing.md +++ b/skills/ack/references/model-routing.md @@ -79,9 +79,10 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的 - `reasoningEffort`:CLI 支持时显式声明; - `permissionMode`:只允许 `read-only` 或 `workspace-write`。 -`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的 -`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree -必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。 +`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 +`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。 +launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须 +命中对应 CLI/角色/档位的精确列表。 模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目 覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式 @@ -135,8 +136,9 @@ python3 /scripts/launch_worker.py launch \ ``` `--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次 -worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中 -`project.orchestration.allowedWorktrees`。 +worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git +仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验, +不再依赖 `allowedWorktrees` 白名单)。 项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或 `devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。 diff --git a/skills/ack/references/orca-adapter.md b/skills/ack/references/orca-adapter.md index c082674..dcf2b78 100644 --- a/skills/ack/references/orca-adapter.md +++ b/skills/ack/references/orca-adapter.md @@ -108,9 +108,9 @@ profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 f launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见 `model-routing.md`。 -需要隔离/并行时,可以先用 Orca 创建 worktree。Coordinator 必须先把返回的绝对 -路径加入 `project.orchestration.allowedWorktrees` 并重新校验 `tasks.yaml`,然后才在 -该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。 +需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。 +v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected +fingerprint 的 `launch`,并把 receipt 留作审计。 既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。 --- diff --git a/skills/ack/scripts/launch_worker.py b/skills/ack/scripts/launch_worker.py index a7a26ba..28b406b 100755 --- a/skills/ack/scripts/launch_worker.py +++ b/skills/ack/scripts/launch_worker.py @@ -557,20 +557,13 @@ def assert_git_control_entry(worktree: Path) -> None: def capture_worktree_identity( project_root: Path, worktree_value: str, - allowed_worktrees: object, ) -> dict: - if not isinstance(allowed_worktrees, list): - raise LaunchError("project.orchestration.allowedWorktrees 必须是列表") + # allowedWorktrees 白名单已废弃(v0.19 起)。worktree 合法性由以下硬约束保证: + # 1) 必须是 Git worktree 根目录(不是任意子目录); + # 2) 必须出现在 `git worktree list` 注册表中; + # 3) 必须与 --project-root 属于同一 Git common-dir(同仓库)。 + # worker 默认就在 --project-root(权威状态目录)工作,天然满足这些约束。 worktree = canonical_directory(worktree_value, "worker worktree") - configured_paths: list[Path] = [] - for index, configured in enumerate(allowed_worktrees): - if not isinstance(configured, str): - raise LaunchError(f"allowedWorktrees[{index}] 必须是字符串") - configured_paths.append( - canonical_directory(configured, f"allowedWorktrees[{index}]") - ) - if worktree not in configured_paths: - raise LaunchError(f"worker worktree 不在 allowedWorktrees 中: {worktree}") git = resolve_executable("git") assert_git_control_entry(project_root) @@ -699,7 +692,6 @@ def build_plan( worktree = capture_worktree_identity( project_root, worktree_value, - orchestration.get("allowedWorktrees"), ) executable = resolve_executable(str(profile["cli"])) executable_stat = executable.stat() diff --git a/skills/ack/scripts/worker_profiles.py b/skills/ack/scripts/worker_profiles.py index 7c044a4..1bc1856 100644 --- a/skills/ack/scripts/worker_profiles.py +++ b/skills/ack/scripts/worker_profiles.py @@ -46,6 +46,9 @@ def executable_basename_matches_cli(executable: str, cli: str) -> bool: return True return cli == "grok" and GROK_EXECUTABLE_NAME_RE.fullmatch(name) is not None +# allowedWorktrees 已废弃(v0.19 起):新任务板不再配置,旧任务板遗留该字段仍被容忍。 +# ORCHESTRATION_FIELDS 是“允许出现”的字段全集(含废弃字段), +# REQUIRED_ORCHESTRATION_FIELDS 是“必须存在”的子集(不含废弃字段)。 ORCHESTRATION_FIELDS = frozenset( { "profileVersion", @@ -56,6 +59,15 @@ ORCHESTRATION_FIELDS = frozenset( "defaults", } ) +REQUIRED_ORCHESTRATION_FIELDS = frozenset( + { + "profileVersion", + "mode", + "modelAllowlist", + "profiles", + "defaults", + } +) PROFILE_FIELDS = frozenset( { "role", @@ -341,11 +353,10 @@ def validate_orchestration( return [f"{where}: must be an object"] errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where) - errors.extend(_missing_fields(orchestration, ORCHESTRATION_FIELDS, where)) + errors.extend(_missing_fields(orchestration, REQUIRED_ORCHESTRATION_FIELDS, where)) profile_version = orchestration.get("profileVersion") mode = orchestration.get("mode") - allowed_worktrees = orchestration.get("allowedWorktrees") allowlist = orchestration.get("modelAllowlist") profiles = orchestration.get("profiles") defaults = orchestration.get("defaults") @@ -355,20 +366,9 @@ def validate_orchestration( if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES: errors.append(f"{where}.mode: must be orca/manual") - if not isinstance(allowed_worktrees, list): - errors.append(f"{where}.allowedWorktrees: must be a list") - else: - if mode == "orca" and not allowed_worktrees: - errors.append(f"{where}.allowedWorktrees: Orca mode requires at least one path") - seen_worktrees: set[str] = set() - for index, worktree in enumerate(allowed_worktrees): - item_where = f"{where}.allowedWorktrees[{index}]" - if not _is_absolute_safe_path(worktree): - errors.append(f"{item_where}: must be a safe absolute path other than root") - elif worktree in seen_worktrees: - errors.append(f"{item_where}: duplicate worktree {worktree!r}") - else: - seen_worktrees.add(worktree) + # allowedWorktrees 已废弃:worker 默认在 --project-root 工作, + # 其它 worktree 由 launcher 按“同 git 仓库且已注册”约束(capture_worktree_identity)。 + # 旧任务板中遗留的该字段被容忍,不再校验。 errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist")) @@ -757,11 +757,6 @@ def validate_worker_receipt( profile = candidate else: errors.append(f"{where}.profileId: unknown profile {profile_id!r}") - allowed = orchestration.get("allowedWorktrees") - if isinstance(worktree, dict) and isinstance(allowed, list): - if worktree.get("path") not in allowed: - errors.append(f"{where}.worktree.path: is not in allowedWorktrees") - if profile is not None: try: expected_profile_hash = profile_hash( diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index cbb4351..7d5a80d 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -4,12 +4,23 @@ "title": "ACK task board", "description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。", "type": "object", - "required": ["version", "project", "tasks"], + "required": [ + "version", + "project", + "tasks" + ], "additionalProperties": true, "properties": { - "version": { "type": "integer", "minimum": 1 }, - "updatedAt": { "type": "string" }, - "source": { "type": "string" }, + "version": { + "type": "integer", + "minimum": 1 + }, + "updatedAt": { + "type": "string" + }, + "source": { + "type": "string" + }, "ackVersion": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", @@ -21,16 +32,24 @@ }, "project": { "type": "object", - "required": ["name"], + "required": [ + "name" + ], "additionalProperties": true, "properties": { - "name": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "name": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, "repoPath": { "type": "string", "deprecated": true, "description": "Legacy informational field; ACK derives project files from --project-root/docs/ack" }, - "baseUrl": { "type": "string" }, + "baseUrl": { + "type": "string" + }, "devWorktree": { "type": "string", "deprecated": true, @@ -50,7 +69,9 @@ "const": "docs/ack/delivery.yaml", "description": "可选项目交付契约的唯一权威路径" }, - "bugIntake": { "$ref": "#/definitions/feishuBugIntake" }, + "bugIntake": { + "$ref": "#/definitions/feishuBugIntake" + }, "orchestration": { "$ref": "#/definitions/orchestration" } @@ -60,24 +81,52 @@ "type": "object", "additionalProperties": true, "properties": { - "verified": { "type": "array", "items": { "type": "string" } }, - "open": { "type": "array", "items": { "type": "string" } }, - "failedRetest": { "type": "array", "items": { "type": "string" } }, - "leftovers": { "type": "array", "items": { "type": "string" } } + "verified": { + "type": "array", + "items": { + "type": "string" + } + }, + "open": { + "type": "array", + "items": { + "type": "string" + } + }, + "failedRetest": { + "type": "array", + "items": { + "type": "string" + } + }, + "leftovers": { + "type": "array", + "items": { + "type": "string" + } + } } }, - "statusReference": { "type": "object" }, + "statusReference": { + "type": "object" + }, "workerReceipts": { "type": "array", - "items": { "$ref": "#/definitions/workerReceipt" } + "items": { + "$ref": "#/definitions/workerReceipt" + } }, "deliveryRuns": { "type": "array", - "items": { "$ref": "#/definitions/deliveryRun" } + "items": { + "$ref": "#/definitions/deliveryRun" + } }, "tasks": { "type": "array", - "items": { "$ref": "#/definitions/task" } + "items": { + "$ref": "#/definitions/task" + } } }, "allOf": [ @@ -89,16 +138,24 @@ "pattern": "^(?:0\\.[1-9][0-9]+\\.[0-9]+|[1-9][0-9]*\\.[0-9]+\\.[0-9]+)(?:-|\\+|$)" } }, - "required": ["ackVersion"] + "required": [ + "ackVersion" + ] }, "then": { - "required": ["workerReceipts"], + "required": [ + "workerReceipts" + ], "properties": { "workerReceipts": {}, - "tasks": { "$ref": "#/definitions/launchableTasks" }, + "tasks": { + "$ref": "#/definitions/launchableTasks" + }, "project": { "type": "object", - "required": ["orchestration"], + "required": [ + "orchestration" + ], "properties": { "orchestration": {} } @@ -111,35 +168,49 @@ "properties": { "project": { "type": "object", - "required": ["orchestration"], + "required": [ + "orchestration" + ], "properties": { "orchestration": {} } } }, - "required": ["project"] + "required": [ + "project" + ] }, "then": { - "required": ["workerReceipts"], + "required": [ + "workerReceipts" + ], "properties": { "workerReceipts": {}, - "tasks": { "$ref": "#/definitions/launchableTasks" } + "tasks": { + "$ref": "#/definitions/launchableTasks" + } } } }, { "if": { - "required": ["workerReceipts"], + "required": [ + "workerReceipts" + ], "properties": { "workerReceipts": {} } }, "then": { "properties": { - "tasks": { "$ref": "#/definitions/launchableTasks" }, + "tasks": { + "$ref": "#/definitions/launchableTasks" + }, "project": { "type": "object", - "required": ["orchestration"], + "required": [ + "orchestration" + ], "properties": { "orchestration": {} } @@ -152,16 +223,22 @@ "properties": { "project": { "type": "object", - "required": ["deliveryFile"], + "required": [ + "deliveryFile" + ], "properties": { "deliveryFile": {} } } }, - "required": ["project"] + "required": [ + "project" + ] }, "then": { - "required": ["deliveryRuns"], + "required": [ + "deliveryRuns" + ], "properties": { "deliveryRuns": {} } @@ -175,37 +252,121 @@ }, "feishuBugIntake": { "type": "object", - "required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"], + "required": [ + "provider", + "profile", + "baseToken", + "tableId", + "viewId", + "fields" + ], "additionalProperties": false, "properties": { - "provider": { "const": "feishu-base" }, + "provider": { + "const": "feishu-base" + }, "workflow": { "type": "string", - "enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"] + "enum": [ + "read-only-v1", + "reviewed-writeback-v1", + "clarified-writeback-v1" + ] + }, + "profile": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "baseToken": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "tableId": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "viewId": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" }, - "profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, - "baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "fields": { "type": "object", "required": [], "additionalProperties": false, "properties": { - "title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "fixLogic": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, - "updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } - ,"details": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } - ,"problemStatement": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } - ,"expectedOutcome": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } - ,"intakeStatus": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } - ,"ackTaskId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } + "title": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "actual": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "expected": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "stepsToReproduce": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "fixLogic": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "acceptance": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "priority": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "attachments": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "updatedAt": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "details": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "problemStatement": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "expectedOutcome": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "intakeStatus": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + "ackTaskId": { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + } } } }, @@ -213,35 +374,80 @@ { "if": { "not": { - "properties": { "workflow": { "const": "clarified-writeback-v1" } }, - "required": ["workflow"] + "properties": { + "workflow": { + "const": "clarified-writeback-v1" + } + }, + "required": [ + "workflow" + ] } }, "then": { "properties": { - "fields": { "required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt"] } + "fields": { + "required": [ + "title", + "actual", + "expected", + "stepsToReproduce", + "acceptance", + "attachments", + "updatedAt" + ] + } } } }, { "if": { - "properties": { "workflow": { "const": "reviewed-writeback-v1" } }, - "required": ["workflow"] + "properties": { + "workflow": { + "const": "reviewed-writeback-v1" + } + }, + "required": [ + "workflow" + ] }, "then": { "properties": { - "fields": { "required": ["fixLogic", "priority"] } + "fields": { + "required": [ + "fixLogic", + "priority" + ] + } } } - } - ,{ + }, + { "if": { - "properties": { "workflow": { "const": "clarified-writeback-v1" } }, - "required": ["workflow"] + "properties": { + "workflow": { + "const": "clarified-writeback-v1" + } + }, + "required": [ + "workflow" + ] }, "then": { "properties": { - "fields": { "required": ["title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt"] } + "fields": { + "required": [ + "title", + "details", + "problemStatement", + "expectedOutcome", + "acceptance", + "intakeStatus", + "ackTaskId", + "attachments", + "updatedAt" + ] + } } } } @@ -249,13 +455,24 @@ }, "feishuTaskSource": { "type": "object", - "required": ["kind", "ref", "recordId", "updatedAt"], + "required": [ + "kind", + "ref", + "recordId", + "updatedAt" + ], "additionalProperties": false, "properties": { - "kind": { "const": "feishu-base" }, + "kind": { + "const": "feishu-base" + }, "workflow": { "type": "string", - "enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"] + "enum": [ + "read-only-v1", + "reviewed-writeback-v1", + "clarified-writeback-v1" + ] }, "ref": { "type": "string", @@ -265,27 +482,53 @@ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$" }, - "updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "approvedRevision": { "$ref": "#/definitions/sha256" }, - "approvedPayloadHash": { "$ref": "#/definitions/sha256" } + "updatedAt": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "approvedRevision": { + "$ref": "#/definitions/sha256" + }, + "approvedPayloadHash": { + "$ref": "#/definitions/sha256" + } }, "allOf": [ { "if": { - "properties": { "workflow": { "const": "reviewed-writeback-v1" } }, - "required": ["workflow"] + "properties": { + "workflow": { + "const": "reviewed-writeback-v1" + } + }, + "required": [ + "workflow" + ] }, "then": { - "required": ["approvedRevision", "approvedPayloadHash"] + "required": [ + "approvedRevision", + "approvedPayloadHash" + ] } - } - ,{ + }, + { "if": { - "properties": { "workflow": { "const": "clarified-writeback-v1" } }, - "required": ["workflow"] + "properties": { + "workflow": { + "const": "clarified-writeback-v1" + } + }, + "required": [ + "workflow" + ] }, "then": { - "required": ["approvedRevision", "approvedPayloadHash"] + "required": [ + "approvedRevision", + "approvedPayloadHash" + ] } } ] @@ -320,8 +563,12 @@ "minProperties": 1, "additionalProperties": false, "properties": { - "standard": { "$ref": "#/definitions/modelList" }, - "strong": { "$ref": "#/definitions/modelList" } + "standard": { + "$ref": "#/definitions/modelList" + }, + "strong": { + "$ref": "#/definitions/modelList" + } } }, "modelRoleAllowlist": { @@ -329,17 +576,27 @@ "minProperties": 1, "additionalProperties": false, "properties": { - "developer": { "$ref": "#/definitions/modelTierAllowlist" }, - "test": { "$ref": "#/definitions/modelTierAllowlist" } + "developer": { + "$ref": "#/definitions/modelTierAllowlist" + }, + "test": { + "$ref": "#/definitions/modelTierAllowlist" + } } }, "modelAllowlist": { "type": "object", "additionalProperties": false, "properties": { - "codex": { "$ref": "#/definitions/modelRoleAllowlist" }, - "cursor-agent": { "$ref": "#/definitions/modelRoleAllowlist" }, - "grok": { "$ref": "#/definitions/modelRoleAllowlist" } + "codex": { + "$ref": "#/definitions/modelRoleAllowlist" + }, + "cursor-agent": { + "$ref": "#/definitions/modelRoleAllowlist" + }, + "grok": { + "$ref": "#/definitions/modelRoleAllowlist" + } } }, "workerProfile": { @@ -356,27 +613,50 @@ "properties": { "role": { "type": "string", - "enum": ["developer", "test"] + "enum": [ + "developer", + "test" + ] }, "cli": { "type": "string", - "enum": ["codex", "cursor-agent", "grok", "omp"] + "enum": [ + "codex", + "cursor-agent", + "grok", + "omp" + ] }, "tier": { "type": "string", - "enum": ["standard", "strong"] + "enum": [ + "standard", + "strong" + ] }, "model": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" }, "reasoningEffort": { - "type": ["string", "null"], - "enum": ["low", "medium", "high", "xhigh", null] + "type": [ + "string", + "null" + ], + "enum": [ + "low", + "medium", + "high", + "xhigh", + null + ] }, "permissionMode": { "type": "string", - "enum": ["read-only", "workspace-write"] + "enum": [ + "read-only", + "workspace-write" + ] } } }, @@ -384,9 +664,15 @@ "type": "object", "additionalProperties": false, "properties": { - "developer": { "$ref": "#/definitions/profileId" }, - "test": { "$ref": "#/definitions/profileId" }, - "developerUpgraded": { "$ref": "#/definitions/profileId" } + "developer": { + "$ref": "#/definitions/profileId" + }, + "test": { + "$ref": "#/definitions/profileId" + }, + "developerUpgraded": { + "$ref": "#/definitions/profileId" + } } }, "orchestration": { @@ -394,7 +680,6 @@ "required": [ "profileVersion", "mode", - "allowedWorktrees", "modelAllowlist", "profiles", "defaults" @@ -407,7 +692,10 @@ }, "mode": { "type": "string", - "enum": ["orca", "manual"] + "enum": [ + "orca", + "manual" + ] }, "allowedWorktrees": { "type": "array", @@ -416,7 +704,8 @@ "type": "string", "minLength": 1, "pattern": "^/" - } + }, + "description": "已废弃(v0.19 起)。为兼容旧任务板保留,新任务板不应配置;launcher 不再校验。" }, "modelAllowlist": { "$ref": "#/definitions/modelAllowlist" @@ -438,18 +727,30 @@ { "if": { "properties": { - "mode": { "const": "orca" } + "mode": { + "const": "orca" + } }, - "required": ["mode"] + "required": [ + "mode" + ] }, "then": { "properties": { - "allowedWorktrees": { "type": "array", "minItems": 1 }, - "modelAllowlist": { "type": "object", "minProperties": 1 }, - "profiles": { "type": "object", "minProperties": 1 }, + "modelAllowlist": { + "type": "object", + "minProperties": 1 + }, + "profiles": { + "type": "object", + "minProperties": 1 + }, "defaults": { "type": "object", - "required": ["developer", "test"], + "required": [ + "developer", + "test" + ], "properties": { "developer": {}, "test": {} @@ -518,23 +819,43 @@ "properties": { "cli": { "type": "string", - "enum": ["codex", "cursor-agent", "grok", "omp"] + "enum": [ + "codex", + "cursor-agent", + "grok", + "omp" + ] }, "tier": { "type": "string", - "enum": ["standard", "strong"] + "enum": [ + "standard", + "strong" + ] }, "model": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$" }, "reasoningEffort": { - "type": ["string", "null"], - "enum": ["low", "medium", "high", "xhigh", null] + "type": [ + "string", + "null" + ], + "enum": [ + "low", + "medium", + "high", + "xhigh", + null + ] }, "permissionMode": { "type": "string", - "enum": ["read-only", "workspace-write"] + "enum": [ + "read-only", + "workspace-write" + ] }, "executable": { "type": "string", @@ -667,7 +988,11 @@ }, "createdFor": { "type": "object", - "required": ["taskId", "attemptId", "role"], + "required": [ + "taskId", + "attemptId", + "role" + ], "additionalProperties": false, "properties": { "taskId": { @@ -680,7 +1005,10 @@ }, "role": { "type": "string", - "enum": ["developer", "test"] + "enum": [ + "developer", + "test" + ] } } }, @@ -704,45 +1032,76 @@ }, "roleDispatch": { "type": "object", - "required": ["profileId", "receiptId", "attemptId", "taskId", "dispatchId"], + "required": [ + "profileId", + "receiptId", + "attemptId", + "taskId", + "dispatchId" + ], "additionalProperties": false, "properties": { "profileId": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^[a-z][a-z0-9-]{1,63}$" }, "receiptId": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^WR-[0-9a-f]{64}$" }, "attemptId": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$" }, "taskId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "dispatchId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "allOf": [ { "if": { "properties": { - "receiptId": { "type": "null" } + "receiptId": { + "type": "null" + } }, - "required": ["receiptId"] + "required": [ + "receiptId" + ] }, "then": { "properties": { - "attemptId": { "type": "null" } + "attemptId": { + "type": "null" + } } }, "else": { "properties": { - "profileId": { "type": "string" }, - "attemptId": { "type": "string" } + "profileId": { + "type": "string" + }, + "attemptId": { + "type": "string" + } } } } @@ -763,17 +1122,32 @@ }, "round": { "type": "object", - "required": ["round", "result"], + "required": [ + "round", + "result" + ], "additionalProperties": true, "properties": { - "round": { "type": "integer", "minimum": 1, "maximum": 3 }, + "round": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, "attemptId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-3]$", "description": "稳定逻辑轮次 ID,应为 -A;旧轮次可缺省,但作为知识来源前必须补齐" }, - "result": { "type": "string", "enum": ["passed", "failed"] }, - "evidence": { "type": "string" } + "result": { + "type": "string", + "enum": [ + "passed", + "failed" + ] + }, + "evidence": { + "type": "string" + } } }, "environmentIncident": { @@ -796,10 +1170,19 @@ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-ENV-[1-9][0-9]*$" }, - "attemptId": { "type": ["string", "null"] }, + "attemptId": { + "type": [ + "string", + "null" + ] + }, "role": { "type": "string", - "enum": ["coordinator", "developer", "test"] + "enum": [ + "coordinator", + "developer", + "test" + ] }, "phase": { "type": "string", @@ -816,15 +1199,47 @@ }, "status": { "type": "string", - "enum": ["open", "resolved"] + "enum": [ + "open", + "resolved" + ] }, - "summary": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "evidence": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "impact": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "recoveryAction": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "userAction": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "reportedAt": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "resolvedAt": { "type": ["string", "null"] } + "summary": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "evidence": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "impact": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "recoveryAction": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "userAction": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "reportedAt": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + } } }, "knowledgeRef": { @@ -847,111 +1262,213 @@ "properties": { "kind": { "type": "string", - "enum": ["guardrail", "pitfall", "verification"] + "enum": [ + "guardrail", + "pitfall", + "verification" + ] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "claim": { + "type": "string", + "minLength": 1 }, - "title": { "type": "string", "minLength": 1 }, - "claim": { "type": "string", "minLength": 1 }, "scope": { "type": "object", "minProperties": 1, "additionalProperties": false, "properties": { - "all": { "type": "boolean" }, + "all": { + "type": "boolean" + }, "components": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "paths": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "dependencies": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "versions": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "tags": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "symbols": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, "errorSignatures": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true } } }, - "appliesWhen": { "type": "string", "minLength": 1 }, - "directive": { "type": "string", "minLength": 1 }, - "rationale": { "type": "string", "minLength": 1 }, + "appliesWhen": { + "type": "string", + "minLength": 1 + }, + "directive": { + "type": "string", + "minLength": 1 + }, + "rationale": { + "type": "string", + "minLength": 1 + }, "evidenceRefs": { "type": "array", "minItems": 1, - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "uniqueItems": true }, - "proposedBy": { "type": "string" }, - "proposedAt": { "type": "string" } + "proposedBy": { + "type": "string" + }, + "proposedAt": { + "type": "string" + } } }, "knowledgeApplication": { "type": "object", - "required": ["ref", "result", "evidence"], + "required": [ + "ref", + "result", + "evidence" + ], "additionalProperties": false, "properties": { - "ref": { "$ref": "#/definitions/knowledgeRef" }, + "ref": { + "$ref": "#/definitions/knowledgeRef" + }, "result": { "type": "string", - "enum": ["applied", "not_applicable"] + "enum": [ + "applied", + "not_applicable" + ] }, - "evidence": { "type": "string", "minLength": 1 } + "evidence": { + "type": "string", + "minLength": 1 + } } }, "knowledgeCheck": { "type": "object", - "required": ["ref", "result", "evidence"], + "required": [ + "ref", + "result", + "evidence" + ], "additionalProperties": false, "properties": { - "ref": { "$ref": "#/definitions/knowledgeRef" }, + "ref": { + "$ref": "#/definitions/knowledgeRef" + }, "result": { "type": "string", - "enum": ["passed", "failed", "not_applicable"] + "enum": [ + "passed", + "failed", + "not_applicable" + ] }, - "evidence": { "type": "string", "minLength": 1 }, - "checkedBy": { "type": "string" }, - "checkedAt": { "type": "string" } + "evidence": { + "type": "string", + "minLength": 1 + }, + "checkedBy": { + "type": "string" + }, + "checkedAt": { + "type": "string" + } } }, "deliveryArtifactEvidence": { "type": "object", - "required": ["id", "type", "reference"], + "required": [ + "id", + "type", + "reference" + ], "additionalProperties": false, "properties": { - "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, - "type": { "type": "string", "enum": ["deb", "oci-image", "file"] }, - "reference": { "type": "string", "minLength": 1 }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "type": { + "type": "string", + "enum": [ + "deb", + "oci-image", + "file" + ] + }, + "reference": { + "type": "string", + "minLength": 1 + }, "digest": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^sha256:[0-9a-f]{64}$" } } }, "deliveryDeploymentEvidence": { "type": "object", - "required": ["environment", "result", "evidence"], + "required": [ + "environment", + "result", + "evidence" + ], "additionalProperties": false, "properties": { "environment": { @@ -960,9 +1477,16 @@ }, "result": { "type": "string", - "enum": ["succeeded", "failed", "rolled_back"] + "enum": [ + "succeeded", + "failed", + "rolled_back" + ] }, - "evidence": { "type": "string", "minLength": 1 } + "evidence": { + "type": "string", + "minLength": 1 + } } }, "deliveryRun": { @@ -992,14 +1516,20 @@ }, "intent": { "type": "string", - "enum": ["testEnvironment", "release"], + "enum": [ + "testEnvironment", + "release" + ], "description": "用户触发的测试环境或发版操作;有 intent 时 taskIds 可为 []" }, "taskIds": { "type": "array", "minItems": 0, "uniqueItems": true, - "items": { "type": "string", "minLength": 1 } + "items": { + "type": "string", + "minLength": 1 + } }, "status": { "type": "string", @@ -1015,27 +1545,48 @@ ] }, "sourceRevision": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^[0-9a-f]{7,64}$" }, "configRevision": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^[0-9a-f]{7,64}$" }, - "pullRequest": { "type": ["string", "null"] }, + "pullRequest": { + "type": [ + "string", + "null" + ] + }, "artifacts": { "type": "array", - "items": { "$ref": "#/definitions/deliveryArtifactEvidence" } + "items": { + "$ref": "#/definitions/deliveryArtifactEvidence" + } }, "deployments": { "type": "array", - "items": { "$ref": "#/definitions/deliveryDeploymentEvidence" } + "items": { + "$ref": "#/definitions/deliveryDeploymentEvidence" + } }, "evidence": { "type": "array", - "items": { "type": "string", "minLength": 1 } + "items": { + "type": "string", + "minLength": 1 + } }, - "updatedAt": { "type": "string", "minLength": 1 } + "updatedAt": { + "type": "string", + "minLength": 1 + } }, "allOf": [ { @@ -1053,12 +1604,18 @@ ] } }, - "required": ["status"] + "required": [ + "status" + ] }, "then": { "properties": { - "sourceRevision": { "type": "string" }, - "configRevision": { "type": "string" } + "sourceRevision": { + "type": "string" + }, + "configRevision": { + "type": "string" + } } } }, @@ -1076,27 +1633,47 @@ ] } }, - "required": ["status"] + "required": [ + "status" + ] }, "then": { "properties": { - "evidence": { "minItems": 1 } + "evidence": { + "minItems": 1 + } } } }, { "if": { "properties": { - "status": { "enum": ["review_ready", "released"] } + "status": { + "enum": [ + "review_ready", + "released" + ] + } }, - "required": ["status"] + "required": [ + "status" + ] }, "then": { "properties": { - "sourceRevision": { "type": "string" }, - "configRevision": { "type": "string" }, - "pullRequest": { "type": "string", "minLength": 1 }, - "evidence": { "minItems": 1 } + "sourceRevision": { + "type": "string" + }, + "configRevision": { + "type": "string" + }, + "pullRequest": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "minItems": 1 + } } } } @@ -1104,73 +1681,165 @@ }, "task": { "type": "object", - "required": ["id", "title", "status"], + "required": [ + "id", + "title", + "status" + ], "additionalProperties": true, "properties": { - "id": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "type": { "type": "string" }, - "title": { "type": "string", "minLength": 1, "pattern": "\\S" }, - "priority": { "type": "string" }, - "status": { "$ref": "#/definitions/status" }, - "assignee": { "type": "string" }, - "component": { "type": "string" }, - "specRefs": { "type": "array", "items": { "type": "string" } }, - "testRefs": { "type": "array", "items": { "type": "string" } }, + "id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "priority": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/status" + }, + "assignee": { + "type": "string" + }, + "component": { + "type": "string" + }, + "specRefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "testRefs": { + "type": "array", + "items": { + "type": "string" + } + }, "knowledgeRefs": { "type": "array", - "items": { "$ref": "#/definitions/knowledgeRef" }, + "items": { + "$ref": "#/definitions/knowledgeRef" + }, "uniqueItems": true }, "knowledgeApplied": { "type": "array", - "items": { "$ref": "#/definitions/knowledgeApplication" } + "items": { + "$ref": "#/definitions/knowledgeApplication" + } }, "knowledgeCandidates": { "type": "array", - "items": { "$ref": "#/definitions/knowledgeCandidate" } + "items": { + "$ref": "#/definitions/knowledgeCandidate" + } }, "knowledgeChecks": { "type": "array", - "items": { "$ref": "#/definitions/knowledgeCheck" } + "items": { + "$ref": "#/definitions/knowledgeCheck" + } }, "source": { "if": { "type": "object", - "required": ["kind"], - "properties": { "kind": { "const": "feishu-base" } } + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "feishu-base" + } + } }, "then": { "$ref": "#/definitions/feishuTaskSource" } }, - "description": { "type": "string" }, - "fixLogic": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "description": { + "type": "string" + }, + "fixLogic": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, "acceptanceCriteria": { "type": "array", "minItems": 1, - "items": { "type": "string", "minLength": 1, "pattern": "\\S" } + "items": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "stepsToReproduce": { + "type": "array", + "items": { + "type": "string" + } + }, + "expected": { + "type": "string" + }, + "actual": { + "type": "string" + }, + "evidence": { + "type": "object" + }, + "verification": { + "type": "object" }, - "stepsToReproduce": { "type": "array", "items": { "type": "string" } }, - "expected": { "type": "string" }, - "actual": { "type": "string" }, - "evidence": { "type": "object" }, - "verification": { "type": "object" }, "dispatch": { "type": "object", "additionalProperties": false, "properties": { - "taskId": { "type": ["string", "null"] }, - "dispatchId": { "type": ["string", "null"] }, - "worker": { "type": ["string", "null"] }, - "developer": { "$ref": "#/definitions/roleDispatch" }, - "test": { "$ref": "#/definitions/roleDispatch" }, + "taskId": { + "type": [ + "string", + "null" + ] + }, + "dispatchId": { + "type": [ + "string", + "null" + ] + }, + "worker": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "$ref": "#/definitions/roleDispatch" + }, + "test": { + "$ref": "#/definitions/roleDispatch" + }, "rounds": { "type": "array", - "items": { "$ref": "#/definitions/round" } + "items": { + "$ref": "#/definitions/round" + } }, "environmentIncidents": { "type": "array", - "items": { "$ref": "#/definitions/environmentIncident" } + "items": { + "$ref": "#/definitions/environmentIncident" + } } } }, @@ -1178,17 +1847,45 @@ "type": "object", "additionalProperties": true, "properties": { - "fixedBy": { "type": ["string", "null"] }, - "verifiedBy": { "type": ["string", "null"] }, - "verifiedAt": { "type": ["string", "null"] }, - "leftoverReason": { "type": ["string", "null"] }, - "evidence": { "type": "object" } + "fixedBy": { + "type": [ + "string", + "null" + ] + }, + "verifiedBy": { + "type": [ + "string", + "null" + ] + }, + "verifiedAt": { + "type": [ + "string", + "null" + ] + }, + "leftoverReason": { + "type": [ + "string", + "null" + ] + }, + "evidence": { + "type": "object" + } } } }, "allOf": [ { - "if": { "properties": { "status": { "const": "leftover" } } }, + "if": { + "properties": { + "status": { + "const": "leftover" + } + } + }, "then": { "properties": { "resolution": { @@ -1200,10 +1897,14 @@ "pattern": "\\S" } }, - "required": ["leftoverReason"] + "required": [ + "leftoverReason" + ] } }, - "required": ["resolution"] + "required": [ + "resolution" + ] } } ] diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 13da651..647f7e7 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -28,10 +28,10 @@ project: # attachments: "" # updatedAt: "" orchestration: + # worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单 + # (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。 profileVersion: 1 mode: "orca" - allowedWorktrees: - - "" modelAllowlist: codex: developer: diff --git a/skills/deployer/README.md b/skills/deployer/README.md new file mode 100644 index 0000000..71c4595 --- /dev/null +++ b/skills/deployer/README.md @@ -0,0 +1,62 @@ +# deployer + +把「一堆 VPS / NAS 上的 Docker 服务」变成一个 Git 仓库就能管的事:仓库里只放服务配置(数据), +部署、同步、升级的方法和脚本全部由这个 skill 自带,换台电脑、换个项目都能直接用。 + +支持两种用法: + +- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有服务(如 app00) +- **项目内环境**:在普通项目里放 `.skiff/deployer/{prod,test,dev}/`, + 把这个项目的生产/测试/开发环境也用同一套流程部署 + +## 什么时候使用 + +- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器 +- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志 +- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务 +- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套 +- 需要一张「哪台机器跑哪些服务」的清单 + +## 使用前准备 + +- 本机装有 Python 3、`rsync`、`ssh` +- 目标机器装好 Docker + Docker Compose v2 +- `~/.ssh/config` 里为每台机器配好 Host 别名,且能免密(或 agent)登录 +- 知道每个服务的运行时数据放在哪(这些目录不能被同步覆盖) + +## 使用示例 + +```text +# 独立配置中心 +帮我把 vyyo1/naiveproxy 的配置改完部署上去 +升级 vora3/gpt-load 的镜像版本 +列一下现在所有服务和各自在哪台机器上 +新增一个服务 uptime 到 vora3,先帮我建好目录结构 +vhom1 上那个 naiveproxy 为什么 sync 失败? + +# 项目内环境 +给这个项目建好 .skiff/deployer,prod 和 test 分别放到两台机器上 +把 test 环境重新部署一下 +prod 的 compose 加个 redis,改完发上去 +``` + +## Agent 会做什么 + +1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径; + 项目内布局从 `.skiff/deployer/` 自动发现,无需额外配置 +2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`) +3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启) +4. 同步后查看容器状态和日志确认生效 +5. 只针对你指定的那一个服务操作,不会批量动整台机器 + +项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`), +避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。 + +重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据 +必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。 + +## 如何判断完成 + +- 脚本输出显示同步完成、远程命令执行成功 +- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致 +- 域名/端口类服务能 curl 通 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md new file mode 100644 index 0000000..f7293c9 --- /dev/null +++ b/skills/deployer/SKILL.md @@ -0,0 +1,200 @@ +--- +name: deployer +description: >- + 管理多 VPS / NAS 的 Docker Compose 配置中心:仓库只存服务数据(compose.yaml、静态配置), + 部署方法与脚本由本 skill 提供。当用户要求部署、同步、升级、重启远程 Docker 服务, + 新增/迁移/下线服务,梳理节点与服务清单,或提到 make sync/deploy/upgrade/TGT、_config.yaml、 + rsync 同步、tar over SSH、Synology NAS 部署失败时使用。 +--- + +# deployer:多机 Compose 配置中心 + +仓库 = 数据(各机器的 `compose.yaml` 与静态配置);方法 = 本 skill 的脚本与规范。 +本地改配置 → skill 脚本同步到对应 SSH 节点 → 远程 `docker compose` 应用。 + +--- + +## 何时使用 + +- 部署 / 同步 / 升级 / 重启某个远程 Docker Compose 服务 +- 新增、迁移、下线一个服务;梳理「哪台机器跑什么」 +- sync 失败排查、证书丢失、改了配置不生效等运维问题 +- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步 + +## 不适用 + +- 单机 docker 日常使用(无多机同步诉求) +- K8s / Nomad 等编排系统 +- CI/CD 流水线构建发布(本流程是 push 式运维,不是流水线) + +--- + +## 核心模型(先读懂再动手) + +- **仓库只放数据**:`compose.yaml`、Caddyfile、Traefik 动态配置等静态配置进 Git; + 运行时数据(证书、数据库、上传文件)永不进 Git,也永不参与同步范围。 +- **每个可部署服务目录必须有 `compose.yaml`**,且能解析出目标节点 `node` + (来自该目录、部署根或祖先目录的 `_config.yaml`,或父目录名恰为 SSH Host 别名)。 +- **`node` 即 SSH Host 别名**(`~/.ssh/config`),支持 `user@host` 形式。 +- `unused/` 下不参与自动发现与部署。 + +### 两种布局 + +**A. 独立配置中心仓库**(如 app00):仓库根即部署根, +`DEPLOYER_ROOT=/path/to/repo` 指定后按仓库内相对路径操作: + +``` +repo/ +├── _config.yaml # 可选,全局默认 +├── vyyo1/_config.yaml # node: vyyo1(主机目录) +│ └── naiveproxy/ # 服务目录:compose.yaml + 可选 _config.yaml +└── unused/ +``` + +远程目录名 = 目录末级名:`vyyo1/naiveproxy` → `/opt/app/naiveproxy`。 + +**B. 项目内环境布局**:项目根放 `.skiff/deployer/{prod,test,dev}/`, +每个环境一个目录。从项目内任意位置运行脚本即自动发现(也可用 `DEPLOYER_ROOT` +显式指定),无需环境变量: + +``` +my-project/ +├── src/ ... # 项目本体 +└── .skiff/deployer/ + ├── _config.yaml # 三个环境共享默认(node/base_path 等) + ├── prod/ + │ ├── compose.yaml # 生产 compose 与配置 + │ └── _config.yaml # 环境级覆盖 + ├── test/compose.yaml + └── dev/compose.yaml +``` + +项目模式下远程目录名自动加项目前缀 `{git仓库名}-{env}` +(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖; +`_config.yaml` 写 `name:` 可显式指定。 + +## 步骤 + +### 0. 定位部署根 + +skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/list), +不依赖具体项目路径。部署根按以下顺序解析: + +1. 环境变量 `DEPLOYER_ROOT` 显式指定(独立配置中心仓库用这个) +2. 从当前目录向上找 `.skiff/deployer/`(项目内环境布局自动发现) +3. skill 安装位置兜底(仅用于查看,没有可部署服务) + +```bash +# = 本 SKILL.md 所在目录,先解析出来记下 +# 布局 A:显式指定仓库根 +export DEPLOYER_ROOT=/path/to/your/compose-repo +python3 /scripts/deploy/list.py + +# 布局 B:在项目内直接跑即可(cwd 在项目里) +python3 /scripts/deploy/list.py +``` + +### 1. 摸底:列出服务与节点 + +上一步的 `list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。 +项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。 + +### 2. 解析单个服务 + +```bash +# 查看 node、远程路径、排除规则(sync.py 干跑会打印这些信息) +python3 /scripts/deploy/sync.py +``` + +或直接读服务目录及祖先的 `_config.yaml`。 + +### 3. 命令选择(语义严格区分) + +| 意图 | 命令 | +|------|------| +| 只同步文件,不动容器 | `sync.py ` | +| 应用 compose/配置变更 | `sync.py && remote.py up` | +| 改配置后强制重建 | `remote.py recreate`(配合前置 sync) | +| 镜像 tag 变更升级 | `sync.py && remote.py upgrade` | +| 仅重启,不同步文件 | `remote.py restart` | +| 排查 | `remote.py ps` / `remote.py logs` | + +### 4. 项目侧 Makefile(可选薄封装) + +若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。 +没有 Makefile 时直接调 python 脚本即可,不要新建封装层。 + +### 5. 新增服务 / 环境 checklist + +独立仓库布局: + +1. 在合适分类目录创建服务文件夹,写 `compose.yaml` +2. 在服务目录或祖先目录放 `_config.yaml`(至少能解析出 `node`) +3. 有运行时目录 → 加进 `sync_exclude` +4. 远程首次建目录:`ssh "mkdir -p /"` +5. 首次部署:sync + up +6. 验证:ps + logs,必要时 curl/ssh 检查端口 + +项目环境布局: + +1. 项目根建 `.skiff/deployer/{env}/`(env 通常为 prod/test/dev) +2. 每个环境写 `compose.yaml`;三个环境共享的 node/base_path 放 + `.skiff/deployer/_config.yaml` +3. 环境有差异(不同主机、不同排除项)→ 在该环境的 `_config.yaml` 覆盖 +4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:` +5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉) + +### 6. 下线服务 + +独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理: +`ssh "cd / && docker compose down"`,数据卷按需保留或删除。 +项目环境布局:删除对应 `.skiff/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。 + +--- + +## 注意事项 + +- **禁止节点级批量操作**:所有 sync/up/recreate/upgrade/restart 必须按单服务执行。 + 批量升级风险过高,逐个来。 +- **rsync 带 `--delete`**:远程多余文件会被删除。运行时数据必须放在 + 默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径 + (如 `/data01/docker//`),否则会被清掉。 +- **镜像固定 tag**,不用 `:latest` 漂移;成对升级的服务(如 proxy 客户端/服务端)要同步升。 +- **密钥**:优先放远程 `.env` 或环境变量,不要提交新密钥进 Git。 +- **Git 安全**:不 `--force` 推送、不硬 reset,除非用户明确要求。 +- **NAS / Synology 特例**:部分 NAS 的 SSH 用户禁用 rsync 协议(Permission denied)。 + 表现是 sync 报错但 ssh 正常。处理顺序: + 1. 该节点 `_config.yaml` 写真实 `base_path`(如 `/volume1/docker`,避开符号链接路径) + 2. 仍失败则手动 tar over SSH 推送: + + ```bash + tar czf - -C <服务目录> . --exclude='data' --exclude='_data' \ + | ssh "mkdir -p / && cd / && tar xzf -" + ssh "cd / && /usr/local/bin/docker compose up -d" + ``` + + tar 不会删除远程多余文件;需清理旧文件时手动 SSH 删除。 + 3. Synology 上 docker 路径可能是 `/usr/local/bin/docker` + +## 验证 + +- `list.py` 输出全部服务与节点分布,数量与预期一致 +- 每次 sync/deploy 后 `remote.py ps` 容器 Up、`logs` 无报错 +- 升级后额外确认镜像 tag 与 compose.yaml 一致 +- 改 Traefik/Caddy 路由后 curl 对应域名验证生效 + +## scripts/ + + +| 文件 | 用途 | +|------|------| +| `scripts/deploy/lib.py` | 解析服务目录、合并继承 `_config.yaml`、SSH/rsync 参数构造 | +| `scripts/deploy/sync.py` | rsync -avz --delete 同步;无 rsync 时 tar over SSH 兜底 | +| `scripts/deploy/remote.py` | SSH 远程 docker compose:up/recreate/restart/upgrade/ps/logs | +| `scripts/deploy/list.py` | 扫描全部可部署服务 | + +## references/ + +| 文件 | 用途 | +|------|------| +| `references/config-reference.md` | `_config.yaml` 字段完整说明与继承合并规则 | diff --git a/skills/deployer/references/config-reference.md b/skills/deployer/references/config-reference.md new file mode 100644 index 0000000..a58f51f --- /dev/null +++ b/skills/deployer/references/config-reference.md @@ -0,0 +1,137 @@ +# `_config.yaml` 配置参考 + +`_config.yaml` 供 skill 部署脚本解析,决定同步目标与排除规则。可放在**服务目录、部署根或其任意祖先目录**;子目录中的字段覆盖父目录(继承合并)。 + +## 放置位置(两种布局) + +| 布局 | 部署根 | `_config.yaml` 典型位置 | +|------|--------|------------------------| +| 独立配置中心仓库(`DEPLOYER_ROOT` 指向) | 仓库根 | 主机目录 `vyyo1/_config.yaml`、服务目录 | +| 项目内环境 `.skiff/deployer/{env}/` | `.skiff/deployer/` | 根级共享默认、各环境目录覆盖 | + +项目布局示例: + +```yaml +# .skiff/deployer/_config.yaml — 三个环境共享 +node: my-vps +base_path: /srv/apps + +# .skiff/deployer/prod/_config.yaml — 仅生产环境差异 +node: prod-vps # 覆盖父级 +name: my-project-api # 可选,覆盖默认的 {项目名}-{env} +``` + +## 字段一览 + +| 字段 | 必填 | 可继承 | 说明 | +|------|------|--------|------| +| `node` | 是 | 是 | SSH 目标主机别名 | +| `port` | 否 | 是 | SSH 端口,默认 22 | +| `identity_file` | 否 | 是 | SSH 私钥路径 | +| `base_path` | 否 | 是 | 远程部署根目录,默认 `/opt/app` | +| `sync_exclude` | 否 | 是 | rsync 排除目录列表 | +| `name` | 否 | 是 | 远程目录名覆盖(见下) | + +## name(可选) + +远程目录名默认取服务/环境文件夹最后一级名称。**项目内环境布局**默认改为 +`{git仓库名}-{env}`(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖。 +写 `name:` 显式指定最终远程目录名(不含 base_path 部分)。 + +## node(必填) + +对应 `~/.ssh/config` 中的 `Host` 别名,支持带用户形式: + +```yaml +node: deploy@prod +``` + +独立仓库布局默认远程目录名 = 服务文件夹最后一级名称:`vyyo1/naiveproxy` → `{base_path}/naiveproxy`; +项目环境布局见上文 name 一节。 +兜底规则:合并后仍无 `node` 时,若**直接父目录名**是 SSH Host 别名则作为 node。 + + +## port(可选) + +```yaml +node: edge +port: 2222 +``` + +sync 通过 `rsync -e "ssh -p PORT"` 连接,remote 使用 `ssh -p PORT`。 +与 `~/.ssh/config` 的 `Port` 同时存在时以 `_config.yaml` 为准(命令行覆盖 config)。 + +## identity_file(可选) + +```yaml +node: edge +identity_file: ~/.ssh/deploy_ed25519 +``` + +须为绝对路径或 `~` 开头;私钥不进 Git。带 passphrase 的密钥需事先加入 ssh-agent,脚本不支持交互输入。 + +## base_path(可选) + +```yaml +node: nas +base_path: /volume1/docker # 默认路径是符号链接的设备写真实路径 +``` + +实际同步目标 `{base_path}/{服务名}`。 + +## sync_exclude(可选) + +rsync 排除的目录名列表(仅目录名)。默认已排除 `data`、`_data`。 +子级重新定义时**整体替换**父级列表(非追加): + +```yaml +sync_exclude: + - data + - _data + - logs +``` + +运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker//`),避免被 `--delete` 清掉。 + +--- + +## 继承合并规则 + +1. 收集**部署根自身**的 `_config.yaml`(全局默认),再收集部署根到服务目录路径上的所有 `_config.yaml` +2. 按祖先 → 子顺序合并;同名字段后者覆盖前者;`sync_exclude` 整体替换 +3. 无任何配置且父目录名为 SSH Host 别名 → 兜底为 node + +## 配置组织建议 + +| 场景 | 建议 | +|------|------| +| 同一 SSH 主机多个服务 | 主机目录写一份 `node`/`base_path`,子服务免重复 | +| 各服务目标不同 | 服务目录单独写 `_config.yaml` | +| 个别覆盖 | 子目录只写差异字段 | +| 项目三环境同主机 | `.skiff/deployer/_config.yaml` 写共享 node,各环境只放差异 | +| 项目环境分属不同主机 | 各环境 `_config.yaml` 分别写 `node` | + +## 示例 + +```yaml +# hosts/nas/_config.yaml — 该节点下所有服务共享 +node: nas +base_path: /volume1/docker + +# hosts/web1/_config.yaml +node: web1 +port: 2222 +identity_file: ~/.ssh/web1_ed25519 + +# hosts/web2/special/_config.yaml — 覆盖父级的 node +node: deploy@web2-backup +``` + +## 可部署服务判定 + +同时满足才被 `list.py` 发现: + +- 目录内有 `compose.yaml` +- 能通过继承或兜底解析出 `node` +- 独立仓库布局:路径中不含 `unused/` 段 +- 项目环境布局:位于部署根 `.skiff/deployer/` 内(其外的项目文件不扫描) diff --git a/skills/deployer/scripts/deploy/lib.py b/skills/deployer/scripts/deploy/lib.py new file mode 100755 index 0000000..753a799 --- /dev/null +++ b/skills/deployer/scripts/deploy/lib.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""deployer 部署工具公共模块:解析部署根、服务目录与 _config.yaml。 + +支持两种布局: +1. 独立配置中心仓库(DEPLOYER_ROOT 指向,或 skill 安装位置) +2. 项目内环境目录 .skiff/deployer/{prod,test,dev}/(从 CWD 自动发现) +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +DEFAULT_BASE_PATH = "/opt/app" +DEFAULT_SYNC_EXCLUDES = ("data", "_data") +_SKILL_DIR = Path(__file__).resolve().parent.parent # scripts/ +PROJECT_ROOT: Path | None = None + + +def _find_project_root() -> Path: + """部署根:DEPLOYER_ROOT > 从 CWD 向上找 .skiff/deployer > skill 安装位置。""" + env = os.environ.get("DEPLOYER_ROOT", "").strip() + if env: + p = Path(env).expanduser().resolve() + if not p.is_dir(): + print(f"错误: DEPLOYER_ROOT 不是目录: {p}") + sys.exit(1) + return p + cur = Path.cwd() + while True: + cand = cur / ".skiff" / "deployer" + if cand.is_dir(): + return cand + if cur == cur.parent: + break + cur = cur.parent + return _SKILL_DIR.parent + + +def project_root() -> Path: + global PROJECT_ROOT + if PROJECT_ROOT is None: + PROJECT_ROOT = _find_project_root() + return PROJECT_ROOT + + +def in_project_layout(root: Path | None = None) -> bool: + """部署根是否为某项目内的 .skiff/deployer/。""" + root = root or project_root() + return root.name == "deployer" and root.parent.name == ".skiff" + + +def project_display_name(root: Path | None = None) -> str: + """项目名:git 仓库名优先,否则 .skiff 的父目录名。""" + root = root or project_root() + anchor = root.parent.parent if in_project_layout(root) else root + try: + proc = subprocess.run( + ["git", "-C", str(anchor), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return Path(proc.stdout.strip()).name + except OSError: + pass + return anchor.name + + +def load_config(config_path: os.PathLike | str, *, required: bool = True) -> dict: + """解析精简版 _config.yaml(仅支持本项目使用的字段)。""" + config: dict = {} + current_list_key: str | None = None + + try: + with open(config_path, encoding="utf-8") as f: + for raw_line in f: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + if line.startswith("- ") and current_list_key: + config.setdefault(current_list_key, []).append(line[2:].strip()) + continue + + current_list_key = None + if ":" not in line: + continue + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + if not value: + current_list_key = key + config.setdefault(key, []) + continue + + config[key] = value + except FileNotFoundError: + if required: + print(f"错误: 配置文件不存在: {config_path}") + sys.exit(1) + return {} + + return config + + +def config_paths_for_service(service_dir: str) -> list[Path]: + """收集部署根自身及服务目录各层 _config.yaml(祖先在前,服务目录在后)。 + + 部署根的 _config.yaml(如 .skiff/deployer/_config.yaml)作为全局默认, + 对所有环境/服务生效。 + """ + root = project_root() + rel = Path(service_dir) + paths: list[Path] = [] + root_config = root / "_config.yaml" + if root_config.is_file(): + paths.append(root_config) + for depth in range(1, len(rel.parts) + 1): + config_path = root / Path(*rel.parts[:depth]) / "_config.yaml" + if config_path.is_file() and config_path != root_config: + paths.append(config_path) + return paths + + +def merge_service_config(service_dir: str) -> tuple[dict, list[Path]]: + """合并服务目录及其祖先的 _config.yaml,子级覆盖父级。""" + merged: dict = {} + sources = config_paths_for_service(service_dir) + for config_path in sources: + merged.update(load_config(config_path, required=True)) + return merged, sources + + +def ssh_config_hosts() -> set[str]: + """读取 ~/.ssh/config 中的 Host 别名(不含通配符)。""" + global _SSH_HOSTS + if _SSH_HOSTS is not None: + return _SSH_HOSTS + + hosts: set[str] = set() + config_path = Path.home() / ".ssh" / "config" + if config_path.is_file(): + for raw_line in config_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if not line.lower().startswith("host "): + continue + for host in line.split()[1:]: + if "*" in host or "?" in host or "!" in host: + continue + hosts.add(host) + + _SSH_HOSTS = hosts + return hosts + + +_SSH_HOSTS: set[str] | None = None + + +def parse_port(raw: str, *, strict: bool = True) -> int | None: + """解析 SSH 端口,无效时 strict 模式下退出。""" + try: + port = int(raw) + except ValueError: + if strict: + print(f"错误: port 必须是整数: {raw!r}") + sys.exit(1) + return None + if not 1 <= port <= 65535: + if strict: + print(f"错误: port 超出有效范围 1-65535: {port}") + sys.exit(1) + return None + return port + + +def resolve_identity_file(raw: str, *, strict: bool = True) -> str | None: + """解析 SSH 私钥路径(expanduser,须为绝对路径且文件存在)。""" + path = os.path.expanduser(raw) + if not os.path.isabs(path): + if strict: + print(f"错误: identity_file 必须是绝对路径或 ~ 开头: {raw!r}") + sys.exit(1) + return None + if not os.path.isfile(path): + if strict: + print(f"错误: identity_file 不存在: {path}") + sys.exit(1) + return None + return path + + +def ssh_base_args(info: dict) -> list[str]: + """ssh 可执行文件及 -p / -i 等选项(不含 host 与 remote command)。""" + args = ["ssh"] + port = info.get("port") + if port is not None: + args.extend(["-p", str(port)]) + identity_file = info.get("identity_file") + if identity_file: + args.extend(["-i", identity_file]) + return args + + +def ssh_cmd(info: dict, remote_command: str) -> list[str]: + """构建 ssh 命令行(含可选 -p / -i)。""" + return [*ssh_base_args(info), info["node"], remote_command] + + +def rsync_ssh_args(info: dict) -> list[str]: + """rsync 需自定义 ssh 时通过 -e 传入 port / identity_file。""" + base = ssh_base_args(info) + if len(base) == 1: + return [] + return ["-e", shlex.join(base)] + + +def node_from_parent_dir(service_dir: str) -> str | None: + """父目录名若是 SSH Host 别名,则作为 node。""" + parent = Path(service_dir.rstrip("/")).parent.name + if not parent or parent == ".": + return None + if parent in ssh_config_hosts(): + return parent + return None + + +def service_info(service_dir: str, *, strict: bool = True) -> dict | None: + """解析单个服务目录,返回 node、远程路径等信息。""" + root = project_root() + abs_dir = (root / service_dir).resolve() + if not abs_dir.is_dir(): + if strict: + print(f"错误: 服务目录不存在: {service_dir}") + sys.exit(1) + return None + + env_name = abs_dir.name + rel_dir = str(abs_dir.relative_to(root)) + config, sources = merge_service_config(rel_dir) + + # 远程目录名:_config.yaml 的 name 显式覆盖;项目布局默认 {项目名}-{env} + # 防止同主机上多个项目的 prod/test 相互覆盖 + if "name" in config: + name = str(config["name"]) + elif in_project_layout(root): + name = f"{project_display_name(root)}-{env_name}" + if strict: + print(f"提示: 项目模式,远程名自动加前缀: {name!r}(_config.yaml 写 name: 可覆盖)") + else: + name = env_name + + node = config.get("node") + if not node: + node = node_from_parent_dir(rel_dir) + if node and strict and not config: + print(f"提示: 未找到 _config.yaml,使用父目录 SSH 主机 {node!r}") + + if not node: + if strict: + print(f"错误: 无法解析 node: {service_dir}") + print(" 请在服务目录或其祖先目录添加 _config.yaml,或确保父目录是 SSH 主机别名") + sys.exit(1) + return None + + base_path = config.get("base_path", DEFAULT_BASE_PATH) + excludes = config.get("sync_exclude") or list(DEFAULT_SYNC_EXCLUDES) + remote_dir = f"{base_path}/{name}" + port = parse_port(config["port"], strict=strict) if "port" in config else None + identity_file = ( + resolve_identity_file(config["identity_file"], strict=strict) + if "identity_file" in config + else None + ) + + info = { + "service_dir": str(abs_dir), + "name": name, + "node": node, + "port": port, + "identity_file": identity_file, + "base_path": base_path, + "remote_dir": remote_dir, + "sync_exclude": excludes, + "config_sources": [str(p.relative_to(root)) for p in sources], + } + local_config = abs_dir / "_config.yaml" + if strict and sources and not local_config.is_file(): + rel_sources = info["config_sources"] + if len(rel_sources) == 1: + print(f"提示: 使用继承配置 {rel_sources[0]!r}") + else: + print(f"提示: 使用继承配置 {' -> '.join(rel_sources)!r}") + + return info + + +def is_deployable_dir(path: Path, root: Path) -> bool: + if "unused" in path.parts or "__pycache__" in path.parts: + return False + if ".skiff" in path.parts and root.name != "deployer": + return False + if not (path / "compose.yaml").is_file(): + return False + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def discover_services(node: str | None = None) -> list[dict]: + """扫描仓库内所有带 compose.yaml 且能解析 node 的服务。""" + root = project_root() + services: list[dict] = [] + + for compose_path in sorted(root.glob("**/compose.yaml")): + service_dir = compose_path.parent + if not is_deployable_dir(service_dir, root): + continue + rel = str(service_dir.relative_to(root)) + info = service_info(rel, strict=False) + if info is None: + continue + if node is None or info["node"] == node: + services.append(info) + + return services diff --git a/skills/deployer/scripts/deploy/list.py b/skills/deployer/scripts/deploy/list.py new file mode 100755 index 0000000..8f4ed40 --- /dev/null +++ b/skills/deployer/scripts/deploy/list.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""列出仓库内所有可部署服务及其目标节点。""" + +from __future__ import annotations + +import os +import sys +from collections import defaultdict + +from lib import discover_services, project_root + + +def main() -> int: + root = project_root() + os.chdir(root) + services = discover_services() + if not services: + print("未找到任何可部署服务(需 compose.yaml 且能解析 node)") + return 0 + + by_node: dict[str, list[str]] = defaultdict(list) + for info in services: + rel = os.path.relpath(info["service_dir"], root) + by_node[info["node"]].append(rel) + + print(f"共 {len(services)} 个服务,分布在 {len(by_node)} 个节点:\n") + for node in sorted(by_node): + print(f"[{node}]") + for service in by_node[node]: + print(f" - {service}") + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/deployer/scripts/deploy/remote.py b/skills/deployer/scripts/deploy/remote.py new file mode 100755 index 0000000..c9b0ea5 --- /dev/null +++ b/skills/deployer/scripts/deploy/remote.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +在远程节点上执行 docker compose 操作。 + +用法: + python remote.py <服务目录> <命令> + +命令: + up 启动/更新容器(不拉镜像) + recreate 强制重建容器(up -d --force-recreate,改配置后用) + restart 重启 compose 内所有服务 + upgrade 拉取镜像并重建容器 + ps 查看容器状态 + logs 查看最近日志(非 follow) +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + +from lib import project_root, service_info, ssh_cmd + +REMOTE_COMMANDS = { + "up": "docker compose up -d", + "recreate": "docker compose up -d --force-recreate", + "restart": "docker compose restart", + "upgrade": "docker compose pull && docker compose up -d", + "ps": "docker compose ps", + "logs": "docker compose logs --tail=100", +} + + +def run_ssh(info: dict, compose_command: str) -> int: + remote_dir = info["remote_dir"] + remote_shell = f"cd {remote_dir} && {compose_command}" + cmd = ssh_cmd(info, remote_shell) + endpoint = info["node"] + if info.get("port") is not None: + endpoint = f"{endpoint}:{info['port']}" + print(f"远程执行: {endpoint}:{remote_dir}") + print(f"$ {compose_command}") + print("-" * 60) + try: + return subprocess.run(cmd, check=False).returncode + except FileNotFoundError: + print("错误: ssh 命令未找到") + return 1 + + +def run_for_service(service_dir: str, action: str) -> int: + info = service_info(service_dir) + compose_command = REMOTE_COMMANDS[action] + endpoint = info["node"] + if info.get("port") is not None: + endpoint = f"{endpoint}:{info['port']}" + print(f"服务: {service_dir} -> {endpoint}:{info['remote_dir']}") + return run_ssh(info, compose_command) + + + +def main() -> int: + parser = argparse.ArgumentParser(description="在远程节点执行 docker compose 操作") + parser.add_argument("target", help="服务目录,例如 hosts/web1/myapp") + parser.add_argument( + "action", + choices=sorted(REMOTE_COMMANDS), + help="远程操作", + ) + args = parser.parse_args() + + os.chdir(project_root()) + return run_for_service(args.target, args.action) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/deployer/scripts/deploy/sync.py b/skills/deployer/scripts/deploy/sync.py new file mode 100755 index 0000000..7e4577d --- /dev/null +++ b/skills/deployer/scripts/deploy/sync.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +目录同步脚本 +将指定目录同步到远程机器 + +用法: + python sync.py <目录路径> + 例如: python sync.py hosts/web1/myapp +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import shutil +import subprocess +import sys + +from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd + + +def sync_directory(info: dict) -> int: + if shutil.which("rsync"): + return _sync_rsync(info) + print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)") + return _sync_tar(info) + + +def _sync_rsync(info: dict) -> int: + source_dir = info["service_dir"] + node = info["node"] + remote_path = f"{node}:{info['remote_dir']}" + + rsync_cmd = [ + "rsync", + "-avz", + "--delete", + *rsync_ssh_args(info), + ] + for item in info["sync_exclude"]: + rsync_cmd.append(f"--exclude={item}/") + + rsync_cmd.extend([f"{source_dir}/", remote_path]) + + print(f"正在同步 {source_dir} 到 {remote_path}") + if info["sync_exclude"]: + print(f"排除目录: {', '.join(info['sync_exclude'])}") + print(f"执行命令: {' '.join(rsync_cmd)}") + print("-" * 60) + + try: + subprocess.run(rsync_cmd, check=True) + print("-" * 60) + print("同步完成!") + return 0 + except subprocess.CalledProcessError as e: + print(f"错误: rsync 执行失败,退出码: {e.returncode}") + return 1 + except FileNotFoundError: + print("错误: rsync 命令未找到,请确保已安装 rsync") + return 1 + + +def _sync_tar(info: dict) -> int: + source_dir = info["service_dir"] + remote_dir = info["remote_dir"] + tar_cmd = ["tar", "czf", "-", "-C", source_dir] + for item in info["sync_exclude"]: + tar_cmd.append(f"--exclude={item}") + tar_cmd.append(".") + + remote_shell = ( + f"mkdir -p {shlex.quote(remote_dir)} && " + f"tar xzf - -C {shlex.quote(remote_dir)}" + ) + ssh = ssh_cmd(info, remote_shell) + print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}") + if info["sync_exclude"]: + print(f"排除目录: {', '.join(info['sync_exclude'])}") + print(f"执行命令: tar | {' '.join(ssh)}") + print("-" * 60) + + tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE) + try: + completed = subprocess.run(ssh, stdin=tar.stdout, check=False) + finally: + if tar.stdout: + tar.stdout.close() + tar.wait() + if tar.returncode: + print(f"错误: tar 打包失败,退出码: {tar.returncode}") + return tar.returncode + if completed.returncode: + print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}") + return completed.returncode + print("-" * 60) + print("同步完成!") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="将指定目录同步到远程机器", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python sync.py hosts/web1/myapp + python sync.py infra/traefik --base-path /opt/app + """, + ) + + parser.add_argument( + "directory", + help="要同步的目录路径(相对项目根,例如: hosts/web1/myapp)", + ) + + parser.add_argument( + "--base-path", + default=DEFAULT_BASE_PATH, + help=f"远程基础路径(默认: {DEFAULT_BASE_PATH})", + ) + + args = parser.parse_args() + + info = service_info(args.directory) + if args.base_path != DEFAULT_BASE_PATH: + name = info["name"] + info["base_path"] = args.base_path + info["remote_dir"] = f"{args.base_path}/{name}" + + print(f"目标节点: {info['node']}") + if info.get("port") is not None: + print(f"SSH 端口: {info['port']}") + if info.get("identity_file"): + print(f"SSH 密钥: {info['identity_file']}") + print(f"源目录: {args.directory}") + print(f"远程路径: {info['remote_dir']}") + print() + + return sync_directory(info) + + +if __name__ == "__main__": + sys.exit(main())