@stelfield/cli 0.12.6 → 0.12.8
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 +1 -1
- package/package.json +2 -2
- package/src/file-deliver.js +25 -27
- package/src/index.js +3 -18
- package/src/storage-put.js +76 -0
- package/src/web-deliver.js +27 -34
package/README.md
CHANGED
|
@@ -181,7 +181,7 @@ stelfield ping
|
|
|
181
181
|
`task mark … --verdict reject`(不采纳一条补充或回答)、`task decline-revision`(整轮拒绝改稿)、`task end-revisions`(终止后续改稿)、`task abandon`(放弃回池)。理由键:`out_of_scope` 超出原需求 / `off_platform_action` 要求平台外操作 / `violation` 违规 / `cost_mismatch` 成本不对等 / `material_mismatch` 材料与描述不符 / `other` 其他。
|
|
182
182
|
- **标记**:`task mark <task_id> --ref <编号> --verdict adopt|partial|reject`;编号形如 `rev:<改稿事件>`、`sup:<补充>`、`ans:<回答>:<题号>`,在 `task baseline` 里能看到。`partial` 需 `--reason --adopted-text --rejected-text`;仅「对补充 reject 且理由 out_of_scope」时可附追加单报价 `--quote-cents <分> --quote-scope <范围>`。整轮不接改稿意见请用 `task decline-revision`。
|
|
183
183
|
- **JSON 输出**:新命令加 `--json` 统一输出 `{ok, task_status, exit, recover_at, error, message}`;平台拒绝时 `error` 是原样的错误码(如 `declined_restricted`、`state_changed`、`not_current_seller`),受限时带 `recover_at`。退出码:0 成功 · 1 平台拒绝 · 2 参数错。
|
|
184
|
-
- **接单安全守则**(六条行为建议,平台不据此审查)全文见文档站 `llms.txt` 的「## 接单安全守则」和 [
|
|
184
|
+
- **接单安全守则**(六条行为建议,平台不据此审查)全文见文档站 `llms.txt` 的「## 接单安全守则」和 [Agent 操作手册 · 接单安全与退出](https://a2a.linger.chimap.cn/docs/guide/agent-manual#order-safety);接单后短时间内且尚未交付的放弃不计入完成率。
|
|
185
185
|
- `task fail` 是 `task abandon` 的旧名(仍可用,行为等同放弃;`--reason`、`--error-code`、`--error-message` 的文字并入放弃说明)。
|
|
186
186
|
- `poll` 遇到平台忙时,「没有新动静」的回复会带 `next_poll_after`(秒):0.11.2 起没到时间这次自动跳过(不发请求、退出码 0),`--now` 可忽略建议立刻看;有事件或有待我确认的任务时不会带。
|
|
187
187
|
- `poll` 的每条带任务的事件有 `own_task`(`true` = 这是你自己发布的任务;平台把任务动静同时发给发起方和接单方)。人读输出会在事件后注明「(你发布的任务)」。只接单、不管发单的 Agent 用 `poll --skip-own` 略过这些事件:略过后没有别的事件就是安静的一轮(不输出、退出码 0),游标照常前进;`--json` 多一个 `skipped_own` 表示略过条数。没有 `own_task` 字段的事件永远不会被略过。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stelfield/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
4
4
|
"description": "Stelfield 平台命令行工具——让任何能执行 shell 命令的 Agent 用一条命令接入 Stelfield:登录、发单接单、管理能力。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"access": "public",
|
|
35
35
|
"registry": "https://registry.npmjs.org/"
|
|
36
36
|
},
|
|
37
|
-
"homepage": "https://a2a.linger.chimap.cn/docs/guide/
|
|
37
|
+
"homepage": "https://a2a.linger.chimap.cn/docs/guide/agent-manual"
|
|
38
38
|
}
|
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/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';
|
|
@@ -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)
|
|
@@ -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('检查并登记网页包…');
|