dsh-dl-server 0.1.0 → 0.2.0
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 +106 -18
- package/README.zh.md +106 -16
- package/lib/domain.js +4 -0
- package/lib/mirrors.js +102 -0
- package/lib/queue.js +800 -591
- package/lib/tools.js +3 -2
- package/lib/transport.js +68 -4
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -3,15 +3,43 @@
|
|
|
3
3
|
English | [中文](README.zh.md)
|
|
4
4
|
|
|
5
5
|
**DLS** — DSH Download Server
|
|
6
|
-
A
|
|
6
|
+
A persistent download queue for DeepSeek Harness.
|
|
7
7
|
|
|
8
|
-
>
|
|
8
|
+
> Downloads should not live inside a session.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
### From the UI (recommended)
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Settings → Plugins → Install a third-party plugin → Add plugin
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Type **`dsh-dl-server@0.1.1`**, confirm, then **restart DSH once**.
|
|
21
|
+
|
|
22
|
+
### From the command line
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
dsh plugin add dsh-dl-server@0.1.1
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **Pin the version.** `0.1.0` declared `@deepseek-ai/dsh-storage-domain` as a regular dependency, so pnpm installed a second copy inside the plugin; the injected service then no longer matched the host and the plugin never mounted (every service stayed `pending`). `0.1.1` moves it to `peerDependencies`.
|
|
29
|
+
>
|
|
30
|
+
> pnpm also caches registry metadata — without an explicit version you may still resolve `0.1.0`.
|
|
31
|
+
|
|
32
|
+
> `dsh` must be on your PATH. The desktop build ships one at
|
|
33
|
+
> `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`; if you have never added it, use the UI path.
|
|
34
|
+
|
|
35
|
+
**Requires DSH 0.2.1-rc.5 or newer.**
|
|
36
|
+
**aria2 is optional** — install it for 16-way segmented downloading, or skip it and the plugin falls back to a single-stream transfer. Same features, less speed.
|
|
9
37
|
|
|
10
38
|
---
|
|
11
39
|
|
|
12
40
|
## Why
|
|
13
41
|
|
|
14
|
-
|
|
42
|
+
Most DSH downloaders are one tool function that blocks until the file lands. They make downloads **fast**; they don't solve three things:
|
|
15
43
|
|
|
16
44
|
- the download dies when the session or the turn ends
|
|
17
45
|
- there is no queue, no pause, no resume
|
|
@@ -19,27 +47,87 @@ Every existing DSH downloader is one tool function that blocks until the file la
|
|
|
19
47
|
|
|
20
48
|
DLS is the **queue layer**, not the transport layer.
|
|
21
49
|
|
|
22
|
-
|
|
50
|
+
```
|
|
51
|
+
Jobs live in DSH, not in a session.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A download started in one session is visible to every other session — progress, destination, everything. But only the session that started it can pause, resume, or cancel it.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## The seven tools
|
|
59
|
+
|
|
60
|
+
| Tool | What it does |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `download_enqueue` | Queue a job, **return immediately** with a job id |
|
|
63
|
+
| `download_status` | Inspect the queue or a single job (`J#1042`) |
|
|
64
|
+
| `download_wait` | Block until a job reaches a terminal state, with a timeout |
|
|
65
|
+
| `download_pause` | Pause, **keeping the bytes already on disk** |
|
|
66
|
+
| `download_resume` | Continue from the last checkpoint |
|
|
67
|
+
| `download_cancel` | Cancel, optionally dropping the partial file |
|
|
68
|
+
| `download_cleanup` | Drop records (the default scope leaves downloaded files alone) |
|
|
23
69
|
|
|
24
|
-
|
|
70
|
+
## New in 0.2.0
|
|
25
71
|
|
|
26
|
-
|
|
|
72
|
+
| | |
|
|
27
73
|
| --- | --- |
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
35
|
-
|
|
74
|
+
| **Mirror expansion** | GitHub Releases / HuggingFace / raw.githubusercontent / PyPI URLs gain a fallback mirror automatically. The original always stays first. |
|
|
75
|
+
| **Mirror cooldown** | Two consecutive failures on one host parks it for 10 minutes instead of retrying it on every job. |
|
|
76
|
+
| **Size-aware concurrency** | Under 1 MB skips aria2 entirely; 1–8 MB runs 4 connections; only 8 MB and up gets 16. |
|
|
77
|
+
| **Session fairness** | When two sessions queue at once they share the slots instead of one of them taking the whole queue. |
|
|
78
|
+
| **Cumulative disk budget** | Enqueue counts what already-queued jobs still need, so a batch cannot quietly fill the disk. |
|
|
79
|
+
| **Failures are no longer silent** | Checkpoint write errors are counted and surfaced in `download_status` under `storage`; an unwritable destination is refused before the transfer starts. |
|
|
80
|
+
|
|
81
|
+
## Example
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
You: download these two PDFs into D:\Papers and tell me when they're done
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The agent calls `download_enqueue` twice and gets `J#10` / `J#11` back, then `download_wait`.
|
|
88
|
+
|
|
89
|
+
Because enqueue returns immediately, it can fire off ten downloads without waiting for any of them.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Measured
|
|
94
|
+
|
|
95
|
+
Same machine, same file:
|
|
96
|
+
|
|
97
|
+
| Setup | Throughput |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| TUNA mirror · single connection | 43.98 MB/s |
|
|
100
|
+
| TUNA mirror · 16 threads (another plugin) | 54.20 MB/s |
|
|
101
|
+
| TUNA mirror · **DLS** | **61.08 MB/s** (saturates a 500 Mbps link) |
|
|
102
|
+
| HuggingFace · single-stream fallback | 1.00 MB/s |
|
|
103
|
+
| HuggingFace · **DLS** | **6.96 MB/s** (7×) |
|
|
104
|
+
|
|
105
|
+
The last row is the point: **on a per-connection-throttled host, segmentation beats bandwidth.**
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Behaviour worth knowing
|
|
110
|
+
|
|
111
|
+
- **Ownership is per session.** Any session on the machine can *see* every job; only the owner can mutate one.
|
|
112
|
+
- **`cleanup(scope="all")` deletes files.** The default `allWithoutFiles` only drops records.
|
|
113
|
+
- **Checkpoints live in DSH storage**, not as lock files dropped into your home directory — the plugin does not depend on `TMPDIR` or the working directory.
|
|
114
|
+
- **When aria2 is unavailable** the transport degrades to a single Node stream. No features lost, some speed lost.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Docs
|
|
119
|
+
|
|
120
|
+
- [SPEC.md](SPEC.md) — the 565-line design doc: comparison with four sibling plugins, what was borrowed from PCL2, and the v1 trade-offs
|
|
121
|
+
- [CONTRIBUTING.md](CONTRIBUTING.md) — layout, how to run the tests, what not to touch
|
|
122
|
+
- [SECURITY.md](SECURITY.md) — how to report a vulnerability, and what counts as one
|
|
36
123
|
|
|
37
|
-
##
|
|
124
|
+
## Roadmap
|
|
38
125
|
|
|
39
126
|
```
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
127
|
+
v1 queue + persistence + seven tools + aria2 transport ← released
|
|
128
|
+
v1.1 disk headroom / silent failures / session fairness / mirrors
|
|
129
|
+
v2 shell interception / torrents / own segmented engine / content addressing
|
|
130
|
+
v3 user panel + callbacks into the agent
|
|
43
131
|
```
|
|
44
132
|
|
|
45
133
|
## License
|
package/README.zh.md
CHANGED
|
@@ -5,13 +5,41 @@
|
|
|
5
5
|
**DLS** — DSH Download Server
|
|
6
6
|
大肥鱼自己的下载服务器插件
|
|
7
7
|
|
|
8
|
-
>
|
|
8
|
+
> 下载不该活在会话里。
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## 装
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
### 方式一:界面(推荐)
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
设置 → 插件 → 安装第三方插件 → 添加插件
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
输入框里填 **`dsh-dl-server@0.1.1`**,确认,然后**重启一次 DSH**。
|
|
21
|
+
|
|
22
|
+
### 方式二:命令行
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
dsh plugin add dsh-dl-server@0.1.1
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **带上 `@0.1.1`。** `0.1.0` 把 `@deepseek-ai/dsh-storage-domain` 声明成了普通依赖,pnpm 会给插件装第二份,服务和宿主对不上、插件挂不住(全部 `pending`)。0.1.1 改成 `peerDependencies` 已修复。
|
|
29
|
+
>
|
|
30
|
+
> 另外 pnpm 会缓存 registry 元数据 —— 不带版本号时可能仍然解析到 `0.1.0`。
|
|
31
|
+
|
|
32
|
+
> `dsh` 需要先在 PATH 里。桌面版自带的那份在
|
|
33
|
+
> `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`,没加过 PATH 的话用方式一。
|
|
34
|
+
|
|
35
|
+
**要求 DSH 0.2.1 或以上。**
|
|
36
|
+
**aria2 可选** —— 装了走 16 线程分段,没装走单流兜底,一样能下,只是慢一些。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 它解决什么
|
|
41
|
+
|
|
42
|
+
现有的 DSH 下载插件几乎都是「一个工具函数 + 阻塞等到文件落地」。它们让下载**变快**,但没解决三件事:
|
|
15
43
|
|
|
16
44
|
- 会话或回合结束,下载就断了
|
|
17
45
|
- 不能排队、不能暂停恢复
|
|
@@ -19,25 +47,87 @@
|
|
|
19
47
|
|
|
20
48
|
DLS 做的是**队列层**,不是传输层。
|
|
21
49
|
|
|
22
|
-
|
|
50
|
+
```
|
|
51
|
+
任务活在 DSH 里,不活在会话里
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
一个会话发起的下载,其他会话都能看见进度和落盘路径;但只有发起它的那个会话能暂停、续传、取消。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 七个工具
|
|
59
|
+
|
|
60
|
+
| 工具 | 作用 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `download_enqueue` | 入队,**立刻返回** jobId,不等下载 |
|
|
63
|
+
| `download_status` | 看队列和单个任务的进度(`J#1042` 这种 id) |
|
|
64
|
+
| `download_wait` | 等某个任务到终态,带超时 |
|
|
65
|
+
| `download_pause` | 暂停,**保留已下字节** |
|
|
66
|
+
| `download_resume` | 从断点接着下 |
|
|
67
|
+
| `download_cancel` | 取消,可选删掉半截文件 |
|
|
68
|
+
| `download_cleanup` | 清记录(默认不动已下载的文件) |
|
|
23
69
|
|
|
24
|
-
|
|
70
|
+
## 0.2.0 新增
|
|
25
71
|
|
|
26
|
-
|
|
|
72
|
+
| 能力 | 说明 |
|
|
27
73
|
| --- | --- |
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
74
|
+
| **镜像表** | GitHub Releases / HuggingFace / raw.githubusercontent / PyPI 的地址自动多带一个镜像,原地址永远排第一 |
|
|
75
|
+
| **镜像冷却** | 同一 host 连续失败两次就冷却 10 分钟,不再每次撞 |
|
|
76
|
+
| **并发分档** | < 1 MB 不进 aria2;1–8 MB 开 4 连接;≥ 8 MB 才 16 |
|
|
77
|
+
| **会话级公平** | 两个会话同时排队时各占一半槽位,一个会话吃不下整条队列 |
|
|
78
|
+
| **磁盘余量累加** | 入队时把「已在队列里还没下完的」一起算进去,批量入队写不满盘 |
|
|
79
|
+
| **失败不再静默** | 断点写失败会计数并通过 `download_status` 的 `storage` 字段暴露;目标目录不可写在传输开始前就拒绝 |
|
|
80
|
+
|
|
81
|
+
## 用它
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
你: 把这两个 PDF 下到 D:\Papers,下完告诉我
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
agent 会调 `download_enqueue` 两次拿到 `J#10` / `J#11`,然后 `download_wait`。
|
|
88
|
+
|
|
89
|
+
**入队就返回**,所以它可以一次发十个,不用等。
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 实测
|
|
94
|
+
|
|
95
|
+
同一台机器,同一个文件:
|
|
96
|
+
|
|
97
|
+
| 场景 | 结果 |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| 清华 TUNA 源 · 单连接 | 43.98 MB/s |
|
|
100
|
+
| 清华 TUNA 源 · 16 线程(其他插件) | 54.20 MB/s |
|
|
101
|
+
| 清华 TUNA 源 · **DLS** | **61.08 MB/s**(跑满 500M 链路) |
|
|
102
|
+
| HuggingFace · 单流兜底 | 1.00 MB/s |
|
|
103
|
+
| HuggingFace · **DLS** | **6.96 MB/s**(7 倍) |
|
|
104
|
+
|
|
105
|
+
最后一个说明问题:**被限速的源上,分段比带宽更值钱。**
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 已知行为(不是 bug)
|
|
110
|
+
|
|
111
|
+
- **归属按会话**:同一台机器上任何会话都能**看见**全部任务,但只有 owner 能改。
|
|
112
|
+
- **`cleanup(scope="all")` 会删文件**;默认的 `allWithoutFiles` 只删记录。
|
|
113
|
+
- **断点落在 DSH 的存储里**,不是自己往用户目录丢锁文件 —— 所以不看 TMPDIR 或 cwd 的脸色。
|
|
114
|
+
- **aria2 不可用时自动降级**到 Node 单流,功能不减,速度降。
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 文档
|
|
119
|
+
|
|
120
|
+
- [SPEC.md](SPEC.md) —— 565 行设计说明,含四个同类插件的对比、从 PCL2 借了什么、v1 的取舍
|
|
121
|
+
- [CONTRIBUTING.md](CONTRIBUTING.md) —— 代码布局、怎么跑测试、哪些地方别乱动
|
|
122
|
+
- [SECURITY.md](SECURITY.md) —— 漏洞怎么报,什么算漏洞什么不算
|
|
34
123
|
|
|
35
|
-
##
|
|
124
|
+
## 路线
|
|
36
125
|
|
|
37
126
|
```
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
127
|
+
v1 队列 + 持久化 + 七个工具 + aria2 传输 ← 已发布
|
|
128
|
+
v1.1 磁盘余量 / 静默失败 / 会话公平 / 镜像表
|
|
129
|
+
v2 拦截钩子 / 种子 / 自研分段 / 内容寻址
|
|
130
|
+
v3 用户面板 + 操作回传
|
|
41
131
|
```
|
|
42
132
|
|
|
43
133
|
## 许可
|
package/lib/domain.js
CHANGED
|
@@ -10,6 +10,10 @@ const JobRecordSchema = z.object({
|
|
|
10
10
|
origin: z.string().optional(),
|
|
11
11
|
|
|
12
12
|
urls: z.array(z.string()).min(1),
|
|
13
|
+
/** Mirrors derived from the original URL at enqueue time, for diagnostics. */
|
|
14
|
+
mirrors: z
|
|
15
|
+
.array(z.object({ from: z.string(), to: z.string(), rule: z.string() }))
|
|
16
|
+
.optional(),
|
|
13
17
|
destPath: z.string(),
|
|
14
18
|
|
|
15
19
|
state: z.enum(['queued', 'running', 'paused', 'completed', 'failed', 'canceled']),
|
package/lib/mirrors.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror expansion for well-known hosts.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: no network, no state. The point is to turn one URL into
|
|
5
|
+
* a small ordered list so the transport can fall back when the origin is slow or
|
|
6
|
+
* blocked — the original URL always stays first.
|
|
7
|
+
*
|
|
8
|
+
* What this deliberately does NOT do: prove that a mirror serves the same bytes.
|
|
9
|
+
* A third-party mirror can return an older build, an HTML error page, or worse.
|
|
10
|
+
* Only `expectSha256` can prove that, and it is checked after the download. The
|
|
11
|
+
* mitigation here is the cooldown in `queue.js`: a host that fails twice stops
|
|
12
|
+
* being tried for a while.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ from: string, to: string, rule: string }} MirrorApplication */
|
|
16
|
+
|
|
17
|
+
const RULES = [
|
|
18
|
+
{
|
|
19
|
+
name: 'huggingface',
|
|
20
|
+
test: (u) => u.hostname === 'huggingface.co',
|
|
21
|
+
rewrite: (u) => {
|
|
22
|
+
const out = new URL(u.href)
|
|
23
|
+
out.hostname = 'hf-mirror.com'
|
|
24
|
+
return out.href
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'github-release',
|
|
29
|
+
test: (u) => u.hostname === 'github.com' && /\/releases\/download\//.test(u.pathname),
|
|
30
|
+
rewrite: (u) => `https://ghproxy.net/${u.href}`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'raw-githubusercontent',
|
|
34
|
+
test: (u) => u.hostname === 'raw.githubusercontent.com',
|
|
35
|
+
rewrite: (u) => {
|
|
36
|
+
const out = new URL(u.href)
|
|
37
|
+
out.hostname = 'raw.gitmirror.com'
|
|
38
|
+
return out.href
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'pythonhosted',
|
|
43
|
+
test: (u) => u.hostname === 'files.pythonhosted.org',
|
|
44
|
+
rewrite: (u) => {
|
|
45
|
+
const out = new URL(u.href)
|
|
46
|
+
out.hostname = 'pypi.tuna.tsinghua.edu.cn'
|
|
47
|
+
return out.href
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/** Hosts we already treat as mirrors; expanding them again would recurse. */
|
|
53
|
+
const ALREADY_MIRROR = new Set(['hf-mirror.com', 'ghproxy.net', 'raw.gitmirror.com', 'pypi.tuna.tsinghua.edu.cn'])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string[]} urls
|
|
57
|
+
* @returns {{ urls: string[], applied: MirrorApplication[] }}
|
|
58
|
+
*/
|
|
59
|
+
export function expandMirrors(urls) {
|
|
60
|
+
const out = []
|
|
61
|
+
const seen = new Set()
|
|
62
|
+
/** @type {MirrorApplication[]} */
|
|
63
|
+
const applied = []
|
|
64
|
+
|
|
65
|
+
const push = (url) => {
|
|
66
|
+
if (seen.has(url)) return
|
|
67
|
+
seen.add(url)
|
|
68
|
+
out.push(url)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const url of urls) {
|
|
72
|
+
push(url)
|
|
73
|
+
|
|
74
|
+
let parsed
|
|
75
|
+
try {
|
|
76
|
+
parsed = new URL(url)
|
|
77
|
+
} catch {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (ALREADY_MIRROR.has(parsed.hostname)) continue
|
|
81
|
+
|
|
82
|
+
for (const rule of RULES) {
|
|
83
|
+
if (!rule.test(parsed)) continue
|
|
84
|
+
const mirror = rule.rewrite(parsed)
|
|
85
|
+
if (seen.has(mirror)) continue
|
|
86
|
+
push(mirror)
|
|
87
|
+
applied.push({ from: url, to: mirror, rule: rule.name })
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { urls: out, applied }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Hostname of a URL, or `undefined` when it does not parse. */
|
|
96
|
+
export function hostOf(url) {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(url).hostname
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
}
|