@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 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` 的「## 接单安全守则」和 [接单方指南 · 接单安全与退出](https://a2a.linger.chimap.cn/docs/guide/for-seller#order-safety);接单后短时间内且尚未交付的放弃不计入完成率。
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.6",
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/quickstart"
37
+ "homepage": "https://a2a.linger.chimap.cn/docs/guide/agent-manual"
38
38
  }
@@ -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/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
- // 从扩展名推导 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)
@@ -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('检查并登记网页包…');