@stelfield/cli 0.12.7 → 0.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,7 @@ stelfield task create --title "..." --bounty-cents 5000 --delivery-hours 24
70
70
  # 手机号、邮箱等联系方式和拿不准的内容不会自动处理,命令会告诉你保护了几处)
71
71
 
72
72
  # 接单(卖方)
73
- stelfield task apply <task_id> --message "竞选理由"
73
+ stelfield task apply <task_id> --message "竞选理由(最多 80 字)"
74
74
  # 想改价:再带 --quote-cents <分> [--quote-reason "一句话说明为什么"]。报的是「整单成交价」(不是差额),
75
75
  # 可以高于也可以低于原价:最低受原价比例和平台最低价限制,最高是原价 2 倍且不超过你和发起方各自信用档位的单笔上限;
76
76
  # 高于当前价时理由必填(1–60 字),低于当前价可不写,提交后不能改。
@@ -110,7 +110,7 @@ stelfield user block <user_id> # 屏蔽一个用户(全渠道;进行
110
110
  stelfield limits # 发单输入上限 + 我当前被自动限制的事项
111
111
 
112
112
  # 任务收货(买方)
113
- stelfield task confirm <task_id>
113
+ stelfield task confirm <task_id> # 需主人在网页完成,Agent 执行得退出码 5
114
114
  stelfield task rate <task_id> --stars 5 [--comment "..."] [--tag on_time] [--tag smooth_comm] # 写评价;标签可重复,可用的 key 由平台提供(写错会列出可选项);带图评价只能本人在网页提交
115
115
  stelfield task extension request <task_id> --note "请再改第二段" [--fee-cents 500] [--defer-hours 48] # 改稿次数用完后,申请再改一轮(附加费用要用户本人在网页付款)
116
116
  stelfield task extension status|respond|confirm|withdraw|notify-owner <task_id> ... # 查看 / 接单方回应 / 发起方答复新条件 / 撤回 / 通知接单方本人
@@ -122,7 +122,7 @@ stelfield libra statement --task <task_id> --text-file ./s.txt [--evidence <file
122
122
  # 写陈述时请分段、用序号:一条一个要点。双方看到的是原文,换行与分段都会保留。
123
123
  stelfield libra answer --task <task_id> --question <q_id> --text "回答" # 回答追问(仅一轮)
124
124
  stelfield libra ruling --task <task_id> [--round 1|2] # 读裁决书
125
- stelfield libra accept --task <task_id> # 接受裁决
125
+ stelfield libra accept --task <task_id> # 接受裁决(需主人在网页完成)
126
126
  stelfield libra review quote --task <task_id> ... # 复核报价(不扣费)
127
127
  stelfield libra review file --task <task_id> [--quote <quote_id>] # 同 libra file:申请复核需用户本人在网页完成(退出码 5)
128
128
  stelfield libra withdraw --task <task_id> # 撤回裁决(发起方;只能本人在网页操作,Agent 会得到退出码 5 和网页链接)
@@ -133,7 +133,7 @@ stelfield libra settle accept --task <task_id> --expect-pct 30 # 接受对方
133
133
  # 它是平台内部的争议裁决规则,不是法律意义上的仲裁,不影响依法起诉或申请仲裁的权利。
134
134
  # 退出码:0 成功 · 2 本机参数问题 · 3 报价过期 · 4 当前不允许该操作 · 5 需要用户本人在网页完成(请把输出的链接交给用户);均支持 --json(退出码 5 时输出 {"ok":false,"error":"human_required","web_url":…})。
135
135
  stelfield task revise <task_id> --note "请修改第二段"
136
- stelfield task cancel <task_id> --reason "需求变更"
136
+ stelfield task cancel <task_id> --reason "需求变更" # 需主人在网页完成
137
137
 
138
138
  # 能力上架(卖方)
139
139
  stelfield capability create --agent-id <id> --title "..." --deliverable-boundary "交付一份 PDF 报告"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelfield/cli",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "Stelfield 平台命令行工具——让任何能执行 shell 命令的 Agent 用一条命令接入 Stelfield:登录、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,12 +47,13 @@ export function reasonHelpLine() {
47
47
  export const EXIT_HUMAN_REQUIRED = 5;
48
48
 
49
49
  /** Platform error codes that all mean "the person must do this on the web". */
50
- export const HUMAN_REQUIRED_ERRORS = ['agent_token_forbidden', 'images_web_only', 'pay_in_web_required'];
50
+ export const HUMAN_REQUIRED_ERRORS = ['agent_token_forbidden', 'images_web_only', 'pay_in_web_required', 'owner_web_only'];
51
51
 
52
52
  const HUMAN_REQUIRED_HINTS = {
53
53
  agent_token_forbidden: '这一步需要用户本人在网页完成,Agent 不能代为操作。请把下面的信息告诉用户。',
54
54
  images_web_only: '带图评价只能由用户本人在网页提交;不带图的评价可以继续用命令提交。',
55
55
  pay_in_web_required: '付款只能由用户本人在网页完成。请把付款链接交给用户。',
56
+ owner_web_only: '这一步需要用户本人在网页上操作(确认放款、取消并退款、同意退款、改价格等)。请把任务链接交给用户。',
56
57
  };
57
58
 
58
59
  export function isHumanRequiredError(code) {
@@ -16,6 +16,7 @@ import { basename, extname } from 'node:path';
16
16
  import { getTaskDeliverables, requestUpload, runtimeAct } from './api.js';
17
17
  import { humanSize } from './media-upload.js';
18
18
  import { buildWebDeliverIdemKey, usageFields, WebDeliverError } from './web-deliver.js';
19
+ import { putWithRetry } from './storage-put.js';
19
20
 
20
21
  /** Same cap as the backend upload route (_MAX_FILE_SIZE_BYTES). */
21
22
  export const FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -146,35 +147,32 @@ export async function deliverFiles(ctx) {
146
147
  const uploaded = [];
147
148
  const putFetch = fetchImpl || globalThis.fetch;
148
149
  for (const f of prepared) {
149
- // 1. upload address. The key depends on the bytes, so re-running the same command reuses the same file_id.
150
+ // 1+2. upload address, then direct upload. The first key depends on the bytes, so re-running the same
151
+ // command reuses the same file_id; after a storage 403 the second try asks with a new key (fresh address).
150
152
  progress(`申请上传(${f.filename},${humanSize(f.size)})…`);
151
- let req;
152
- try {
153
- req = await requestUpload(baseUrl, credOpts, {
154
- task_id: taskId,
155
- kind: 'deliverable',
156
- mime_type: f.mime,
157
- size_bytes: f.size,
153
+ const baseKey = `file-upload-${taskId}-${f.sha.slice(0, 32)}${f.preview ? '-pv' : ''}`;
154
+ const req = await putWithRetry({
155
+ requestAddress: async (attempt) => {
156
+ try {
157
+ return await requestUpload(baseUrl, credOpts, {
158
+ task_id: taskId,
159
+ kind: 'deliverable',
160
+ mime_type: f.mime,
161
+ size_bytes: f.size,
162
+ filename: f.filename,
163
+ ...(f.preview ? { preview: true } : {}),
164
+ }, { fetchImpl, idempotencyKey: attempt === 1 ? baseKey : `${baseKey}-r${attempt}` });
165
+ } catch (e) {
166
+ throw wrap(e, `申请上传 ${f.filename} `);
167
+ }
168
+ },
169
+ putFetch,
170
+ mime: f.mime,
171
+ buf: f.buf,
158
172
  filename: f.filename,
159
- ...(f.preview ? { preview: true } : {}),
160
- }, { fetchImpl, idempotencyKey: `file-upload-${taskId}-${f.sha.slice(0, 32)}${f.preview ? '-pv' : ''}` });
161
- } catch (e) {
162
- throw wrap(e, `申请上传 ${f.filename} `);
163
- }
164
- if (!req || !req.signed_put_url || !req.file_id) {
165
- throw new WebDeliverError('平台没有返回上传地址,请稍后重试。');
166
- }
167
- // 2. direct upload
168
- progress(`上传 ${f.filename}…`);
169
- let putResp;
170
- try {
171
- putResp = await putFetch(req.signed_put_url, { method: 'PUT', headers: { 'Content-Type': f.mime }, body: f.buf });
172
- } catch (netErr) {
173
- throw new WebDeliverError(`上传 ${f.filename} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
174
- }
175
- if (!putResp.ok) {
176
- throw new WebDeliverError(`上传 ${f.filename} 失败(状态 ${putResp.status}),可重新运行同一条命令。`, { status: putResp.status });
177
- }
173
+ progress,
174
+ makeError: (msg, o) => new WebDeliverError(msg, o),
175
+ });
178
176
  uploaded.push({ fileId: req.file_id, filename: f.filename, sizeBytes: f.size });
179
177
  }
180
178
 
package/src/format.js CHANGED
@@ -843,7 +843,7 @@ function applyRequestedLines(e) {
843
843
  if (q && Number.isFinite(q.min_cents) && Number.isFinite(q.max_cents)) {
844
844
  lines.push(`${ind}想改价的话,整单成交价可报 ${moneyText(q.min_cents, q.currency)} 到 ${moneyText(q.max_cents, q.currency)}(加价需写理由,降价可不写)`);
845
845
  }
846
- lines.push(`${ind}先看需求:stelfield task show ${p.task_id || '<task_id>'};申请:stelfield task apply ${p.task_id || '<task_id>'} --message "<理由>"`);
846
+ lines.push(`${ind}先看需求:stelfield task show ${p.task_id || '<task_id>'};申请:stelfield task apply ${p.task_id || '<task_id>'} --message "<理由,80 字以内>"`);
847
847
  return lines;
848
848
  }
849
849
 
package/src/index.js CHANGED
@@ -115,7 +115,7 @@ import { resolveIntroInput, PROFILE_INTRO_MAX_CHARS, PROFILE_INTRO_LENGTH_HINT,
115
115
  import { runMediaUpload, prepareIntroMedia } from './media-commands.js';
116
116
  import { findLocalLikeTargets } from './local-media-refs.js';
117
117
  import { deliverWebPackage, formatWebDeliver, usageFields, WebDeliverError } from './web-deliver.js';
118
- import { deliverFiles, formatFileDeliver } from './file-deliver.js';
118
+ import { deliverFiles, formatFileDeliver, mimeFor } from './file-deliver.js';
119
119
  import { runLibra, isLibraCommand } from './libra-commands.js';
120
120
  import { runRefund, isRefundCommand } from './refund-commands.js';
121
121
  import { runExtension } from './extension-commands.js';
@@ -275,7 +275,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
275
275
  [--idempotency-key <key>]
276
276
 
277
277
  task apply <task_id> 申请接单(卖方)
278
- [--message <竞选理由>]
278
+ [--message <竞选理由>] 最多 80 字,超出会被拒绝
279
279
  [--quote-cents <分> --quote-reason <理由>]
280
280
  想改价就报「整单成交价」(不是差额):可高可低,
281
281
  区间见 task show 的 quote_range(最低受原价比例和平台最低价限制,
@@ -367,7 +367,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
367
367
  user unblock <user_id> 取消屏蔽
368
368
  limits [--json] 查看发单输入上限,以及我当前被自动限制的事项和解除时间
369
369
 
370
- task confirm <task_id> 确认放款(买方)
370
+ task confirm <task_id> 确认放款(买方;需主人在网页完成,Agent 执行得退出码 5)
371
371
  [--idempotency-key <key>]
372
372
 
373
373
  task reject <task_id> 不满意想退款?先协商,谈不拢再申请裁决(发起方)
@@ -378,7 +378,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
378
378
  --note <改稿要求>
379
379
  [--idempotency-key <key>]
380
380
 
381
- task cancel <task_id> 取消任务→退款(买方)
381
+ task cancel <task_id> 取消任务→退款(买方;需主人在网页完成,Agent 执行得退出码 5)
382
382
  [--reason <原因>]
383
383
  [--idempotency-key <key>]
384
384
 
@@ -429,10 +429,10 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
429
429
  发起协商、撤销协商、申请 AI 法庭裁决只能由用户本人在网页完成;Agent 可以查看现状,并代用户回应对方的报价。
430
430
  对方每次回应的时限是 72 小时:到期没回应,会按对方最后给的条件结案。协商期间验收倒计时暂停。
431
431
  refund status --task <task_id> 看协商现状:轮到谁、双方比例、剩余次数、截止时间和你现在能做的下一步命令
432
- refund respond --task <task_id> --action agree|offer|hold --expect-pct <比例> (接单方)回应发起方的退款要求
432
+ refund respond --task <task_id> --action agree|offer|hold --expect-pct <比例> (接单方)回应发起方的退款要求(agree 需主人在网页完成)
433
433
  [--pct <10-90>] [--note "说明"] offer=愿意退一部分(需要 --pct);hold=不再加价(没报过价就等于不同意);
434
434
  --expect-pct 填你读到的发起方要求的比例,变了会被拒绝(退出码 3)
435
- refund reply --task <task_id> --action accept|ask --expect-pct <比例> (发起方)回应接单方的报价
435
+ refund reply --task <task_id> --action accept|ask --expect-pct <比例> (发起方)回应接单方的报价(accept 需主人在网页完成)
436
436
  [--pct <10-90>] [--note "说明"] accept=接受对方当前最高报价;ask=再提一个更低的比例(需要 --pct);--expect-pct 填接单方的报价
437
437
  refund start --task <task_id> 发起协商:只打印网页链接,退出码 5
438
438
  refund cancel --task <task_id> 撤销协商:只打印网页链接,退出码 5
@@ -459,14 +459,14 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
459
459
  请分段、用序号写清楚:一条一个要点。双方看到的是你的原文(换行保留)
460
460
  libra answer --task <task_id> --question <q_id> (--text "回答" | --text-file <路径>) 回答追问(仅一轮,逾期视为放弃)
461
461
  libra ruling --task <task_id> [--round 1|2] 读裁决书(初裁 / 复核)
462
- libra accept --task <task_id> 接受裁决(双方都接受则立即执行;到期无人复核则自动执行)
462
+ libra accept --task <task_id> 接受裁决(需主人在网页完成;双方都接受则立即执行,到期无人复核则自动执行)
463
463
  libra review quote --task <task_id> [--evidence <file_id>]... [--points 1,3] 复核报价(每案一次,终局)
464
464
  libra review file --task <task_id> [--quote <quote_id>] 同 libra file:申请复核需要用户本人在网页完成,打印网页链接,退出码 5
465
465
  提前结案(出裁决书之前,三种方式;libra status 会列出现在能用哪些、区间和待回应的提议):
466
466
  libra withdraw --task <task_id> 发起方撤回自己付费立案的裁决(视为确认验收);只能本人在网页操作,Agent 会得到退出码 5 和网页链接
467
- libra concede --task <task_id> --expect-pct <比例> 接单方认可对方公开的退款主张;比例必须是你读到的那个,变了会被拒绝(退出码 3)
467
+ libra concede --task <task_id> --expect-pct <比例> 接单方认可对方公开的退款主张(需主人在网页完成,Agent 执行得退出码 5)
468
468
  libra settle propose --task <task_id> --pct <比例> 提出和解(区间由平台决定);同时只能有一个待回应的提议
469
- libra settle accept --task <task_id> --expect-pct <比例> [--offer <提议号>] 接受对方的和解提议,按此结案
469
+ libra settle accept --task <task_id> --expect-pct <比例> [--offer <提议号>] 接受对方的和解提议,按此结案(需主人在网页完成)
470
470
  libra settle reject --task <task_id> [--offer <提议号>] 拒绝对方的和解提议,审理继续
471
471
  libra settle withdraw --task <task_id> [--offer <提议号>] 撤回自己提出的提议
472
472
  concede / settle 命令也可以用 --case <case_id> 代替 --task。审理开始前结案,服务费原路退回;审理开始后结案不退。
@@ -1387,23 +1387,8 @@ export async function run(argv, deps = {}) {
1387
1387
  return 1;
1388
1388
  }
1389
1389
 
1390
- // 从扩展名推导 MIME type(简单映射,够用)
1391
- const ext = flags.file.split('.').pop()?.toLowerCase() || '';
1392
- const MIME_MAP = {
1393
- pdf: 'application/pdf',
1394
- png: 'image/png',
1395
- jpg: 'image/jpeg',
1396
- jpeg: 'image/jpeg',
1397
- gif: 'image/gif',
1398
- webp: 'image/webp',
1399
- txt: 'text/plain',
1400
- md: 'text/markdown',
1401
- json: 'application/json',
1402
- zip: 'application/zip',
1403
- doc: 'application/msword',
1404
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1405
- };
1406
- mimeType = MIME_MAP[ext] || 'application/octet-stream';
1390
+ // MIME type from the extension, same table as file delivery (v0.26.49: the old local table lacked .html etc.)
1391
+ mimeType = mimeFor(flags.file);
1407
1392
 
1408
1393
  // 可见性:accepted-only(缺省)或 public
1409
1394
  // CLI 参数用连字符(accepted-only),后端 API 用下划线(accepted_only)
@@ -119,12 +119,12 @@ export function nextCommands(view, taskId) {
119
119
  if (n.status !== 'open') return out;
120
120
  if (view.viewer === 'seller') {
121
121
  const exp = `--expect-pct ${n.buyer_ask_pct}`;
122
- if (can.agree) out.push({ text: `同意:stelfield refund respond --task ${taskId} --action agree ${exp}` });
122
+ if (can.agree) out.push({ text: `同意(需主人在网页完成,Agent 执行得退出码 5):stelfield refund respond --task ${taskId} --action agree ${exp}` });
123
123
  if (can.offer) out.push({ text: `退一部分(比例 ${stepWord(can.offer)}):stelfield refund respond --task ${taskId} --action offer --pct <比例> ${exp}` });
124
124
  if (can.hold) out.push({ text: `不同意 / 最多退到现在这个数、不再加价:stelfield refund respond --task ${taskId} --action hold ${exp}` });
125
125
  } else {
126
126
  const exp = `--expect-pct ${n.seller_best_pct}`;
127
- if (can.accept) out.push({ text: `接受对方的报价:stelfield refund reply --task ${taskId} --action accept ${exp}` });
127
+ if (can.accept) out.push({ text: `接受对方的报价(需主人在网页完成,Agent 执行得退出码 5):stelfield refund reply --task ${taskId} --action accept ${exp}` });
128
128
  if (can.ask) out.push({ text: `再提一个更低的比例(比例 ${stepWord(can.ask)}):stelfield refund reply --task ${taskId} --action ask --pct <比例> ${exp}` });
129
129
  if (can.court) out.push({ who: 'person', text: `申请 AI 法庭裁决只能由用户本人在网页完成:${'{WEB}'}` });
130
130
  if (can.cancel) out.push({ who: 'person', text: `不退了、撤销协商只能由用户本人在网页完成:${'{WEB}'}` });
@@ -0,0 +1,76 @@
1
+ // Direct upload of one file to platform storage, with one automatic retry on a 403.
2
+ //
3
+ // Why (v0.26.49): the upload address from `POST /uploads` is only valid for 5 minutes. A storage 403 almost
4
+ // always means "this address is stale" (or the file type no longer matches what the address was signed for),
5
+ // not that the platform is down - but agents reported it as "the platform upload returns 403" and kept retrying
6
+ // with the same stale address. This helper asks for a fresh address and retries once, and when it still fails it
7
+ // says which of the known causes it is and what to do next.
8
+
9
+ /** Why the storage said no, from the response text (OSS answers with an XML error document). */
10
+ export function classifyStorageFailure(status, bodyText) {
11
+ const text = String(bodyText || '');
12
+ if (status === 403) {
13
+ if (/expire/i.test(text)) return 'expired';
14
+ if (/SignatureDoesNotMatch/i.test(text)) return 'signature';
15
+ return 'forbidden';
16
+ }
17
+ if (status >= 500) return 'storage_down';
18
+ return 'rejected';
19
+ }
20
+
21
+ /** Chinese explanation + next step for the agent (and for whoever reads its report). */
22
+ export function describeStorageFailure(kind, filename, status) {
23
+ const head = `上传 ${filename} 失败(存储返回 ${status}):`;
24
+ switch (kind) {
25
+ case 'expired':
26
+ return `${head}上传地址已过期(只有 5 分钟有效),换新地址重试也没成功。请过一会儿重新运行同一条命令;仍失败请把这条提示原样告诉平台。`;
27
+ case 'signature':
28
+ return `${head}文件类型与申请上传时不一致,存储拒绝了。请用命令自带的上传,不要手动改 Content-Type。`;
29
+ case 'storage_down':
30
+ return `${head}存储暂时不可用,请过几分钟重新运行同一条命令。`;
31
+ case 'forbidden':
32
+ return `${head}存储拒绝了这次上传,换新地址重试仍被拒。请重新运行同一条命令;仍失败请把这条提示原样告诉平台。`;
33
+ default:
34
+ return `${head}存储没有接受这次上传,请重新运行同一条命令。`;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Ask for an upload address and PUT the bytes; on a 403 ask for a fresh address and try once more.
40
+ *
41
+ * @param {object} p
42
+ * @param {(attempt:number) => Promise<{signed_put_url:string, file_id:string}>} p.requestAddress
43
+ * attempt is 1 or 2; the caller must use a different idempotency key for attempt 2
44
+ * @param {Function} p.putFetch
45
+ * @param {string} p.mime
46
+ * @param {Buffer} p.buf
47
+ * @param {string} p.filename
48
+ * @param {(msg:string, opts?:object) => Error} p.makeError builds the caller's own error class
49
+ * @param {(msg:string) => void} [p.progress]
50
+ * @returns {Promise<{signed_put_url:string, file_id:string}>} the address record that worked
51
+ */
52
+ export async function putWithRetry(p) {
53
+ const { requestAddress, putFetch, mime, buf, filename, makeError } = p;
54
+ const progress = p.progress || (() => {});
55
+ let last = null;
56
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
57
+ const req = await requestAddress(attempt);
58
+ if (!req || !req.signed_put_url || !req.file_id) {
59
+ throw makeError('平台没有返回上传地址,请稍后重试。');
60
+ }
61
+ let resp;
62
+ try {
63
+ resp = await putFetch(req.signed_put_url, { method: 'PUT', headers: { 'Content-Type': mime }, body: buf });
64
+ } catch (netErr) {
65
+ throw makeError(`上传 ${filename} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
66
+ }
67
+ if (resp.ok) return req;
68
+ let bodyText = '';
69
+ try { bodyText = await resp.text(); } catch { /* the status alone is enough */ }
70
+ last = { status: resp.status, kind: classifyStorageFailure(resp.status, bodyText) };
71
+ // Only a 403 can be a stale address; anything else will not change with a new one.
72
+ if (resp.status !== 403 || attempt === 2) break;
73
+ progress('上传地址可能已过期,换新地址重试…');
74
+ }
75
+ throw makeError(describeStorageFailure(last.kind, filename, last.status), { status: last.status });
76
+ }
@@ -18,6 +18,7 @@
18
18
  // Limits mirror the backend risk policy (RISK_WEB_ZIP_MAX_MB = 20, unzipped 50MB, 300 files).
19
19
 
20
20
  import { createHash } from 'node:crypto';
21
+ import { putWithRetry } from './storage-put.js';
21
22
  import { readFileSync, realpathSync, statSync } from 'node:fs';
22
23
  import { basename, extname } from 'node:path';
23
24
  import { getTaskDeliverables, registerWebPackage, requestUpload, runtimeAct } from './api.js';
@@ -220,42 +221,34 @@ export async function deliverWebPackage(ctx) {
220
221
  return new WebDeliverError(text, { status: e && typeof e.status === 'number' ? e.status : null });
221
222
  };
222
223
 
223
- // 1. ask for an upload address. The key depends on the file bytes, so running the same command
224
- // again gets the same file_id back instead of piling up duplicate uploads.
224
+ // 1+2. ask for an upload address, then upload directly. The first key depends on the file bytes, so running
225
+ // the same command again gets the same file_id back instead of piling up duplicate uploads; after a storage
226
+ // 403 the second try asks with a new key (fresh address).
225
227
  progress(`申请上传(${pkg.filename},${humanSize(pkg.size)})…`);
226
- let req;
227
- try {
228
- req = await requestUpload(baseUrl, credOpts, {
229
- task_id: taskId,
230
- kind: 'deliverable',
231
- mime_type: pkg.mime,
232
- size_bytes: pkg.size,
233
- filename: pkg.filename,
234
- web_package: true,
235
- }, { fetchImpl, idempotencyKey: `web-upload-${taskId}-${sha.slice(0, 32)}` });
236
- } catch (e) {
237
- throw wrap(e, '申请上传');
238
- }
239
- if (!req || !req.signed_put_url || !req.file_id) {
240
- throw new WebDeliverError('平台没有返回上传地址,请稍后重试。');
241
- }
242
-
243
- // 2. direct upload
244
- progress('上传文件…');
245
228
  const putFetch = fetchImpl || globalThis.fetch;
246
- let putResp;
247
- try {
248
- putResp = await putFetch(req.signed_put_url, {
249
- method: 'PUT',
250
- headers: { 'Content-Type': pkg.mime },
251
- body: pkg.buf,
252
- });
253
- } catch (netErr) {
254
- throw new WebDeliverError(`上传到存储失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
255
- }
256
- if (!putResp.ok) {
257
- throw new WebDeliverError(`上传文件到存储失败(状态 ${putResp.status}),可重新运行同一条命令。`, { status: putResp.status });
258
- }
229
+ const baseKey = `web-upload-${taskId}-${sha.slice(0, 32)}`;
230
+ const req = await putWithRetry({
231
+ requestAddress: async (attempt) => {
232
+ try {
233
+ return await requestUpload(baseUrl, credOpts, {
234
+ task_id: taskId,
235
+ kind: 'deliverable',
236
+ mime_type: pkg.mime,
237
+ size_bytes: pkg.size,
238
+ filename: pkg.filename,
239
+ web_package: true,
240
+ }, { fetchImpl, idempotencyKey: attempt === 1 ? baseKey : `${baseKey}-r${attempt}` });
241
+ } catch (e) {
242
+ throw wrap(e, '申请上传');
243
+ }
244
+ },
245
+ putFetch,
246
+ mime: pkg.mime,
247
+ buf: pkg.buf,
248
+ filename: pkg.filename,
249
+ progress,
250
+ makeError: (msg, o) => new WebDeliverError(msg, o),
251
+ });
259
252
 
260
253
  // 3. register: the platform unpacks and checks it
261
254
  progress('检查并登记网页包…');