From 69cfd0b51daac5f5857693f5e6f0d11113c28ed1 Mon Sep 17 00:00:00 2001 From: yuzhe Date: Wed, 22 Jul 2026 19:34:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20=E5=BC=BA=E5=8C=96=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=9B=BE=E7=89=87=E5=AF=BC=E5=85=A5=E4=B8=8E=20Skill?= =?UTF-8?q?=20=E5=BC=82=E5=B8=B8=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/upload-delivery-desk-work/SKILL.md | 16 +++++ .../references/api-contract.md | 19 +++--- .../scripts/delivery_desk_upload.py | 61 ++++++++++++++---- api/routes/storage.ts | 4 +- api/storage.ts | 6 +- docs/architecture.md | 2 +- docs/handoff.md | 2 +- docs/integration-guide.md | 3 +- docs/operator-runbook.md | 4 +- scripts/test-postgres-runtime.ts | 13 ++-- skill-packages/upload-delivery-desk-work.zip | Bin 9695 -> 11228 bytes tests/test_upload_skill.py | 13 ++++ 12 files changed, 112 insertions(+), 31 deletions(-) diff --git a/.agents/skills/upload-delivery-desk-work/SKILL.md b/.agents/skills/upload-delivery-desk-work/SKILL.md index 1c4eb70..3f98a7e 100644 --- a/.agents/skills/upload-delivery-desk-work/SKILL.md +++ b/.agents/skills/upload-delivery-desk-work/SKILL.md @@ -143,6 +143,22 @@ Report group, project, work ID, `externalId`, created round number, title, image If the result is `state_unknown`, do not rerun `apply`. Run `inspect-work` and compare server state to the plan. If the result still cannot be proven, ask the operator before any retry. +## Failure actions + +On failure, the script exits with code `1` and writes one JSON object to stderr: + +```json +{"success": false, "action": "revise", "error": "...", "next_step": "..."} +``` + +Follow `action` exactly: + +- `retry`: Retry only the same read-only command, with the same target and parameters, after completing `next_step`. Never reinterpret this as permission to retry `apply`. +- `revise`: Change only the invalid input identified by `error`, regenerate the plan, show the full changed plan, and obtain a new confirmation code before `apply`. +- `ask_operator`: Stop. Show the exact target, `error`, and `next_step` to the operator or administrator. Do not change targets, permissions, project state, or credentials yourself. + +If output is `state_unknown`, it is always `ask_operator`: inspect current server state first and never automatically repeat the write. + ## API troubleshooting Read [references/api-contract.md](references/api-contract.md) before calling an endpoint directly, interpreting an error, or changing this skill for a new API version. diff --git a/.agents/skills/upload-delivery-desk-work/references/api-contract.md b/.agents/skills/upload-delivery-desk-work/references/api-contract.md index 566963e..c277d0a 100644 --- a/.agents/skills/upload-delivery-desk-work/references/api-contract.md +++ b/.agents/skills/upload-delivery-desk-work/references/api-contract.md @@ -93,13 +93,16 @@ It applies the same COS reuse/import rules as work creation. - Only `active` projects accept new works or rounds. - Closed or archived projects are read-only. - A completed project may require an authorized administrator to reopen the relevant workflow before another round can be created. -- `400`: malformed or incomplete input. -- `401`: missing/invalid authentication. -- `403`: key or account cannot access the target project. -- `404`: target does not exist. -- `409`: target state disallows mutation or a uniqueness conflict occurred. -- `413`: a remote image exceeds 20 MB. -- `422`: a remote image cannot be downloaded or is not a supported image response. -- `502`: Delivery Desk could not store an imported image in Tencent COS. +| Status | Script action | Required next step | +|---|---|---| +| `400` | `revise` | Correct malformed input, regenerate the plan, and obtain a new confirmation. | +| `401` | `ask_operator` | Ask the operator to configure or replace `DELIVERY_DESK_API_KEY`. | +| `403` | `ask_operator` | Report the exact group/project and ask an administrator to correct Key scope. Never select another target. | +| `404` | `ask_operator` | Re-run read-only discovery, then ask the operator if the confirmed target is gone or inaccessible. | +| `409` | `ask_operator` | Report the target state and wait for the operator or administrator to resolve it. | +| `413` | `revise` | Replace or reduce the image, regenerate the plan, and obtain a new confirmation. | +| `422` | `revise` | Replace the unreachable or unsupported public image URL, regenerate the plan, and obtain a new confirmation. | +| `502` | `ask_operator` for writes | Do not retry the write automatically; inspect state and ask the operator to check Tencent COS. | +| `429`, `503`, `504` | `retry` for reads only | Wait and retry the same read-only command without changing the target. Writes require `ask_operator`. | Do not work around `403` or `409` by selecting a different project. Report the exact target and ask the operator or administrator to resolve access/state. diff --git a/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py b/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py index 40625a8..ab515c2 100644 --- a/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py +++ b/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py @@ -17,7 +17,40 @@ from urllib.request import Request, urlopen class UploadError(RuntimeError): - pass + def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None: + super().__init__(message) + self.action = action + self.next_step = next_step + + def payload(self) -> dict[str, Any]: + return {"success": False, "action": self.action, "error": str(self), "next_step": self.next_step} + + +def revise(message: str, next_step: str) -> UploadError: + return UploadError(message, action="revise", next_step=next_step) + + +def retry(message: str, next_step: str) -> UploadError: + return UploadError(message, action="retry", next_step=next_step) + + +def http_failure(method: str, path: str, status: int, detail: str) -> UploadError: + message = f"{method} {path} 返回 {status}: {detail}" + if status in {400, 413, 422}: + return revise(message, "根据错误修改输入或图片 URL,重新生成计划,并取得新的确认码后再执行") + if status == 401: + return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY") + if status == 403: + return UploadError(message, next_step="停止操作,向调用者报告已确认的运营组和项目,请管理员修正 Key 权限;不要改选其他项目") + if status == 404: + return UploadError(message, next_step="重新执行只读发现命令核对目标;若目标已删除或不可见,询问调用者,不要猜测新目标") + if status == 409: + return UploadError(message, next_step="停止操作并报告目标当前状态,请调用者或管理员解除冲突;不要更换目标或自动重试") + if method == "GET" and status in {429, 502, 503, 504}: + return retry(message, "等待片刻后原样重试当前只读命令;不要改变目标或参数") + if method == "POST": + return UploadError(message, next_step="写入结果可能不确定,禁止自动重试;先只读检查目标状态,再询问调用者") + return UploadError(message, next_step="停止操作并向调用者报告服务异常;确认服务恢复后再重新执行只读发现") def api_key() -> str: @@ -48,11 +81,14 @@ def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, A detail = payload.get("error", raw) if isinstance(payload, dict) else raw except json.JSONDecodeError: detail = raw - raise UploadError(f"{method} {path} 返回 {error.code}: {detail}") from error + raise http_failure(method, path, error.code, str(detail)) from error except URLError as error: if method == "POST": - raise UploadError(f"state_unknown: {method} {path} 的结果未知,禁止自动重试;请先检查服务器状态:{error.reason}") from error - raise UploadError(f"无法连接 {base}: {error.reason}") from error + raise UploadError( + f"state_unknown: {method} {path} 的结果未知,禁止自动重试;请先检查服务器状态:{error.reason}", + next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定", + ) from error + raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error def print_json(value: Any) -> None: @@ -88,10 +124,10 @@ def project_identity(project: dict[str, Any]) -> dict[str, Any]: def validate_images(values: list[str]) -> list[str]: if not 1 <= len(values) <= 30: - raise UploadError("必须提供 1-30 个图片 URL") + raise revise("必须提供 1-30 个图片 URL", "调整图片数量和顺序,重新生成计划并取得新的确认码") cleaned = [value.strip() for value in values] if any(not re.match(r"^https?://[^\s]+$", value, re.IGNORECASE) or len(value) > 2048 for value in cleaned): - raise UploadError("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL") + raise revise("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL", "替换为可公开访问的 HTTP/HTTPS 图片 URL,重新生成计划并取得新的确认码") return cleaned @@ -125,14 +161,14 @@ def assert_work_project(work: dict[str, Any], project_id: int) -> None: def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None = None) -> dict[str, Any]: current = current or {} if args.clear_description and args.description is not None: - raise UploadError("--description 与 --clear-description 不能同时使用") + raise revise("--description 与 --clear-description 不能同时使用", "只保留其中一个参数后重新生成计划") if args.clear_tags and args.tags is not None: - raise UploadError("--tag 与 --clear-tags 不能同时使用") + raise revise("--tag 与 --clear-tags 不能同时使用", "只保留其中一种 Tag 操作后重新生成计划") title = args.title if args.title is not None else current.get("title") description = "" if args.clear_description else args.description if args.description is not None else current.get("description", "") tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", []) if not str(title or "").strip(): - raise UploadError("标题不能为空") + raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码") return { "title": str(title).strip(), "description": str(description or ""), @@ -168,7 +204,7 @@ def cmd_plan_work(args: argparse.Namespace) -> None: if project.get("status") != "active": raise UploadError("目标项目不是 active,不能创建作品") if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id): - raise UploadError("externalId 格式无效") + raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划") plan = { "schema_version": 1, "operation": "create_work", @@ -224,7 +260,7 @@ def cmd_apply(args: argparse.Namespace) -> None: plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) expected_code = confirmation_code(plan) if args.confirm != expected_code or plan.get("confirmation_code") != expected_code: - raise UploadError("确认码不匹配;计划可能已改变,禁止执行") + raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码") if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}: raise UploadError("不支持的计划格式") base = base_url(plan.get("base_url")) @@ -330,5 +366,6 @@ if __name__ == "__main__": try: raise SystemExit(main()) except (UploadError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: - print(f"失败: {error}", file=sys.stderr) + failure = error if isinstance(error, UploadError) else UploadError(f"输入或响应格式无效: {error}") + print(json.dumps(failure.payload(), ensure_ascii=False), file=sys.stderr) raise SystemExit(1) diff --git a/api/routes/storage.ts b/api/routes/storage.ts index 64c5281..98ae56e 100644 --- a/api/routes/storage.ts +++ b/api/routes/storage.ts @@ -2,7 +2,7 @@ import { Router, type Response } from 'express'; import { audit, requireRole, type AuthRequest } from '../auth.js'; import { encryptSecret } from '../configCrypto.js'; import { database, withTransaction } from '../database.js'; -import { testStorageConfig, type StorageConfigRecord } from '../storage.js'; +import { assertPublicHttpUrl, testStorageConfig, type StorageConfigRecord } from '../storage.js'; const router=Router(); type PublicRow=Record&{id:number|string;has_credentials:boolean|number}; @@ -10,7 +10,7 @@ async function publicRows(){return(await database.all(`SELECT s.id,s. function validUrl(value:string){if(!value)return true;try{return['http:','https:'].includes(new URL(value).protocol)}catch{return false}} router.get('/',requireRole('platform_admin'),async(_req,res)=>res.json(await publicRows())); -router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))}); +router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}await assertPublicHttpUrl(publicBaseUrl||`https://${bucket}.cos.${region}.myqcloud.com`);if(cdnDomain)await assertPublicHttpUrl(cdnDomain);if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))}); router.post('/:configId/test',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one('SELECT id,region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted FROM storage_configs WHERE id=? AND status!=?',[id,'archived']);if(!config){res.status(404).json({error:'存储配置不存在'});return}try{const message=await testStorageConfig(config);await database.execute("UPDATE storage_configs SET test_status='passed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_passed','storage_config',id,{bucket:config.bucket});res.json({success:true,test_status:'passed',test_message:message})}catch(error){const message=error instanceof Error?error.message:'COS 连接测试失败';await database.execute("UPDATE storage_configs SET test_status='failed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_failed','storage_config',id,{bucket:config.bucket,message});res.status(400).json({error:message,test_status:'failed'})}}); router.post('/:configId/activate',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<{id:number;bucket:string;test_status:string}>('SELECT id,bucket,test_status FROM storage_configs WHERE id=? AND status=?',[id,'draft']);if(!config){res.status(404).json({error:'待启用的存储配置不存在'});return}if(config.test_status!=='passed'){res.status(409).json({error:'连接测试通过后才能启用该配置'});return}await withTransaction(async(tx)=>{await tx.execute("UPDATE storage_configs SET status='archived' WHERE status='active'");await tx.execute("UPDATE storage_configs SET status='active',activated_at=? WHERE id=?",[new Date().toISOString(),id])});await audit(req,'storage_config.activate','storage_config',id,{bucket:config.bucket});res.json({success:true})}); export default router; diff --git a/api/storage.ts b/api/storage.ts index 1e920d6..47a16e1 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -110,6 +110,10 @@ async function assertPublicRemote(url: URL): Promise { } } +export async function assertPublicHttpUrl(value: string): Promise { + await assertPublicRemote(new URL(value)); +} + function assertRemoteUrlShape(url: URL): void { if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { throw new StorageImportError(400, '图片地址必须是公开的 HTTP/HTTPS URL'); @@ -207,7 +211,7 @@ export async function storeExternalImageUrl(value: string): Promise/.delivery-desk-check/` 是否残留临时文件。 -COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。 +COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公网地址,本机、私有网段、局域网或保留地址会在保存配置时被拒绝。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。 JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。 +使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发应使用 `pnpm server:dev` 或 `pnpm dev`。 + ## Agent 上传 Skill 维护 Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。 diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index 4a7a1ee..1dda495 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -80,11 +80,16 @@ try { expectStatus(missingStorage.response.status,409,'没有活动 COS 时拒绝 URL 导入',missingStorage.body); const worksWithoutStorage=await request(`/api/projects/${projectId}/works`,{},adminCookie); if((worksWithoutStorage.body as unknown[]).length!==0)throw new Error('URL 导入失败后仍创建了作品记录'); + const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie); + expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body); await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]); - const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止内网图片',images:['http://127.0.0.1/private.jpg']})},adminCookie); - expectStatus(blockedPrivateImage.response.status,400,'拒绝内网图片转存',blockedPrivateImage.body); - const blockedPrivateIpv6=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止 IPv6 本机图片',images:['http://[::1]/private.jpg']})},adminCookie); - expectStatus(blockedPrivateIpv6.response.status,400,'拒绝 IPv6 本机图片转存',blockedPrivateIpv6.body); + const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg']; + for(const [index,imageUrl] of privateImageUrls.entries()){ + const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie); + expectStatus(blockedPrivateImage.response.status,400,`拒绝私有图片地址 ${imageUrl}`,blockedPrivateImage.body); + } + const worksAfterPrivateImages=await request(`/api/projects/${projectId}/works`,{},adminCookie); + if((worksAfterPrivateImages.body as unknown[]).length!==0)throw new Error('私有图片地址被拒绝后仍创建了作品记录'); const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie); expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body); const otherProjectId=Number((otherProject.body as {id:number}).id); diff --git a/skill-packages/upload-delivery-desk-work.zip b/skill-packages/upload-delivery-desk-work.zip index 4361fbfa82ad856789ff6198fb4b001b996225a1..ceb7303868287f12456128b7f028d16e113c58dc 100644 GIT binary patch delta 10788 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

Q>Vpy(8>|wD;NJz+E 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 Dzv{(q delta 9239 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%0D-{^pb7A;J=X=gU zqk#xoVK{w&fvzm2^W~$&cxUc@9MwP~P*sFB#n6XGc;<6J*{<|FH+8>hXMX+Oc1-<^ zh&10m!@0PUFDrROy`^rWBU#gqCLN8nR9x<--v{`^vw-B^pZ&c(Aew?j-_k-cC~0W0 z03iBzTj&?_RJlvKTJcQk?|OT5^ry~==m7)T|Hc(qbL8QyC)Cami#MK}F5Jkr*`D=% z($fH@DJc&#iv>Dt3QNfgMdviP=}WN%l|8xg;$;G8_}7AeuB&r|ysWNiWk3EvcC0l- zd=#)^9)>sOFD7tsK1XXk9{iGMh-6$&@dnzxU5q4HO**W?Jjqy_u+Vyrc8OJh!TAY6 zOz{M$+kX2_UVHLy2;*z2&><2PIwJz@>le1OCC#F7-!J^U0@*Yj`%8fi%>2ly>*hgW zuaS84)d~xBne-flQfO9aJLbCtr^$=n=Vr=$1WPGLvCw!K;3kC1B4j#CUp(UO1-7l= z3oQbt8RH%dq+dMQ1vLI99X0cj5lJ8}t8`kx99J;ww*DvQlWnYx#xFe5gTP=9X-Uey zD<>}~nu-=?bbNl0m_$RE?_%qiM9=zdOoNS{zQsIoGKbQZNN zJqSPJ9l_r@Y~!=XWh#iQ215{PW+E>E{o5JJA$LK`$#8HGQZZ%Nomis_W;H7E9NIsE0?8k#;x}V>)x-2Ms2v#T-1ITXns&W@K=Xykj6* zIAs_!{aPex5^z7{vYCm^55cG zF3_VsLj!>x`BS>EX#heySHhL29$>Wj3Q3n?(&H?|mLYS#(?J%NLuLvI+tiBLU}H;~ z(cLrcn+A6Zs6J1gNCKXmnx0B<3F}fcE79t~>{j-k_CtKG>-74%wX0)TD%j#=16PIb7;iH+U= z^0^8)9S|=ma_yMu_GtXj)i^mZS(%ofap2)vs%?932g|Fq5Z0An*rgwF`GY%h;(0kS zg#zQN!*g6H8M+CJNk<(+%W zXl_ncK^G}oag0&uN@^J z2;YSZ4E~Z=)(gjzTxb)qnd2eGBK)jGK*xjA4S6T3$*OoE)nPOF;SM$UXtDPL+tTXv z<(*0d_Hz!Gq#&mNDy^%O(;r8hDt=@)+d2W?Jb^Rq*pilo6l{wOQ?EuH5R?4@4l8Md zjhV5nDAc%$Hac`N4nD@R@pB^AY$h%slWwjCj`cvH>JKX(CC3(MdYWeO5&0J0*jp?g zL)E_16Py)`wau}+&vsq3qN3||(5vNI!ionCd!m2bjm#=6oc#?oo%(jN3Ck_k>l{ulFcis8oKPkiB$sT=0gYFb+`VecSidy-oWgoC&bV|hG{h~#bPrtr zIWS1I&pA?mhtUJ$i6nx~JQkL9BMwNS{9wz=W9og3?gs0+*9=j?V7Dpb{U%c?dK&#l zudH#R=Rq6@&G*MZ>eM_?pDzAt14a5mU0Z6vqDumgiYic2IxIK$q#;4@CZ&zp~m_HuBOlVrYO0Ll^z> zb>8)R{BIdG{9^=OQqx@5X`6hfz<}V%!L0_A>)r+6RQ7W!Zm@?`oQ&Adt^4!bcX)+x zaN|Ot=3=;xmmUR{(eC|4>1pr15wH;A^JRX$cpBW$dsnOI@B8%NZT>QE8Et*!bJjs89;v-oKgN6R%_4ZAk=;_I-?Ns@0|T5Ic3n(8Vd~SF5GOb-CSdQ zc+Y!>R(KAo=kDge-^<{^F&qO-oJWO8*wAv-B_E|WyrPRmeVr%x{V#r_&jR>&WIE2V z+=M}$gz&64*qgzk+R+6WarPLxl_XBI2XBNHVa+KBVN};6V*tbY_!0KsI?)HK^LatU zgn^|cCrKr1LZa1r)BSeBRSe7pFrzXYB=j*K*@1x=3jM?8T=ce)by84A!kj`Z-QaLq zp54nI!Ul)Wi)NkzUq6)TD=DWpqY%y-f(FyKL)knXlwo;;!^+|4f*ih45xo%tRu_Hn zxp^XE#&MT|1)%><%@omFQAozp`&g{?G5F$+F5Cj=ew#5T?CEuIsx11j$&WEIpTGP( z^L`7Tmc4J|GC(C0nwZhTh{hK$(0w3B-VCR>ho<`0>tI&fQT5)qTcg`96&($(y^h<@hxrq^~}P{0qO_) z?K$N)Ng@5F+;XTe2ZOpJ37_+-xeyqP7{>;$WyyD6Bp)U4_$G6SRTB#9S$HwT0`FIoA`%P!Gc0I%oi42)rPn<9o*u^M!W#9ciz?SRL~gMX$@;er@rN-HWdm zJQ&K)5*DaZC!H8!8I5>`QUDePH}SD;1mq;kIy0j4>*LR+vkg!~(ndgYYPSTK=>N{c zeuw9pA3{sczIyfBbr>0n}7>Ix3w>7sG)3$R}rfzN*vH3b7rEZNf{J!oHl3+F|p8sA$nuM4wBfb{8JZN=e zzbC}{JCMcXd?6C5SnKxqG{i1vcm`D*R3#fZ28V6yt5BP#05-VicvT|L4n8gjt37Ee z1h~PrY3(u3LZfG7GlCZh-+pwsh#(A)U1{fdkS-~fsBuD_f4Ti_J@+3 zDu=w}tcNW8V(1Li4~J|%Sk4q;B)jl`xfuJWa-G#Td6?#+O|PeTJ$MURJ~?XF4bnS7wb(4c9OGM?jlp2oxvQ za#dR{m&V0FL{&y45kGSg#JeCI>`3!4#JRy}x8k7*cXxy1w=Tcw{~5c=|0C5ZW0F(p zk!B&097db{Lmf>K;Z%px-#LAnJ3S#?6va)Tv9Mg^i%x!5yoQK2v&UO*zI14UvkdFA z!Z~}^&w8Z-6N$WO4@~(dx;GDgJ)pPMHYi@bkqmEX*kooP>8zOOYLX%1*N%9aUxn;x zgotEYGs-wfZyS1W)}% zO5RXUd9Y}K!+8uc%i+q!>4qhZ!L|r?N3YJHA;RC#2DtXryiRcKDma-D2kN);=4Nkv zA!*Mk6*-bh)+bw2f1^Id_o@@o_fB!xX`eWs_+XWsma%Py;!p|t%rmkKi%xZ@2gCD1 zh<27A8~4?OjSM6Q`j~vPgjdG01Em_aOOg|RYHZOIfg9swGE27X1y(*WeYd-u>w$yS z+d<3E6uUni1e~3kO=nSm0SKROSE5x+Yoo2OpzTIQOOD?YDg7LI9%e~hrmyr?V&Vww zu+Pi3Yww5Kyq0%FPaVeh6ntZ~l1kLHXdOD((ogByZTGHb=EyWOn#k0s?lMb#c;64X zwcH`onhnmP?)@QGA@Ua?iXt-9_qIY3)HwUJlwJlh6oS|!XyrZ@xcU|4Jvk9TtU+yGd1r6!A zmyOP6ne&!DuNR*-Ul`2ryoFc}$9Y8B9T+z%~ll?RTPG@5CZ@d!F+X z{OQ3`Z)WOP1|R|lfrJK$OY`=g&h*!bW}!ahq@ zk(qGutwX2*Ka7K5!BL4S`Y>3H${8sa+Aw)eX$q1Zxulq=5PQ7hi#q}iD+Dt}&}0eQ z)a!81=X&2uVZTSxY_`c?3kNlTenq ziE`!S=g#T59pLyXPBPKA2dyNx+cblB6b@emC#U`Z^Y5K??eFj4;tRQR(UlXa#xJSgP^v56UGT z>uk85(X)szelIx#?HPq*rTDI_>#^bHDO2-@HDdK=FYxE_OpWAEZm@7Mg9oXvytag9 zdZH^nz1E?sI+v*;QzvFcEIQf|LoZIQKQ7`EED*kRu-dyn$2fRG9O=5%p@gi}pE&J` zsu{^G2UH$A-IMh4#5QT-WTa~c(8wuv@h&ZI5+VNf`ifUgQ%%)u`W1G{&K2e@%K{qP z!g10x^gliuO@Ghv1?3t8iya3)Ro{NuatLxQR*TO0>u50CO8qls-!$3T-4iE)N^q3( zv6pvt2}$&72&dgUW5I+;-{d4Ny(c!s4TV%hY^J(X9M%ZlCph%c!^iu};$-IIF?;i{G zN>53EP8vsE%bqqc$&SsDH#Nb1{flII?|E11l2@}F`f{fM`Mrj5{KB;iGar;TuND|1RH*umxz zJu1IQ`CYLy&wAQpb{NCo>2=7jEZUr$RYudyoiG^M2y+8=CW5GDg&4h5mW4>gYtBQS z&Qp>^JLs50q!JF+!+{^lNP5$~%~a`ESpcr1ohdNxgjFCu*Ip z0=&y)?=yw-xrHp`*@vu=RE~mc!_agz;x9$RtW;-4J?wWxa?xV9ORGPk`;r@&#u9g}=;<8FqGG zukPWGSz7tT27Ay64e~@P4eEas#bghhW%zK7@=`SZ zER=}wW{r{3VDJu+8kmZOe#EfKQ|1bLgFSPT&YZd9gM*tn!|BYEdAkzOm@IOr! TW=bzMdCC|g8cZM6{|NsJJP^5q diff --git a/tests/test_upload_skill.py b/tests/test_upload_skill.py index 2320bd6..8fc52de 100644 --- a/tests/test_upload_skill.py +++ b/tests/test_upload_skill.py @@ -71,6 +71,7 @@ def main() -> None: raise AssertionError("错误确认码未被阻止") except MODULE.UploadError as error: assert "确认码不匹配" in str(error) + assert error.action == "revise" and "确认码" in error.next_step assert not calls MODULE.request_json = lambda *_args, **_kwargs: (200, [{"id": 88, "external_id": "agent-test-001"}]) @@ -97,6 +98,7 @@ def main() -> None: raise AssertionError("版本漂移未被阻止") except MODULE.UploadError as error: assert "当前版本已变化" in str(error) + assert error.action == "ask_operator" assert not post_calls work_reads = iter([detail(2, work_id=34), detail(3, work_id=34)]) @@ -110,6 +112,17 @@ def main() -> None: raise AssertionError("错误项目归属未被阻止") except MODULE.UploadError as error: assert "不是已确认项目" in str(error) + + revised = MODULE.http_failure("POST", "/api/projects/12/works", 422, "图片不可用") + assert revised.action == "revise" and "重新生成计划" in revised.next_step + unauthorized = MODULE.http_failure("GET", "/api/projects", 401, "无效 Key") + assert unauthorized.action == "ask_operator" and "DELIVERY_DESK_API_KEY" in unauthorized.next_step + transient_read = MODULE.http_failure("GET", "/api/projects", 503, "暂时不可用") + assert transient_read.action == "retry" and "只读" in transient_read.next_step + uncertain_write = MODULE.http_failure("POST", "/api/works/34/rounds", 503, "暂时不可用") + assert uncertain_write.action == "ask_operator" and "禁止自动重试" in uncertain_write.next_step + private_url = MODULE.http_failure("POST", "/api/projects/12/works", 400, "图片地址不允许访问私有网络") + assert private_url.action == "revise" and "修改输入" in private_url.next_step finally: MODULE.exact_project = original_exact_project MODULE.request_json = original_request_json