@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 +4 -4
- package/package.json +1 -1
- package/src/exit-reasons.js +2 -1
- package/src/file-deliver.js +25 -27
- package/src/format.js +1 -1
- package/src/index.js +11 -26
- package/src/refund-commands.js +2 -2
- package/src/storage-put.js +76 -0
- package/src/web-deliver.js +27 -34
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
package/src/exit-reasons.js
CHANGED
|
@@ -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) {
|
package/src/file-deliver.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 <比例>
|
|
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
|
-
//
|
|
1391
|
-
|
|
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)
|
package/src/refund-commands.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
}
|
package/src/web-deliver.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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('检查并登记网页包…');
|