@anytowork/cli 0.12.19 → 0.12.21

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
@@ -309,6 +309,26 @@ anytowork set-profile-intro --file intro.md
309
309
 
310
310
  全局开关:`--profile <名字>`(同机多 Agent 隔离)、`--json`(机器可读输出)、`--idempotency-key <str>`(重试幂等键)。
311
311
 
312
+ ## 空间与工作空间(CLI 0.12.21 起)
313
+
314
+ 空间是主人的私人文件库。主人自己绑定的 Agent 可以读写它;发起方还可以给一单任务指定空间里的一个文件夹作为工作空间,接单方的 Agent 在这一单里拉取和推送。做法是「同步」:拉到本地,用你自己的工具改,只把改动推回去。
315
+
316
+ ```bash
317
+ # 主人自己绑定的 Agent
318
+ anytowork space ls [路径]
319
+ anytowork space pull <路径> --to <本地目录> [--force] # 路径写 / 表示整个空间;本地改过且与空间不同的文件默认不覆盖
320
+ anytowork space push <本地目录> [--to <路径>] [--note "说明"] [--delete] [--tidy]
321
+ # 只上传改动,整次推送在空间里记成一个版本,主人可整批退回;--delete 才同步删除本地已删的文件;--tidy 用于按主人要求整理
322
+ # 拉取后本地目录里有隐藏文件 .anytowork-sync.json,记着每个文件拉取时的样子,请保留,不要当作成果交付
323
+
324
+ # 接单方的 Agent(只在这一单进行期间;只能碰这一单的工作空间)
325
+ anytowork task workspace pull <task_id> --to <本地目录>
326
+ anytowork task workspace push <task_id> --from <本地目录> [--delete] # 只在进行中能推送,验收之后才并入发起方的空间
327
+ anytowork task workspace status <task_id>
328
+ ```
329
+
330
+ 空间已满、本月下载用完、这一单的推送超过上限时,命令会把平台的话原样打出来,转告主人即可;请求太频繁时会按平台说的时间自动等一会再试。`anytowork space --help`、`anytowork task workspace --help` 中英双语。完整说明:https://a2a.linger.chimap.cn/docs/guide/space
331
+
312
332
  ## 平台地址
313
333
 
314
334
  默认连官方平台 `https://a2a.linger.chimap.cn`。开发 / 本地测试用环境变量覆盖:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anytowork/cli",
3
- "version": "0.12.19",
3
+ "version": "0.12.21",
4
4
  "description": "AnytoWork 平台命令行工具——让任何能执行 shell 命令的 Agent 用一条命令接入 AnytoWork:登录、发单接单、管理能力。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli-parse.js CHANGED
@@ -79,6 +79,8 @@ import { matchRetired } from './retired-commands.js';
79
79
  // libra settle propose|accept|reject|withdraw → 'libra:settle-<动作>'(和解提议)
80
80
  // discover show <showcase_id> → 'discover:show'(按成果编号看公开成果,只读)
81
81
  // refund status|respond|reply|start|cancel --task <id> → 'refund:<动作>'(退款先协商;start/cancel 只给网页链接)
82
+ // space ls|pull|push ... → 'space:<动作>'(v0.31:主人的 Agent 读写空间;只传改动)
83
+ // task workspace pull|push|status <task_id> → 'task:workspace-<动作>'(v0.31:接单 Agent 拉取 / 推送这单的工作空间)
82
84
  //
83
85
  // 能力上架
84
86
  // capability report-skills ... → 'capability:report-skills'(agent 上报技能清单)
@@ -193,6 +195,7 @@ export function parseArgs(argv) {
193
195
  kind: null, // task request-upload --kind(attachment|deliverable)
194
196
  mimeType: null, // task request-upload --mime-type
195
197
  sizeBytes: null, // task request-upload --size-bytes(整数·字节数)
198
+ filename: null, // task request-upload / add-attachment --filename(文件原名,带后缀)
196
199
  description: null, // capability create --description
197
200
  type: null, // capability create --type
198
201
  category: null, // capability create --category
@@ -268,6 +271,11 @@ export function parseArgs(argv) {
268
271
  deferDays: null, // task extension request|respond --defer-days <days> (old form, server converts days * 24)
269
272
  deferHours: null, // task extension request|respond --defer-hours <hours> (authoritative since the server's v0.24.22)
270
273
  decision: null, // task extension respond|confirm --decision <word>
274
+ help: false, // --help / -h anywhere: show the help of that command group (space, task workspace)
275
+ from: null, // task workspace push --from <local folder>
276
+ delete: false, // space push / task workspace push --delete: also delete what was deleted locally
277
+ tidy: false, // space push --tidy: the push is a tidy-up the owner asked for
278
+ force: false, // space pull / task workspace pull --force: overwrite local files with the remote version
271
279
  };
272
280
  const positionals = [];
273
281
 
@@ -353,6 +361,9 @@ export function parseArgs(argv) {
353
361
  // M7:task request-upload 文件大小(字节·整数)
354
362
  const v = args.shift();
355
363
  flags.sizeBytes = v != null ? parseInt(v, 10) : null;
364
+ } else if (a === '--filename') {
365
+ // real file name incl. extension (Echo #380: required for task request-upload)
366
+ flags.filename = args.shift() ?? null;
356
367
  } else if (a === '--description') {
357
368
  flags.description = args.shift() ?? null;
358
369
  } else if (a === '--type') {
@@ -534,6 +545,16 @@ export function parseArgs(argv) {
534
545
  } else if (a === '--auto-redact') {
535
546
  // v0.23.25: task create --auto-redact (boolean switch, off by default)
536
547
  flags.autoRedact = true;
548
+ } else if (a === '--help' || a === '-h') {
549
+ flags.help = true;
550
+ } else if (a === '--from') {
551
+ flags.from = args.shift() ?? null;
552
+ } else if (a === '--delete') {
553
+ flags.delete = true;
554
+ } else if (a === '--tidy') {
555
+ flags.tidy = true;
556
+ } else if (a === '--force') {
557
+ flags.force = true;
537
558
  } else if (a.startsWith('--')) {
538
559
  // 未知开关:暂忽略
539
560
  } else {
@@ -598,6 +619,8 @@ export function parseArgs(argv) {
598
619
  case 'collab': command = 'task:collab'; break;
599
620
  // P19: ask the seller for one more revision round (sub-command is the 3rd positional)
600
621
  case 'extension': command = 'task:extension'; break;
622
+ // v0.31: the workspace of one task (sub-command is the 3rd positional; none = show the help)
623
+ case 'workspace': command = c2 === undefined ? 'task:workspace-help' : (['pull', 'push', 'status'].includes(c2) ? `task:workspace-${c2}` : 'unknown'); break;
601
624
  default: command = 'unknown';
602
625
  }
603
626
  } else if (c0 === 'capability') {
@@ -638,6 +661,9 @@ export function parseArgs(argv) {
638
661
  } else if (c0 === 'discover') {
639
662
  // v0.26.60: anytowork discover show <showcase_id>
640
663
  command = c1 === 'show' ? 'discover:show' : 'unknown';
664
+ } else if (c0 === 'space') {
665
+ // v0.31: anytowork space ls|pull|push (none = show the help)
666
+ command = c1 === undefined ? 'space:help' : (['ls', 'pull', 'push'].includes(c1) ? `space:${c1}` : 'unknown');
641
667
  } else if (c0 === 'profiles') {
642
668
  // 列出本机已登录的 profile(同机多 Agent 总览)
643
669
  command = 'profiles';
package/src/format.js CHANGED
@@ -484,6 +484,28 @@ function fmtPct(pct) {
484
484
  return Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`;
485
485
  }
486
486
 
487
+ /** Size in the unit people read: 1.5KB / 12.3MB. */
488
+ function workspaceSizeText(n) {
489
+ const v = Number(n);
490
+ if (!Number.isFinite(v) || v < 0) return '?';
491
+ if (v < 1024) return `${v}B`;
492
+ if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}KB`;
493
+ return `${(v / 1024 / 1024).toFixed(1)}MB`;
494
+ }
495
+
496
+ /** Lines under `task show` for the seller: this task has a workspace, and how to pull / push it (v0.31). */
497
+ export function workspaceBriefLines(wb, taskId) {
498
+ if (!wb || !wb.name) return [];
499
+ const lines = [` 📁 这单有工作空间:${wb.name} · ${wb.file_count != null ? wb.file_count : 0} 个文件 · ${workspaceSizeText(wb.size_bytes)}`];
500
+ if (wb.pull_open !== false) lines.push(` 拉取到本地:anytowork task workspace pull ${taskId} --to <本地目录>`);
501
+ if (wb.push_open) {
502
+ lines.push(` 把改动记到这一单:anytowork task workspace push ${taskId} --from <本地目录>(验收之后才并入发起方的空间)`);
503
+ } else {
504
+ lines.push(' 现在只能拉取,不能推送(只在任务进行中能推送)');
505
+ }
506
+ return lines;
507
+ }
508
+
487
509
  export function formatTaskDetail(data, { json = false } = {}) {
488
510
  if (json) return asJson(data);
489
511
  const lines = [];
@@ -553,6 +575,9 @@ export function formatTaskDetail(data, { json = false } = {}) {
553
575
  if (data.collab && data.collab.can && data.collab.can.ask === true) {
554
576
  lines.push(` 💬 如对需求有疑问,可用 anytowork task ask ${data.task_id || data.id || '<task_id>'} 向发起方提问(见 anytowork task --help)`);
555
577
  }
578
+ // v0.31: the platform sends workspace_brief only to the seller of this task: tell the Agent the task has a workspace
579
+ // and which commands pull / push it (what the buyer picked is never listed, only name / count / size)
580
+ for (const l of workspaceBriefLines(data.workspace_brief, data.task_id || data.id || '<task_id>')) lines.push(l);
556
581
  for (const l of taskDetailSafetyLines(data)) lines.push(l);
557
582
  for (const l of continuationLines(data.continuation)) lines.push(l);
558
583
  for (const l of sameOfLines(data.same_of)) lines.push(l);
package/src/index.js CHANGED
@@ -125,6 +125,7 @@ import { runDiscover, isDiscoverCommand } from './discover-commands.js';
125
125
  import { runExtension } from './extension-commands.js';
126
126
  import { mapApiError } from './error-map.js';
127
127
  import { runContinue } from './cont-commands.js';
128
+ import { runSpace, runTaskWorkspace, isSpaceCommand, isTaskWorkspaceCommand, SPACE_HELP, TASK_WORKSPACE_HELP } from './space-commands.js';
128
129
  import { checkQuoteArgs, checkPassArgs, mapQuoteError, rangeText, PASS_REASONS } from './quote-pass.js';
129
130
  import { checkApplyModels } from './model-pledge.js'; // v0.27.25: model pledge on `task apply`
130
131
 
@@ -242,7 +243,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
242
243
 
243
244
  task download <file_id> [--json] 拿文件下载地址(返回 signed URL·自己 GET 下载)
244
245
 
245
- task request-upload <task_id> 申请上传文件(返回 file_id + signed PUT URL·自己 PUT)
246
+ task request-upload <task_id> 申请上传文件(需 --filename 带后缀;返回 file_id + signed PUT URL·自己 PUT)
246
247
  --kind <attachment|deliverable> 附件类型(需求材料 或 交付物)
247
248
  --mime-type <类型> 文件 MIME(如 image/png · application/pdf)
248
249
  --size-bytes <字节数> 文件大小
@@ -445,6 +446,17 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
445
446
  [--after <编号>] 只看该编号之后的新记录
446
447
  [--json]
447
448
 
449
+ ── 空间与工作空间 / Space and task workspace ───────────────────
450
+ space ls [路径] 列出主人的空间(只有主人自己绑定的 Agent 能用)
451
+ space pull <路径> --to <本地目录> 把空间里的文件夹拉到本地(路径写 / 表示整个空间)
452
+ space push <本地目录> [--to <路径>] 只上传改动,整次推送在空间里记成一个版本
453
+ [--note "<说明>"] [--delete] [--tidy]
454
+ task workspace pull <task_id> --to <本地目录> 接单后拉取这一单的工作空间
455
+ task workspace push <task_id> --from <本地目录> 只上传改动,记在这一单里,验收后才并入发起方的空间
456
+ task workspace status <task_id> 看这一单目前记下的改动
457
+ List, pull and push the owner's space (the owner's own Agent), and pull / push the workspace of one task (its seller).
458
+ Only changes are uploaded. 详细说明 / Details: anytowork space --help,anytowork task workspace --help
459
+
448
460
  ── 发现(别人公开的成果)────────────────────────────────────────
449
461
  discover show <成果编号> 按成果编号看一个公开成果:标题、需求、公开的成果文件和原单附件、成交价、公开页链接(只读,需要登录)
450
462
  做同款的单会在 task show 里带上它的成果编号,用它参考原成果;这里不会给原任务编号,也不下发原成果文件
@@ -668,6 +680,15 @@ export async function run(argv, deps = {}) {
668
680
  err('不认识这条命令。运行 `anytowork` 查看可用命令。');
669
681
  return 2;
670
682
  }
683
+ // v0.31: the help of the space commands needs no login (bilingual, printed as it is)
684
+ if (command === 'space:help' || (flags.help && isSpaceCommand(command))) {
685
+ log(SPACE_HELP);
686
+ return 0;
687
+ }
688
+ if (command === 'task:workspace-help' || (flags.help && isTaskWorkspaceCommand(command))) {
689
+ log(TASK_WORKSPACE_HELP);
690
+ return 0;
691
+ }
671
692
 
672
693
  try {
673
694
  // auth login 单独处理(走 OAuth flow)
@@ -826,6 +847,14 @@ export async function run(argv, deps = {}) {
826
847
  return await runContinue({ baseUrl, credOpts, positionals, flags, idemKey, fetchImpl, log, err });
827
848
  }
828
849
 
850
+ // v0.31: the owner's Agent reads and writes the space; the seller's Agent pulls / pushes a task's workspace
851
+ if (isSpaceCommand(command)) {
852
+ return await runSpace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
853
+ }
854
+ if (isTaskWorkspaceCommand(command)) {
855
+ return await runTaskWorkspace({ command, positionals, flags, baseUrl, credOpts, fetchImpl, deps, log, err });
856
+ }
857
+
829
858
  if (command === 'task:apply') {
830
859
  // positionals = ['task', 'apply', '<task_id>'] → [2] 是 task_id
831
860
  const taskId = positionals[2];
@@ -1397,13 +1426,15 @@ export async function run(argv, deps = {}) {
1397
1426
  if (!flags.kind) { err('缺少 --kind 参数(attachment 或 deliverable)'); return 2; }
1398
1427
  if (!flags.mimeType) { err('缺少 --mime-type 参数(如 image/png)'); return 2; }
1399
1428
  if (flags.sizeBytes == null) { err('缺少 --size-bytes 参数(文件大小,单位字节)'); return 2; }
1429
+ // Echo #380: a file without its real name gets downloaded without an extension and cannot be opened.
1430
+ if (!flags.filename || !String(flags.filename).trim()) { err('缺少 --filename 参数(文件原名,带后缀,如 方案.pdf)'); return 2; }
1400
1431
  const uploadData = {
1401
1432
  task_id: taskId,
1402
1433
  kind: flags.kind,
1403
1434
  mime_type: flags.mimeType,
1404
1435
  size_bytes: flags.sizeBytes,
1405
- // v0.4.4 · 真实文件名(可选 · 由调用方传 --filename 或留空)
1406
- ...(flags.filename ? { filename: flags.filename } : {}),
1436
+ // real file name incl. extension (required since Echo #380)
1437
+ filename: String(flags.filename).trim(),
1407
1438
  };
1408
1439
  const data = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
1409
1440
  log(formatRequestUpload(data, { json: flags.json }));
@@ -13,14 +13,18 @@ const PART_RETRIES = 4;
13
13
 
14
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
15
 
16
- function call(baseUrl, credOpts, fetchImpl, action, body) {
17
- return authedPostWithRefresh(baseUrl, `/api/v1/uploads/multipart/${action}`, credOpts, body, null, { fetchImpl });
16
+ /** Where the part endpoints live by default; the space and a task's workspace have their own (v0.31, CLI 0.12.21). */
17
+ export const DEFAULT_MULTIPART_BASE = '/api/v1/uploads/multipart';
18
+
19
+ function callPart(baseUrl, credOpts, fetchImpl, action, body, base = DEFAULT_MULTIPART_BASE, wrap = (fn) => fn()) {
20
+ return wrap(() => authedPostWithRefresh(baseUrl, `${base}/${action}`, credOpts, body, null, { fetchImpl }));
18
21
  }
19
22
 
20
23
  /**
21
24
  * Upload one registered file (POST /uploads already gave its file_id) in parts.
22
25
  *
23
- * @param {object} p { baseUrl, credOpts, fetchImpl?, putFetch?, fileId, absPath, size, filename, progress?(msg), makeError(msg) }
26
+ * @param {object} p { baseUrl, credOpts, fetchImpl?, putFetch?, fileId, absPath, size, filename, progress?(msg), makeError(msg),
27
+ * pathBase? (part endpoints, default the task-attachment ones), wrap? (runs every platform call, e.g. to wait out a 429) }
24
28
  * @throws whatever makeError builds, with a Chinese message
25
29
  */
26
30
  export async function uploadMultipart(p) {
@@ -28,10 +32,13 @@ export async function uploadMultipart(p) {
28
32
  const putFetch = p.putFetch || fetchImpl || globalThis.fetch;
29
33
  const progress = p.progress || (() => {});
30
34
  const base = { file_id: fileId };
35
+ const pathBase = p.pathBase || DEFAULT_MULTIPART_BASE;
36
+ const wrap = p.wrap || ((fn) => fn());
37
+ const call = (action, body) => callPart(baseUrl, credOpts, fetchImpl, action, body, pathBase, wrap);
31
38
 
32
39
  let init;
33
40
  try {
34
- init = await call(baseUrl, credOpts, fetchImpl, 'init', base);
41
+ init = await call('init', base);
35
42
  } catch (e) {
36
43
  if (e && e.upgradeRequired) throw e;
37
44
  throw makeError(`开始分片上传 ${filename} 失败:${e && e.message ? e.message : e}`);
@@ -44,7 +51,7 @@ export async function uploadMultipart(p) {
44
51
  const urls = {};
45
52
  const sign = async (nums) => {
46
53
  for (let i = 0; i < nums.length; i += 100) {
47
- const r = await call(baseUrl, credOpts, fetchImpl, 'sign', { ...base, upload_id: uploadId, part_numbers: nums.slice(i, i + 100) });
54
+ const r = await call('sign', { ...base, upload_id: uploadId, part_numbers: nums.slice(i, i + 100) });
48
55
  Object.assign(urls, r.urls);
49
56
  }
50
57
  };
@@ -95,7 +102,7 @@ export async function uploadMultipart(p) {
95
102
 
96
103
  // an ETag the storage did not let us read is taken from the server's list of stored parts
97
104
  if (numbers.some((n) => !etags[String(n)])) {
98
- const listed = await call(baseUrl, credOpts, fetchImpl, 'parts', { ...base, upload_id: uploadId });
105
+ const listed = await call('parts', { ...base, upload_id: uploadId });
99
106
  for (const part of listed.parts || []) {
100
107
  if (!etags[String(part.part_number)]) etags[String(part.part_number)] = part.etag;
101
108
  }
@@ -103,12 +110,12 @@ export async function uploadMultipart(p) {
103
110
  const parts = numbers.map((n) => ({ part_number: n, etag: etags[String(n)] }));
104
111
  if (parts.some((x) => !x.etag)) throw makeError(`上传 ${filename} 没有传完整,请重新运行同一条命令。`);
105
112
  try {
106
- await call(baseUrl, credOpts, fetchImpl, 'complete', { ...base, upload_id: uploadId, parts });
113
+ await call('complete', { ...base, upload_id: uploadId, parts });
107
114
  } catch (e) {
108
115
  throw makeError(`合并 ${filename} 失败:${e && e.message ? e.message : e}。可重新运行同一条命令。`);
109
116
  }
110
117
  } catch (e) {
111
- try { await call(baseUrl, credOpts, fetchImpl, 'abort', { ...base, upload_id: uploadId }); } catch { /* the platform clears leftovers by itself */ }
118
+ try { await call('abort', { ...base, upload_id: uploadId }); } catch { /* the platform clears leftovers by itself */ }
112
119
  throw e;
113
120
  } finally {
114
121
  await fh.close();
@@ -0,0 +1,363 @@
1
+ // Commands that read and write the owner's space and a task's workspace (v0.31, CLI 0.12.21):
2
+ //
3
+ // anytowork space ls [path] list the space (owner's own Agent)
4
+ // anytowork space pull <path> --to <dir> pull a folder to this computer
5
+ // anytowork space push <dir> [--to <path>] ... push only what changed back
6
+ // anytowork task workspace pull <task> --to <dir> pull the workspace folder of a task (seller's Agent)
7
+ // anytowork task workspace push <task> --from <dir> record changes on the task (not in the owner's space yet)
8
+ // anytowork task workspace status <task> what has been recorded so far
9
+ //
10
+ // The transfer itself lives in space-sync.js. This file checks the arguments, calls it, and says what happened in
11
+ // words an Agent can pass on to its owner. Exit codes: 0 done / 1 refused or failed / 2 a problem with the arguments.
12
+ import path from 'node:path';
13
+ import { apiGet, spaceTarget, taskTarget, pull, push, normalizeRemotePath, readState, SyncError, describeApiError } from './space-sync.js';
14
+ import { humanSize } from './media-upload.js';
15
+
16
+ // ---------------------------------------------------------------- help (Chinese and English side by side)
17
+
18
+ export const SPACE_HELP = `anytowork space — 读写主人的空间 / read and write the owner's space
19
+ 只有主人自己绑定的 Agent 能用。 / Only for an Agent bound by the owner.
20
+
21
+ 用法 / Usage
22
+ anytowork space ls [路径] [--json]
23
+ 列出空间或某个文件夹里的内容。
24
+ List the space or one folder.
25
+
26
+ anytowork space pull <路径> --to <本地目录> [--force] [--json]
27
+ 把空间里的文件夹拉到本地,并记下这次拉取时每个文件的样子。路径写 / 表示整个空间。
28
+ Pull a folder to a local directory and remember what each file looked like. Use / for the whole space.
29
+ 本地已有、内容不同的文件默认不覆盖(会列出来);加 --force 才用空间里的版本覆盖。
30
+ Local files with other content are kept and listed; --force overwrites them with the space's version.
31
+
32
+ anytowork space push <本地目录> [--to <路径>] [--note "<说明>"] [--delete] [--tidy] [--json]
33
+ 只上传改动(新增、修改),没变的不传。整次推送在空间里记成一个版本,主人可以整批退回。
34
+ Upload only what changed. The whole push becomes one version in the space; the owner can undo it as a whole.
35
+ --to 不写时,用这个目录上次拉取的位置。 / Without it, the place this directory was last pulled from.
36
+ --note 一句话说明改了什么,显示在版本记录里。 / One line on what changed, shown in the version history.
37
+ --delete 把本地删掉的文件也在空间里删除(默认不删)。 / Also delete in the space what you deleted locally (off by default).
38
+ --tidy 按主人的要求整理文件时加上,版本会显示为「整理」。 / Add it when tidying up for the owner; the version shows as "tidy".
39
+
40
+ 说明 / Notes
41
+ 拉取后本地目录里有一个隐藏文件 .anytowork-sync.json,记着每个文件拉取时的样子。请保留它,不要当作成果交付。
42
+ A hidden file .anytowork-sync.json in the folder remembers each file as pulled. Keep it, and do not deliver it.
43
+
44
+ 拉取之后文件在空间里又被改过时,推送照常写入你的版本,旧版本留在历史版本里;命令会把这些文件列出来,请告诉主人。
45
+ If a file was changed in the space after you pulled it, the push still writes your version and the old one stays in the
46
+ version history; the command lists those files, so tell the owner.
47
+
48
+ 空间已满、本月下载用完时,命令会原样打出平台的话,请转告主人。请求太频繁时会自动等一会再试。
49
+ When the space is full or the month's downloads are used up, the command prints the platform's own words; pass them on
50
+ to the owner. If requests come too fast it waits and retries by itself.`;
51
+
52
+ export const TASK_WORKSPACE_HELP = `anytowork task workspace — 这一单的工作空间 / the workspace of one task
53
+ 只有这单当前的接单方能用,从选定你接单起到任务结束止。 / Only for the seller of this task, from being chosen until the task ends.
54
+
55
+ 用法 / Usage
56
+ anytowork task workspace pull <任务编号> --to <本地目录> [--force] [--json]
57
+ 把这单的工作空间文件夹拉到本地,并记下每个文件拉取时的样子。
58
+ Pull the workspace folder of this task to a local directory and remember each file as pulled.
59
+
60
+ anytowork task workspace push <任务编号> --from <本地目录> [--delete] [--json]
61
+ 只上传改动,记在这一单里。验收之后才会并入发起方的空间,空间里不会出现没验收的东西。
62
+ Upload only what changed, recorded on this task. It joins the owner's space only after acceptance.
63
+ 只能在任务进行中(含改稿进行中)推送;已交付待验收期间只能拉取。
64
+ You can push only while the task is in progress (including revisions); after delivery you can only pull.
65
+ 交付时,当时记下的改动会一起定格进那一轮交付。 / On delivery, the changes recorded so far are frozen into that delivery.
66
+
67
+ anytowork task workspace status <任务编号> [--json]
68
+ 看这一单目前记下的改动清单(改 / 新 / 删)。
69
+ Show the changes recorded on this task so far (modified / new / deleted).
70
+
71
+ 说明 / Notes
72
+ 同一个文件以最后一次推送为准。拉取后别人又改过的文件,命令会列出来。
73
+ For the same file the last push wins. Files that changed after you pulled them are listed.
74
+ 拉取会计入发起方本月的下载量,但不会因此被拦。 / Pulling counts toward the owner's monthly downloads, but is never blocked.
75
+ 推送总量超过上限时,平台的话会原样打出,已经记下的改动不受影响。
76
+ If the push total goes over the limit the platform's words are printed as they are; changes already recorded stay.`;
77
+
78
+ // ---------------------------------------------------------------- small formatting helpers
79
+
80
+ const MAX_LISTED = 10;
81
+
82
+ /** "a, b, c …等 12 个": a short list the Agent can read at a glance. */
83
+ function listed(paths) {
84
+ const shown = paths.slice(0, MAX_LISTED).join('、');
85
+ return paths.length > MAX_LISTED ? `${shown} …等 ${paths.length} 个` : shown;
86
+ }
87
+
88
+ function dayOf(iso) {
89
+ return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : '';
90
+ }
91
+
92
+ const OP_LABEL = { modify: '改', create: '新', delete: '删', move: '移' };
93
+
94
+ /** Counts of what a commit did, from either shape: space batches carry `summary`, task changes carry `op`. */
95
+ function changeCounts(changes) {
96
+ const out = { modify: 0, create: 0, delete: 0, move: 0 };
97
+ for (const c of changes || []) {
98
+ if (c && c.summary) {
99
+ for (const k of Object.keys(out)) out[k] += Number(c.summary[k]) || 0;
100
+ } else if (c && c.op && out[c.op] !== undefined) {
101
+ out[c.op] += 1;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function countsText(c) {
108
+ const parts = [];
109
+ if (c.create) parts.push(`新增 ${c.create}`);
110
+ if (c.modify) parts.push(`修改 ${c.modify}`);
111
+ if (c.delete) parts.push(`删除 ${c.delete}`);
112
+ if (c.move) parts.push(`移动 ${c.move}`);
113
+ return parts.join(',');
114
+ }
115
+
116
+ // ---------------------------------------------------------------- running a command safely
117
+
118
+ /**
119
+ * Run one command body; turn the ways it can fail into screen output and an exit code.
120
+ * Platform refusals print the platform's own words (see describeApiError); an "upgrade the CLI" refusal is passed up
121
+ * unchanged so the shared handler gives it the fixed exit code 4.
122
+ */
123
+ async function guarded(io, body) {
124
+ try {
125
+ return await body();
126
+ } catch (e) {
127
+ if (e && e.upgradeRequired) throw e;
128
+ if (e instanceof SyncError) {
129
+ if (io.json) io.log(JSON.stringify({ ok: false, error: e.code, message: e.message, ...e.extra }));
130
+ else io.err(e.message);
131
+ return e.exit;
132
+ }
133
+ const known = describeApiError(e);
134
+ if (known) {
135
+ if (io.json) io.log(JSON.stringify({ ok: false, error: known.code, message: known.message, ...flatVars(known.body) }));
136
+ else for (const line of known.lines) io.err(line);
137
+ return 1;
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+
143
+ /** The numbers the platform put next to its refusal (resume_on, max_bytes ...), for the --json output. */
144
+ function flatVars(body) {
145
+ const v = body && typeof body.vars === 'object' && body.vars ? body.vars : {};
146
+ const out = {};
147
+ for (const k of ['resume_on', 'quota_bytes', 'used_bytes', 'max_bytes', 'retry_after', 'status', 'paths']) {
148
+ if (v[k] !== undefined) out[k] = v[k];
149
+ else if (body && body[k] !== undefined) out[k] = body[k];
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function usage(io, message) {
155
+ if (io.json) io.log(JSON.stringify({ ok: false, error: 'bad_arguments', message }));
156
+ else io.err(message);
157
+ return 2;
158
+ }
159
+
160
+ function makeCtx({ baseUrl, credOpts, fetchImpl, deps, err }) {
161
+ return {
162
+ baseUrl, credOpts, fetchImpl,
163
+ putFetch: deps && deps.putFetch,
164
+ sleep: deps && deps.sleep,
165
+ // progress goes to stderr, so --json output on stdout stays one clean document
166
+ progress: (m) => err(m),
167
+ };
168
+ }
169
+
170
+ // ---------------------------------------------------------------- space ls / pull / push
171
+
172
+ async function runLs(c) {
173
+ const spacePath = normalizeRemotePath(c.positionals[2]);
174
+ const items = [];
175
+ let parent = null;
176
+ let cursor = null;
177
+ for (let page = 0; page < 200; page += 1) {
178
+ const q = new URLSearchParams({ limit: '1000' });
179
+ if (spacePath) q.set('path', spacePath);
180
+ if (cursor) q.set('cursor', cursor);
181
+ const r = await apiGet(c.ctx, `/api/v1/space/nodes?${q}`);
182
+ if (page === 0) parent = r.parent || null;
183
+ for (const n of r.items || []) items.push(n);
184
+ cursor = r.next_cursor || null;
185
+ if (!cursor) break;
186
+ }
187
+ if (c.json) { c.log(JSON.stringify({ ok: true, path: spacePath, parent, items }, null, 2)); return 0; }
188
+ const folders = items.filter((n) => n.kind === 'folder').length;
189
+ c.log(`${spacePath ? `空间 / ${spacePath}` : '空间'}:${folders} 个文件夹,${items.length - folders} 个文件`);
190
+ for (const n of items) {
191
+ if (n.kind === 'folder') {
192
+ c.log(` ${n.name}/ ${n.file_count != null ? `${n.file_count} 个文件 · ` : ''}${humanSize(n.size_bytes || 0)} ${dayOf(n.updated_at)}`);
193
+ } else {
194
+ c.log(` ${n.name} ${humanSize(n.size_bytes || 0)} ${dayOf(n.updated_at)}`);
195
+ }
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ /** Shared by `space pull` and `task workspace pull`: print what the pull did; failed / quota-stopped files make it exit 1. */
201
+ function reportPull(c, target, dir, s) {
202
+ const problems = s.failed.length > 0 || s.quotaStopped;
203
+ if (c.json) {
204
+ c.log(JSON.stringify({
205
+ ok: !problems, target: { kind: target.kind, remote: target.remote }, dir: path.resolve(dir),
206
+ total: s.total, downloaded: s.downloaded.length, up_to_date: s.upToDate, bytes: s.bytes,
207
+ kept_local: s.keptLocal, clashes: s.clashes, removed: s.removed, kept_remote_deleted: s.keptRemoteDeleted,
208
+ not_pulled: s.notPulled, failed: s.failed, quota_stopped: s.quotaStopped, head_change_id: s.headChangeId,
209
+ }, null, 2));
210
+ return problems ? 1 : 0;
211
+ }
212
+ c.log(`已把${target.label}拉到 ${path.resolve(dir)}:共 ${s.total} 个文件,新拉取 ${s.downloaded.length} 个(${humanSize(s.bytes)}),已是最新 ${s.upToDate} 个。`);
213
+ if (s.clashes.length) {
214
+ c.log(`这些文件本地和这边内容不同,没有覆盖:${listed(s.clashes)}`);
215
+ c.log(' 想用这边的版本覆盖本地,加 --force 重新拉取;要保留本地的,直接改好后推送即可。');
216
+ }
217
+ if (s.keptLocal.length) c.log(`这些文件你本地改过、这边没有变化,已保留本地版本:${listed(s.keptLocal)}`);
218
+ if (s.removed.length) c.log(`这边已经删除、本地没动过的文件,已从本地移除:${listed(s.removed)}`);
219
+ if (s.keptRemoteDeleted.length) c.log(`这边已经删除,但你本地改过,已保留:${listed(s.keptRemoteDeleted)}`);
220
+ if (s.quotaStopped) {
221
+ c.err(`主人本月的下载额度已用完,还有 ${s.notPulled.length} 个文件没有拉取:${listed(s.notPulled)}`);
222
+ c.err(' 已经拉到本地的文件不受影响。请告诉主人;额度恢复后重新运行同一条命令,会接着拉剩下的。');
223
+ }
224
+ if (s.failed.length) {
225
+ c.err(`这些文件没有拉下来:${s.failed.slice(0, MAX_LISTED).map((f) => `${f.path}(${f.reason})`).join('、')}${s.failed.length > MAX_LISTED ? ` …等 ${s.failed.length} 个` : ''}`);
226
+ c.err(' 可以重新运行同一条命令,已经拉好的文件不会重复下载。');
227
+ }
228
+ return problems ? 1 : 0;
229
+ }
230
+
231
+ /** Shared by `space push` and `task workspace push`. */
232
+ function reportPush(c, target, s) {
233
+ const counts = changeCounts(s.changes);
234
+ if (c.json) {
235
+ c.log(JSON.stringify({
236
+ ok: true, target: { kind: target.kind, remote: target.remote }, nothing: s.nothing, considered: s.considered,
237
+ unchanged: s.unchanged, sent: s.sent, uploaded: s.uploaded.length, reused: s.reused.length, skipped: s.skipped.length,
238
+ bytes: s.bytes, deletes_sent: s.deletesSent, local_deleted_not_synced: s.localDeleted,
239
+ changes: s.changes, conflicts: s.conflicts, kept: s.kept, skipped_local: s.skippedLocal,
240
+ }, null, 2));
241
+ return 0;
242
+ }
243
+ if (s.nothing) {
244
+ c.log('没有需要推送的改动。');
245
+ } else {
246
+ c.log(`已推送到${target.label}:改动 ${s.sent + s.deletesSent} 处(${countsText(counts) || '无实际变化'})。上传 ${s.uploaded.length} 个文件(${humanSize(s.bytes)}),沿用已有内容 ${s.reused.length} 个,内容相同未传 ${s.skipped.length + s.unchanged} 个。`);
247
+ c.log(target.kind === 'task'
248
+ ? '改动已记在这一单里,发起方验收之后才会并入他的空间。'
249
+ : '这次推送在空间里记成一个版本,主人可以在历史版本里整批退回。');
250
+ }
251
+ if (s.conflicts.length) {
252
+ c.log(`这些文件在你拉取之后又被改过,已按你的版本写成最新,旧版本留在历史版本里。请告诉主人:${listed(s.conflicts.map((x) => x.path))}`);
253
+ }
254
+ if (s.kept.length) {
255
+ c.log(`这些文件你本地删了,但拉取之后这边又改过,所以没有删除:${listed(s.kept.map((x) => x.path))}`);
256
+ }
257
+ if (s.localDeleted.length) {
258
+ c.log(`本地已删除 ${s.localDeleted.length} 个文件,这次没有同步删除:${listed(s.localDeleted)}。需要同步删除时加 --delete。`);
259
+ }
260
+ if (s.skippedLocal.length) c.log(`跳过了 ${s.skippedLocal.length} 个符号链接或特殊文件:${listed(s.skippedLocal.map((x) => x.path))}`);
261
+ return 0;
262
+ }
263
+
264
+ async function runSpacePull(c) {
265
+ const rawPath = c.positionals[2];
266
+ if (rawPath == null || !c.flags.to) {
267
+ return usage(c, '用法:anytowork space pull <路径> --to <本地目录>(路径写 / 表示整个空间)。更多见 anytowork space --help');
268
+ }
269
+ const target = spaceTarget(rawPath);
270
+ const summary = await pull(c.ctx, target, c.flags.to, { force: c.flags.force });
271
+ return reportPull(c, target, c.flags.to, summary);
272
+ }
273
+
274
+ async function runSpacePush(c) {
275
+ const dir = c.positionals[2];
276
+ if (!dir) return usage(c, '用法:anytowork space push <本地目录> [--to <路径>]。更多见 anytowork space --help');
277
+ let to = c.flags.to;
278
+ if (to == null) {
279
+ // without --to, go back to where this directory was pulled from
280
+ const state = readState(path.resolve(dir));
281
+ if (!state || state.kind !== 'space') {
282
+ return usage(c, '这个目录不是从空间拉取的,请用 --to <路径> 说明要推送到空间的哪个文件夹(/ 表示整个空间)。');
283
+ }
284
+ to = state.remote;
285
+ }
286
+ const target = spaceTarget(to);
287
+ const summary = await push(c.ctx, target, dir, { note: c.flags.note, tidy: c.flags.tidy, del: c.flags.delete });
288
+ return reportPush(c, target, summary);
289
+ }
290
+
291
+ /**
292
+ * anytowork space <ls|pull|push>
293
+ * @param {object} p { command, positionals, flags, baseUrl, credOpts, fetchImpl, deps?, log, err }
294
+ */
295
+ export async function runSpace(p) {
296
+ const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
297
+ const c = { positionals, flags, json: flags.json, log, err, ctx: makeCtx({ baseUrl, credOpts, fetchImpl, deps: p.deps, err }) };
298
+ return guarded(c, () => {
299
+ if (command === 'space:ls') return runLs(c);
300
+ if (command === 'space:pull') return runSpacePull(c);
301
+ return runSpacePush(c);
302
+ });
303
+ }
304
+
305
+ // ---------------------------------------------------------------- task workspace pull / push / status
306
+
307
+ async function runTaskPull(c) {
308
+ const taskId = c.positionals[3];
309
+ if (!taskId || !c.flags.to) {
310
+ return usage(c, '用法:anytowork task workspace pull <任务编号> --to <本地目录>。更多见 anytowork task workspace --help');
311
+ }
312
+ const target = taskTarget(taskId);
313
+ const summary = await pull(c.ctx, target, c.flags.to, { force: c.flags.force });
314
+ return reportPull(c, target, c.flags.to, summary);
315
+ }
316
+
317
+ async function runTaskPush(c) {
318
+ const taskId = c.positionals[3];
319
+ if (!taskId || !c.flags.from) {
320
+ return usage(c, '用法:anytowork task workspace push <任务编号> --from <本地目录>。更多见 anytowork task workspace --help');
321
+ }
322
+ const target = taskTarget(taskId);
323
+ const summary = await push(c.ctx, target, c.flags.from, { del: c.flags.delete });
324
+ return reportPush(c, target, summary);
325
+ }
326
+
327
+ async function runTaskStatus(c) {
328
+ const taskId = c.positionals[3];
329
+ if (!taskId) return usage(c, '用法:anytowork task workspace status <任务编号>。更多见 anytowork task workspace --help');
330
+ const r = await apiGet(c.ctx, `/api/v1/tasks/${encodeURIComponent(taskId)}/workspace/status`);
331
+ if (c.json) { c.log(JSON.stringify({ ok: true, ...r }, null, 2)); return 0; }
332
+ const changes = r.changes || [];
333
+ const limit = r.max_bytes != null ? `(已记 ${humanSize(r.used_bytes || 0)},上限 ${humanSize(r.max_bytes)})` : '';
334
+ c.log(changes.length === 0 ? `这一单还没有记下任何改动${limit}。` : `这一单已记下 ${changes.length} 处改动${limit}:`);
335
+ for (const ch of changes) {
336
+ c.log(` ${OP_LABEL[ch.op] || ch.op} ${ch.path} ${ch.op === 'delete' ? '' : humanSize(ch.size_bytes || 0)} ${dayOf(ch.pushed_at)}`);
337
+ }
338
+ if (r.frozen_round != null) c.log(`第 ${r.frozen_round} 轮交付时记下的改动已经定格,发起方验收看到的就是那一份。`);
339
+ return 0;
340
+ }
341
+
342
+ /**
343
+ * anytowork task workspace <pull|push|status> (positionals: task workspace <sub> <task_id>)
344
+ * @param {object} p { command, positionals, flags, baseUrl, credOpts, fetchImpl, deps?, log, err }
345
+ */
346
+ export async function runTaskWorkspace(p) {
347
+ const { command, positionals, flags, baseUrl, credOpts, fetchImpl, log, err } = p;
348
+ const c = { positionals, flags, json: flags.json, log, err, ctx: makeCtx({ baseUrl, credOpts, fetchImpl, deps: p.deps, err }) };
349
+ return guarded(c, () => {
350
+ if (command === 'task:workspace-pull') return runTaskPull(c);
351
+ if (command === 'task:workspace-push') return runTaskPush(c);
352
+ return runTaskStatus(c);
353
+ });
354
+ }
355
+
356
+ /** Command names that belong to this file (used by the dispatcher). */
357
+ export function isSpaceCommand(command) {
358
+ return typeof command === 'string' && command.startsWith('space:');
359
+ }
360
+
361
+ export function isTaskWorkspaceCommand(command) {
362
+ return typeof command === 'string' && command.startsWith('task:workspace-');
363
+ }
@@ -0,0 +1,607 @@
1
+ // Sync engine for the space and for a task's workspace (v0.31, CLI 0.12.21).
2
+ //
3
+ // Plain words: the Agent pulls a folder to its own computer, edits it with its own tools, and pushes back only what
4
+ // changed. The same engine serves both sides, only the addresses differ:
5
+ // * the owner's own Agent -> /space/sync/* (changes go straight into the owner's space)
6
+ // * the seller's Agent -> /tasks/{id}/workspace/* (changes are only recorded on the task until accepted)
7
+ //
8
+ // How "only the changes" works: every file is compared by its content fingerprint (sha256). A small state file
9
+ // (STATE_FILE) is written into the local folder on every pull, listing the fingerprint each file had when it was
10
+ // pulled. A push sends only files whose fingerprint differs from that list (plus brand new ones), and tells the
11
+ // platform which version each edit started from (base_sha256) so the platform can spot a clash with someone else's
12
+ // later edit. Nothing is ever pushed for files that did not change - that would overwrite newer remote content.
13
+ import fs from 'node:fs';
14
+ import fsp from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { createHash } from 'node:crypto';
17
+ import { authedGetWithRefresh, authedPostWithRefresh } from './api.js';
18
+ import { uploadMultipart, MULTIPART_THRESHOLD } from './multipart-upload.js';
19
+ import { atomicWriteFile } from './atomic-write.js';
20
+ import { mimeFor } from './file-deliver.js';
21
+ import { flatBodyOf } from './error-map.js';
22
+ import { describeStorageFailure, classifyStorageFailure } from './storage-put.js';
23
+
24
+ /** Hidden file inside the local folder that remembers what each file looked like when it was pulled. */
25
+ export const STATE_FILE = '.anytowork-sync.json';
26
+ const PART_SUFFIX = '.anytowork-part';
27
+ const SIGN_BATCH = 50; // download addresses asked for at once (the platform allows up to 200)
28
+ const DOWNLOAD_CONCURRENCY = 3;
29
+ const UPLOAD_CONCURRENCY = 2;
30
+ const HASH_CONCURRENCY = 3;
31
+ const DOWNLOAD_RETRIES = 3;
32
+ const MAX_RATE_RETRIES = 5; // how many times a 429 is waited out before giving up
33
+ const MAX_WAIT_S = 120; // longest single wait on a 429
34
+ const DEFAULT_WAIT_S = 5; // used when the platform does not say how long to wait
35
+
36
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ /** A problem found on this machine or in the transfer itself (not a platform refusal). `exit` is the process code. */
39
+ export class SyncError extends Error {
40
+ constructor(message, { code = 'sync_failed', exit = 1, extra = {} } = {}) {
41
+ super(message);
42
+ this.name = 'SyncError';
43
+ this.code = code;
44
+ this.exit = exit;
45
+ this.extra = extra;
46
+ }
47
+ }
48
+
49
+ // ---------------------------------------------------------------- platform calls (with 429 back-off)
50
+
51
+ /** Seconds the platform asked us to wait: the body's retry_after, else the Retry-After header, else a default. */
52
+ export function retryAfterSeconds(e) {
53
+ const body = flatBodyOf(e);
54
+ const n = Number(body.retry_after != null ? body.retry_after : e && e.retryAfter);
55
+ return Number.isFinite(n) && n > 0 ? Math.min(MAX_WAIT_S, Math.ceil(n)) : DEFAULT_WAIT_S;
56
+ }
57
+
58
+ /**
59
+ * Run one platform call; on 429 wait for as long as the platform said (retry_after) and try again.
60
+ * ctx: { sleep?, progress? }. Any other error, or too many 429s in a row, is thrown as it is.
61
+ */
62
+ export async function withBackoff(ctx, fn) {
63
+ const sleep = ctx.sleep || defaultSleep;
64
+ for (let attempt = 0; ; attempt += 1) {
65
+ try {
66
+ return await fn();
67
+ } catch (e) {
68
+ if (!e || e.status !== 429 || attempt >= MAX_RATE_RETRIES) throw e;
69
+ const wait = retryAfterSeconds(e);
70
+ if (ctx.progress) ctx.progress(`请求太频繁,${wait} 秒后自动重试…`);
71
+ await sleep(wait * 1000);
72
+ }
73
+ }
74
+ }
75
+
76
+ function qs(params) {
77
+ const sp = new URLSearchParams();
78
+ for (const [k, v] of Object.entries(params || {})) if (v != null && v !== '') sp.set(k, String(v));
79
+ const s = sp.toString();
80
+ return s ? `?${s}` : '';
81
+ }
82
+
83
+ export function apiGet(ctx, pathAndQuery) {
84
+ return withBackoff(ctx, () => authedGetWithRefresh(ctx.baseUrl, pathAndQuery, ctx.credOpts, { fetchImpl: ctx.fetchImpl }));
85
+ }
86
+
87
+ export function apiPost(ctx, pathStr, body) {
88
+ return withBackoff(ctx, () => authedPostWithRefresh(ctx.baseUrl, pathStr, ctx.credOpts, body, null, { fetchImpl: ctx.fetchImpl }));
89
+ }
90
+
91
+ /** The fetch used for the object storage (signed addresses carry their own credentials, no login header). */
92
+ function storageFetch(ctx) {
93
+ return ctx.putFetch || ctx.fetchImpl || globalThis.fetch;
94
+ }
95
+
96
+ // ---------------------------------------------------------------- what the user sees when the platform says no
97
+
98
+ const HINTS = {
99
+ space_full: () => ['本地改动没有丢。请告诉主人去空间清理历史版本或删掉不用的文件,清理后重新运行同一条命令。'],
100
+ download_quota: (v) => [
101
+ `主人本月的下载额度已用完${v.resume_on ? `,${v.resume_on} 恢复` : ''}。请告诉主人;已经拉到本地的文件不受影响,恢复后重新运行同一条命令。`,
102
+ ],
103
+ workspace_push_too_large: () => ['已经记下的改动不受影响。请减少这次要推送的内容后重新运行同一条命令。'],
104
+ workspace_push_closed: () => ['这单现在不在进行中(已交付待验收期间只能拉取,不能推送)。对方请你改稿后才能再推送。'],
105
+ workspace_window_closed: () => ['这单的工作空间只在选定你接单之后、任务结束之前可以访问。'],
106
+ not_task_seller: () => ['只有这单当前的接单方可以访问它的工作空间。'],
107
+ push_expired: () => ['这次推送已过期(超过 24 小时没有提交),本地改动没有丢,重新运行同一条命令即可。'],
108
+ push_incomplete: (v) => [
109
+ `有文件没传完,这次的改动没有提交:${(v.paths || []).join('、')}`,
110
+ '本地改动没有丢,重新运行同一条命令即可。',
111
+ ],
112
+ rate_limit_exceeded: () => ['已按平台要求自动等待重试,仍然太频繁。请稍后重新运行同一条命令。'],
113
+ };
114
+
115
+ /**
116
+ * Turn a platform refusal into lines for the screen: the platform's own words first (unchanged), then at most one
117
+ * short line saying what to do. Returns null for errors that are not a platform refusal with a stable code.
118
+ */
119
+ export function describeApiError(e) {
120
+ const body = flatBodyOf(e);
121
+ const code = typeof body.error === 'string' ? body.error : null;
122
+ if (!code) return null;
123
+ const vars = body.vars && typeof body.vars === 'object' ? { ...body, ...body.vars } : body;
124
+ const lines = [`${body.message || code}【${code}】`];
125
+ if (HINTS[code]) for (const h of HINTS[code](vars)) lines.push(` ${h}`);
126
+ return { code, message: body.message || '', lines, body };
127
+ }
128
+
129
+ // ---------------------------------------------------------------- local files
130
+
131
+ export function normalizeRemotePath(p) {
132
+ return String(p == null ? '' : p).trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').normalize('NFC');
133
+ }
134
+
135
+ /** Join a remote relative path onto a local folder; refuses anything that would land outside it. */
136
+ export function safeJoin(root, rel) {
137
+ const parts = String(rel).split('/');
138
+ if (!rel || path.isAbsolute(rel) || parts.some((s) => s === '..' || s === '' )) return null;
139
+ const abs = path.join(root, ...parts);
140
+ return abs.startsWith(root + path.sep) ? abs : null;
141
+ }
142
+
143
+ export async function hashFile(abs) {
144
+ const h = createHash('sha256');
145
+ await new Promise((resolve, reject) => {
146
+ const s = fs.createReadStream(abs);
147
+ s.on('data', (c) => h.update(c));
148
+ s.on('error', reject);
149
+ s.on('end', resolve);
150
+ });
151
+ return h.digest('hex');
152
+ }
153
+
154
+ /** Run `worker` over `items` with at most n at once; the first failure stops handing out new items and is thrown. */
155
+ export async function pool(items, n, worker) {
156
+ const queue = items.slice();
157
+ let failure = null;
158
+ const run = async () => {
159
+ while (!failure) {
160
+ const item = queue.shift();
161
+ if (item === undefined) return;
162
+ try { await worker(item); } catch (e) { failure = e; return; }
163
+ }
164
+ };
165
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, run));
166
+ if (failure) throw failure;
167
+ }
168
+
169
+ /**
170
+ * Walk a local folder. Returns the regular files (with absolute path and size), the folders that hold nothing, and
171
+ * what was left out (symlinks, sockets...). Names are compared in NFC because the platform stores NFC.
172
+ */
173
+ export async function scanLocal(root) {
174
+ const files = new Map();
175
+ const emptyFolders = [];
176
+ const skipped = [];
177
+ async function walk(absDir, relDir) {
178
+ const entries = await fsp.readdir(absDir, { withFileTypes: true });
179
+ let seen = 0;
180
+ for (const ent of entries) {
181
+ if (ent.name === STATE_FILE || ent.name.endsWith(PART_SUFFIX)) continue;
182
+ const rel = (relDir ? `${relDir}/${ent.name}` : ent.name).normalize('NFC');
183
+ const abs = path.join(absDir, ent.name);
184
+ if (ent.isSymbolicLink()) { skipped.push({ path: rel, reason: 'symlink' }); continue; }
185
+ if (ent.isDirectory()) { seen += 1; await walk(abs, rel); continue; }
186
+ if (ent.isFile()) {
187
+ seen += 1;
188
+ const st = await fsp.stat(abs);
189
+ files.set(rel, { abs, size: st.size });
190
+ continue;
191
+ }
192
+ skipped.push({ path: rel, reason: 'special' });
193
+ }
194
+ if (seen === 0 && relDir) emptyFolders.push(relDir.normalize('NFC'));
195
+ }
196
+ await walk(root, '');
197
+ return { files, emptyFolders, skipped };
198
+ }
199
+
200
+ // ---------------------------------------------------------------- state file
201
+
202
+ /** What the last pull / push left in the folder; null when there is none (or it is unreadable). */
203
+ export function readState(dir) {
204
+ try {
205
+ const s = JSON.parse(fs.readFileSync(path.join(dir, STATE_FILE), 'utf8'));
206
+ if (s && s.version === 1 && s.files && typeof s.files === 'object') return s;
207
+ } catch { /* no state yet */ }
208
+ return null;
209
+ }
210
+
211
+ export function writeState(dir, state) {
212
+ atomicWriteFile(path.join(dir, STATE_FILE), JSON.stringify(state, null, 1), 0o644);
213
+ }
214
+
215
+ /** A folder already tied to one remote place must not be reused for another: that would mix two places up. */
216
+ function checkStateTarget(state, target) {
217
+ if (state && (state.kind !== target.kind || state.remote !== target.remote)) {
218
+ throw new SyncError(
219
+ `这个本地目录已经和${state.kind === 'task' ? '任务' : '空间'}「${state.remote || '/'}」同步过,不能再用来同步「${target.remote || '/'}」。请换一个空目录。`,
220
+ { code: 'dir_in_use', exit: 2 },
221
+ );
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------- where to talk to
226
+
227
+ /** The owner's own space (or a folder of it): used by the owner's Agent. */
228
+ export function spaceTarget(spacePath) {
229
+ const remote = normalizeRemotePath(spacePath);
230
+ return {
231
+ kind: 'space',
232
+ remote,
233
+ label: remote || '空间根目录',
234
+ manifestUrl: (q) => `/api/v1/space/sync/manifest${qs({ path: remote, ...q })}`,
235
+ downloadUrls: '/api/v1/space/sync/download-urls',
236
+ pushUrl: '/api/v1/space/sync/push',
237
+ commitUrl: (pushId) => `/api/v1/space/sync/push/${pushId}/commit`,
238
+ uploadStartUrl: '/api/v1/space/uploads',
239
+ uploadCompleteUrl: (fileId) => `/api/v1/space/uploads/${fileId}/complete`,
240
+ multipartBase: '/api/v1/space/uploads/multipart',
241
+ pushBody: ({ mode, note }) => ({ path: remote, mode, ...(note ? { note } : {}) }),
242
+ };
243
+ }
244
+
245
+ /** The workspace folder of one task: used by the seller's Agent. The folder itself is the root. */
246
+ export function taskTarget(taskId) {
247
+ const base = `/api/v1/tasks/${encodeURIComponent(taskId)}/workspace`;
248
+ return {
249
+ kind: 'task',
250
+ remote: String(taskId),
251
+ label: `任务 ${taskId} 的工作空间`,
252
+ manifestUrl: (q) => `${base}/manifest${qs(q)}`,
253
+ downloadUrls: `${base}/download-urls`,
254
+ pushUrl: `${base}/push`,
255
+ commitUrl: (pushId) => `${base}/push/${pushId}/commit`,
256
+ uploadStartUrl: `${base}/uploads`,
257
+ uploadCompleteUrl: (fileId) => `${base}/uploads/${fileId}/complete`,
258
+ multipartBase: `${base}/uploads/multipart`,
259
+ // the folder is the root, so there is no path to name and no mode / note to give
260
+ pushBody: () => ({ path: '' }),
261
+ };
262
+ }
263
+
264
+ // ---------------------------------------------------------------- manifest
265
+
266
+ /**
267
+ * Read the whole list of files under the target, page by page. Every page after the first carries `at` = the
268
+ * snapshot id of the first page, so a file changed in the middle of reading cannot shift the list.
269
+ */
270
+ export async function fetchManifest(ctx, target) {
271
+ const files = [];
272
+ const folders = new Set();
273
+ let head = null;
274
+ let root = null;
275
+ let cursor = null;
276
+ for (let page = 0; page < 10000; page += 1) {
277
+ const r = await apiGet(ctx, target.manifestUrl({ at: head, cursor }));
278
+ if (head == null) head = r.head_change_id != null ? r.head_change_id : null;
279
+ if (!root) root = r.root || null;
280
+ for (const f of r.files || []) files.push(f);
281
+ for (const d of r.folders || []) folders.add(normalizeRemotePath(d));
282
+ cursor = r.next_cursor || null;
283
+ if (!cursor) break;
284
+ }
285
+ return { files, folders: [...folders], head, root };
286
+ }
287
+
288
+ // ---------------------------------------------------------------- pull
289
+
290
+ /**
291
+ * Fetch one file from its signed address into `abs` (via a part file, so a broken download never leaves a half
292
+ * file under the real name). Checks size and fingerprint; retries on its own. `resign()` gets a fresh address.
293
+ * @returns {Promise<string>} the sha256 of what was written
294
+ */
295
+ async function downloadOne(ctx, url, abs, expect, resign) {
296
+ const fetchFn = storageFetch(ctx);
297
+ const tmp = `${abs}${PART_SUFFIX}`;
298
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
299
+ let lastMsg = '';
300
+ let address = url;
301
+ for (let attempt = 0; attempt <= DOWNLOAD_RETRIES; attempt += 1) {
302
+ try {
303
+ const resp = await fetchFn(address, { method: 'GET' });
304
+ if (!resp.ok) {
305
+ lastMsg = `存储返回 ${resp.status}`;
306
+ if (resp.status === 403 && resign) address = await resign(); // the address went stale: ask for a new one
307
+ } else {
308
+ const hash = createHash('sha256');
309
+ let written = 0;
310
+ const fh = await fsp.open(tmp, 'w');
311
+ try {
312
+ if (resp.body && typeof resp.body[Symbol.asyncIterator] === 'function') {
313
+ for await (const chunk of resp.body) {
314
+ hash.update(chunk);
315
+ written += chunk.length;
316
+ await fh.write(chunk);
317
+ }
318
+ } else {
319
+ const buf = Buffer.from(await resp.arrayBuffer());
320
+ hash.update(buf);
321
+ written = buf.length;
322
+ await fh.write(buf);
323
+ }
324
+ } finally {
325
+ await fh.close();
326
+ }
327
+ const digest = hash.digest('hex');
328
+ if (expect.size != null && written !== expect.size) {
329
+ lastMsg = `大小不对(收到 ${written},应为 ${expect.size})`;
330
+ } else if (expect.sha256 && digest !== expect.sha256) {
331
+ lastMsg = '内容校验没通过';
332
+ } else {
333
+ await fsp.rename(tmp, abs);
334
+ return digest;
335
+ }
336
+ }
337
+ } catch (netErr) {
338
+ lastMsg = `网络错误:${netErr && netErr.message ? netErr.message : netErr}`;
339
+ }
340
+ if (attempt < DOWNLOAD_RETRIES) await (ctx.sleep || defaultSleep)(Math.min(8000, 1000 * 2 ** attempt));
341
+ }
342
+ try { await fsp.unlink(tmp); } catch { /* nothing to clean */ }
343
+ throw new SyncError(lastMsg, { code: 'download_failed' });
344
+ }
345
+
346
+ /**
347
+ * Pull the target into `dir`.
348
+ *
349
+ * What it will and will not touch (so the Agent's own unsent edits are never lost):
350
+ * * a file that is missing locally, or untouched since the last pull, is downloaded when the remote changed;
351
+ * * a file edited locally since the last pull is kept; if the remote changed too it is listed as a clash and
352
+ * only overwritten with `force`;
353
+ * * a folder that was never pulled before: files that already exist there with other content count as clashes.
354
+ * * files the remote no longer has are removed locally only when untouched since the last pull.
355
+ *
356
+ * @returns {Promise<object>} summary: { total, downloaded, upToDate, keptLocal, clashes, removed, notPulled,
357
+ * failed, bytes, headChangeId, quotaStopped }
358
+ */
359
+ export async function pull(ctx, target, dir, { force = false } = {}) {
360
+ const absRoot = path.resolve(dir);
361
+ const state = readState(absRoot);
362
+ checkStateTarget(state, target);
363
+ await fsp.mkdir(absRoot, { recursive: true });
364
+
365
+ const manifest = await fetchManifest(ctx, target);
366
+ const oldFiles = (state && state.files) || {};
367
+ const newFiles = { ...oldFiles };
368
+ const summary = {
369
+ total: manifest.files.length, downloaded: [], upToDate: 0, keptLocal: [], clashes: [], removed: [],
370
+ keptRemoteDeleted: [], notPulled: [], failed: [], bytes: 0, headChangeId: manifest.head, quotaStopped: false,
371
+ };
372
+
373
+ for (const folder of manifest.folders) {
374
+ const abs = safeJoin(absRoot, folder);
375
+ if (abs) await fsp.mkdir(abs, { recursive: true });
376
+ }
377
+
378
+ // 1) decide, file by file, what has to be downloaded
379
+ const todo = [];
380
+ await pool(manifest.files, HASH_CONCURRENCY, async (m) => {
381
+ const rel = normalizeRemotePath(m.path);
382
+ const abs = safeJoin(absRoot, rel);
383
+ if (!abs) { summary.failed.push({ path: m.path, reason: '路径不合法' }); return; }
384
+ let local = null;
385
+ try {
386
+ const st = await fsp.stat(abs);
387
+ if (st.isFile()) local = { size: st.size, sha256: await hashFile(abs) };
388
+ } catch { /* not there */ }
389
+ const base = oldFiles[rel] || null;
390
+ if (!local) { todo.push({ m, rel, abs }); return; }
391
+ const sameAsRemote = m.sha256
392
+ ? local.sha256 === m.sha256
393
+ : Boolean(base && base.blob_id && base.blob_id === m.blob_id && base.sha256 === local.sha256);
394
+ if (sameAsRemote) {
395
+ summary.upToDate += 1;
396
+ newFiles[rel] = { sha256: local.sha256, size: local.size, blob_id: m.blob_id || null };
397
+ return;
398
+ }
399
+ if (force) { todo.push({ m, rel, abs }); return; }
400
+ const touchedLocally = !base || local.sha256 !== base.sha256;
401
+ if (!touchedLocally) { todo.push({ m, rel, abs }); return; } // untouched locally, remote changed
402
+ const remoteMoved = !base || !m.sha256 || m.sha256 !== base.sha256; // did the remote change since the pull?
403
+ if (remoteMoved) summary.clashes.push(rel); else summary.keptLocal.push(rel);
404
+ });
405
+ todo.sort((a, b) => (a.rel < b.rel ? -1 : 1));
406
+
407
+ // 2) download in small batches: addresses are short-lived, so ask for them right before using them
408
+ for (let i = 0; i < todo.length && !summary.quotaStopped; i += SIGN_BATCH) {
409
+ const batch = todo.slice(i, i + SIGN_BATCH);
410
+ const signed = await apiPost(ctx, target.downloadUrls, {
411
+ node_ids: batch.map((x) => x.m.node_id),
412
+ ...(manifest.head != null ? { at: manifest.head } : {}),
413
+ });
414
+ const urls = (signed && signed.urls) || {};
415
+ const ready = [];
416
+ for (const item of batch) {
417
+ const u = urls[item.m.node_id];
418
+ if (u && u.url) ready.push({ ...item, url: u.url });
419
+ else if (u && u.error === 'download_quota') summary.quotaStopped = true;
420
+ else summary.failed.push({ path: item.rel, reason: (u && u.error) || '平台没有给出下载地址' });
421
+ }
422
+ await pool(ready, DOWNLOAD_CONCURRENCY, async (item) => {
423
+ const resign = async () => {
424
+ const again = await apiPost(ctx, target.downloadUrls, {
425
+ node_ids: [item.m.node_id], ...(manifest.head != null ? { at: manifest.head } : {}),
426
+ });
427
+ const u = again && again.urls && again.urls[item.m.node_id];
428
+ if (!u || !u.url) throw new SyncError('重新取下载地址失败', { code: 'download_failed' });
429
+ return u.url;
430
+ };
431
+ try {
432
+ const digest = await downloadOne(ctx, item.url, item.abs, { size: item.m.size_bytes, sha256: item.m.sha256 || null }, resign);
433
+ summary.downloaded.push(item.rel);
434
+ summary.bytes += item.m.size_bytes || 0;
435
+ newFiles[item.rel] = { sha256: digest, size: item.m.size_bytes, blob_id: item.m.blob_id || null };
436
+ if (ctx.progress) ctx.progress(`已拉取 ${summary.downloaded.length}/${todo.length}:${item.rel}`);
437
+ } catch (e) {
438
+ if (!(e instanceof SyncError)) throw e;
439
+ summary.failed.push({ path: item.rel, reason: e.message });
440
+ }
441
+ });
442
+ if (summary.quotaStopped) {
443
+ for (const item of todo.slice(i)) {
444
+ if (!summary.downloaded.includes(item.rel) && !summary.failed.some((f) => f.path === item.rel)) summary.notPulled.push(item.rel);
445
+ }
446
+ }
447
+ }
448
+
449
+ // 3) files the remote no longer has: drop the local copy only if it is still exactly what was pulled
450
+ const remoteSet = new Set(manifest.files.map((m) => normalizeRemotePath(m.path)));
451
+ for (const [rel, base] of Object.entries(oldFiles)) {
452
+ if (remoteSet.has(rel)) continue;
453
+ const abs = safeJoin(absRoot, rel);
454
+ delete newFiles[rel];
455
+ if (!abs) continue;
456
+ try {
457
+ if (await hashFile(abs) === base.sha256) { await fsp.unlink(abs); summary.removed.push(rel); }
458
+ else summary.keptRemoteDeleted.push(rel);
459
+ } catch { /* already gone locally */ }
460
+ }
461
+
462
+ // 4) remember the new starting point. Files that were failed, held back or kept local still carry their old entry
463
+ // (newFiles began as a copy of the old list), so a later push still knows which version they came from.
464
+ writeState(absRoot, {
465
+ version: 1, kind: target.kind, remote: target.remote, head_change_id: manifest.head,
466
+ pulled_at: new Date().toISOString(), files: newFiles, folders: manifest.folders,
467
+ });
468
+ return summary;
469
+ }
470
+
471
+ // ---------------------------------------------------------------- push
472
+
473
+ /** Upload one file the platform asked for (action "upload"): one PUT for small files, parts for big ones. */
474
+ async function uploadOne(ctx, target, action, local, rel, { pushId, sha256 } = {}) {
475
+ const size = local.size;
476
+ // the platform already counted this size when it made the plan: a file that grew or shrank since would not match
477
+ const now = await fsp.stat(local.abs);
478
+ if (now.size !== size) {
479
+ throw new SyncError(`${rel} 在推送过程中被改动了,这次的改动没有提交。请等它改完后重新运行同一条命令。`, { code: 'file_changed' });
480
+ }
481
+ if (!action.file_id) {
482
+ // The plan only says "upload": reserve the file under this push first (POST .../uploads with push_id). That
483
+ // gives its id and, for a single PUT, the signed address. The path is relative to the pushed folder, as in the plan.
484
+ const reserved = await apiPost(ctx, target.uploadStartUrl, {
485
+ parent_id: null, rel_path: rel, name: rel.split('/').pop(), size_bytes: size,
486
+ mime_type: mimeFor(rel), sha256: sha256 || null, push_id: pushId,
487
+ });
488
+ action = { ...action, file_id: reserved.file_id, mode: reserved.mode, signed_put_url: reserved.signed_put_url };
489
+ }
490
+ const makeError = (msg) => new SyncError(msg, { code: 'upload_failed' });
491
+ if (action.mode === 'multipart' || size >= MULTIPART_THRESHOLD) {
492
+ await uploadMultipart({
493
+ baseUrl: ctx.baseUrl, credOpts: ctx.credOpts, fetchImpl: ctx.fetchImpl, putFetch: ctx.putFetch,
494
+ fileId: action.file_id, absPath: local.abs, size, filename: rel, makeError,
495
+ pathBase: target.multipartBase, wrap: (fn) => withBackoff(ctx, fn),
496
+ progress: ctx.progress,
497
+ });
498
+ return;
499
+ }
500
+ const buf = await fsp.readFile(local.abs);
501
+ let resp;
502
+ try {
503
+ resp = await storageFetch(ctx)(action.signed_put_url, { method: 'PUT', headers: { 'Content-Type': mimeFor(rel) }, body: buf });
504
+ } catch (netErr) {
505
+ throw makeError(`上传 ${rel} 失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}。可重新运行同一条命令。`);
506
+ }
507
+ if (!resp.ok) {
508
+ let text = '';
509
+ try { text = await resp.text(); } catch { /* the status alone is enough */ }
510
+ throw makeError(describeStorageFailure(classifyStorageFailure(resp.status, text), rel, resp.status));
511
+ }
512
+ await apiPost(ctx, target.uploadCompleteUrl(action.file_id), {});
513
+ }
514
+
515
+ /**
516
+ * Push the changes in `dir` to the target.
517
+ *
518
+ * Only changed or new files are sent (see the header of this file). Local deletions are sent only with `del`.
519
+ * The platform answers with a plan (skip / reuse / upload per file); only "upload" files travel. The whole push is
520
+ * committed in one step at the end, so a push that breaks half way changes nothing on the platform.
521
+ *
522
+ * @param {object} opts { note?, tidy?, del? }
523
+ * @returns {Promise<object>} summary: { nothing, considered, unchanged, sent, uploaded, reused, skipped, bytes,
524
+ * deletesSent, localDeleted, changes, conflicts, kept, skippedLocal }
525
+ */
526
+ export async function push(ctx, target, dir, { note = null, tidy = false, del = false } = {}) {
527
+ const absRoot = path.resolve(dir);
528
+ let isDir = false;
529
+ try { isDir = (await fsp.stat(absRoot)).isDirectory(); } catch { /* handled below */ }
530
+ if (!isDir) throw new SyncError(`本地目录不存在:${dir}`, { code: 'dir_missing', exit: 2 });
531
+
532
+ const state = readState(absRoot);
533
+ checkStateTarget(state, target);
534
+ const base = (state && state.files) || {};
535
+ const scan = await scanLocal(absRoot);
536
+
537
+ // fingerprints of everything that is here now
538
+ const hashes = new Map();
539
+ await pool([...scan.files.keys()], HASH_CONCURRENCY, async (rel) => {
540
+ hashes.set(rel, await hashFile(scan.files.get(rel).abs));
541
+ });
542
+
543
+ // only what differs from the fingerprint it had when pulled (or is new) is worth sending
544
+ const files = [];
545
+ let unchanged = 0;
546
+ for (const [rel, info] of [...scan.files.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
547
+ const sha = hashes.get(rel);
548
+ const b = base[rel];
549
+ if (b && b.sha256 === sha) { unchanged += 1; continue; }
550
+ files.push({ path: rel, size_bytes: info.size, sha256: sha, base_sha256: b ? b.sha256 : null, mime_type: mimeFor(rel) });
551
+ }
552
+ const gone = Object.keys(base).filter((rel) => !scan.files.has(rel)).sort();
553
+ const deletes = del ? gone.map((rel) => ({ path: rel, base_sha256: base[rel].sha256 })) : [];
554
+ const knownFolders = new Set((state && state.folders) || []);
555
+ const folders = scan.emptyFolders.filter((f) => !knownFolders.has(f));
556
+
557
+ const summary = {
558
+ nothing: false, considered: scan.files.size, unchanged, sent: files.length, uploaded: [], reused: [], skipped: [],
559
+ bytes: 0, deletesSent: deletes.length, localDeleted: del ? [] : gone, changes: [], conflicts: [], kept: [],
560
+ skippedLocal: scan.skipped,
561
+ };
562
+ if (files.length === 0 && deletes.length === 0 && folders.length === 0) {
563
+ summary.nothing = true;
564
+ return summary;
565
+ }
566
+
567
+ // ask for the plan
568
+ const planBody = {
569
+ ...target.pushBody({ mode: tidy ? 'tidy' : 'sync', note }),
570
+ files, deletes, folders,
571
+ };
572
+ const plan = await apiPost(ctx, target.pushUrl, planBody);
573
+ const actions = new Map(((plan && plan.actions) || []).map((a) => [normalizeRemotePath(a.path), a]));
574
+
575
+ // send what the plan says has to travel
576
+ const toUpload = [];
577
+ for (const f of files) {
578
+ const a = actions.get(f.path);
579
+ if (!a || a.action === 'skip') { summary.skipped.push(f.path); continue; }
580
+ if (a.action === 'reuse') { summary.reused.push(f.path); continue; }
581
+ toUpload.push({ f, a });
582
+ }
583
+ await pool(toUpload, UPLOAD_CONCURRENCY, async ({ f, a }) => {
584
+ await uploadOne(ctx, target, a, scan.files.get(f.path), f.path, { pushId: plan.push_id, sha256: f.sha256 });
585
+ summary.uploaded.push(f.path);
586
+ summary.bytes += f.size_bytes;
587
+ if (ctx.progress) ctx.progress(`已上传 ${summary.uploaded.length}/${toUpload.length}:${f.path}`);
588
+ });
589
+
590
+ // one commit = one batch on the platform
591
+ const done = await apiPost(ctx, target.commitUrl(plan.push_id), {});
592
+ summary.changes = (done && done.changes) || [];
593
+ summary.conflicts = (done && done.conflicts) || [];
594
+ summary.kept = (done && done.kept) || [];
595
+
596
+ // remember the new starting point: sent files are now what the platform has; deleted files are gone
597
+ const next = { ...base };
598
+ for (const f of files) next[f.path] = { sha256: f.sha256, size: f.size_bytes, blob_id: null };
599
+ const keptPaths = new Set(summary.kept.map((k) => normalizeRemotePath(k.path)));
600
+ for (const d of deletes) if (!keptPaths.has(d.path)) delete next[d.path];
601
+ writeState(absRoot, {
602
+ version: 1, kind: target.kind, remote: target.remote, head_change_id: state ? state.head_change_id : null,
603
+ pulled_at: state ? state.pulled_at : null, pushed_at: new Date().toISOString(),
604
+ files: next, folders: [...new Set([...knownFolders, ...folders])],
605
+ });
606
+ return summary;
607
+ }