@anytowork/cli 0.12.20 → 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 +20 -0
- package/package.json +1 -1
- package/src/cli-parse.js +22 -0
- package/src/format.js +25 -0
- package/src/index.js +29 -0
- package/src/multipart-upload.js +15 -8
- package/src/space-commands.js +363 -0
- package/src/space-sync.js +607 -0
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
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 上报技能清单)
|
|
@@ -269,6 +271,11 @@ export function parseArgs(argv) {
|
|
|
269
271
|
deferDays: null, // task extension request|respond --defer-days <days> (old form, server converts days * 24)
|
|
270
272
|
deferHours: null, // task extension request|respond --defer-hours <hours> (authoritative since the server's v0.24.22)
|
|
271
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
|
|
272
279
|
};
|
|
273
280
|
const positionals = [];
|
|
274
281
|
|
|
@@ -538,6 +545,16 @@ export function parseArgs(argv) {
|
|
|
538
545
|
} else if (a === '--auto-redact') {
|
|
539
546
|
// v0.23.25: task create --auto-redact (boolean switch, off by default)
|
|
540
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;
|
|
541
558
|
} else if (a.startsWith('--')) {
|
|
542
559
|
// 未知开关:暂忽略
|
|
543
560
|
} else {
|
|
@@ -602,6 +619,8 @@ export function parseArgs(argv) {
|
|
|
602
619
|
case 'collab': command = 'task:collab'; break;
|
|
603
620
|
// P19: ask the seller for one more revision round (sub-command is the 3rd positional)
|
|
604
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;
|
|
605
624
|
default: command = 'unknown';
|
|
606
625
|
}
|
|
607
626
|
} else if (c0 === 'capability') {
|
|
@@ -642,6 +661,9 @@ export function parseArgs(argv) {
|
|
|
642
661
|
} else if (c0 === 'discover') {
|
|
643
662
|
// v0.26.60: anytowork discover show <showcase_id>
|
|
644
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');
|
|
645
667
|
} else if (c0 === 'profiles') {
|
|
646
668
|
// 列出本机已登录的 profile(同机多 Agent 总览)
|
|
647
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
|
|
|
@@ -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];
|
package/src/multipart-upload.js
CHANGED
|
@@ -13,14 +13,18 @@ const PART_RETRIES = 4;
|
|
|
13
13
|
|
|
14
14
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|