dsh-dl-server 0.1.1 → 0.3.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 CHANGED
@@ -17,14 +17,18 @@ A persistent download queue for DeepSeek Harness.
17
17
  Settings → Plugins → Install a third-party plugin → Add plugin
18
18
  ```
19
19
 
20
- Type **`dsh-dl-server`**, confirm, then **restart DSH once**.
20
+ Type **`dsh-dl-server@0.1.1`**, confirm, then **restart DSH once**.
21
21
 
22
22
  ### From the command line
23
23
 
24
24
  ```sh
25
- dsh plugin add dsh-dl-server
25
+ dsh plugin add dsh-dl-server@0.1.1
26
26
  ```
27
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
+
28
32
  > `dsh` must be on your PATH. The desktop build ships one at
29
33
  > `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`; if you have never added it, use the UI path.
30
34
 
@@ -63,6 +67,26 @@ A download started in one session is visible to every other session — progress
63
67
  | `download_cancel` | Cancel, optionally dropping the partial file |
64
68
  | `download_cleanup` | Drop records (the default scope leaves downloaded files alone) |
65
69
 
70
+ ## New in 0.2.0
71
+
72
+ | | |
73
+ | --- | --- |
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
+ ## New in 0.3.0
82
+
83
+ | | |
84
+ | --- | --- |
85
+ | **Queue de-duplication** | One destination is never downloaded twice. A finished job is reused while its file is still on disk; a different source claiming the same path fails with `dest-in-use`. |
86
+ | **Mirror self-verification** | Before a mirror is used, it is HEADed once: the length must equal the origin's, and when the origin advertises an ETag the mirror's must match too. |
87
+
88
+ > A mirror is a third party and serves whatever it likes. Length plus ETag catches a cached older build or an HTML error page; it does **not** catch a poisoned copy. Only `sha256` proves content, which is why mirrors are a fallback and not a guarantee.
89
+
66
90
  ## Example
67
91
 
68
92
  ```
@@ -108,12 +132,19 @@ The last row is the point: **on a per-connection-throttled host, segmentation be
108
132
 
109
133
  ## Roadmap
110
134
 
111
- ```
112
- v1 queue + persistence + seven tools + aria2 transport ← released
113
- v1.1 disk headroom / silent failures / session fairness / mirrors
114
- v2 shell interception / torrents / own segmented engine / content addressing
115
- v3 user panel + callbacks into the agent
116
- ```
135
+ The roadmap names product stages (`v1`, `v1.1`, `v1.2`, `v2`); npm versions follow semver. They do not map one-to-one — use this table:
136
+
137
+ | npm | Roadmap | Content | Status |
138
+ | --- | --- | --- | --- |
139
+ | `0.1.x` | v1 | queue + persistence + seven tools + aria2 transport | released |
140
+ | `0.2.x` | v1.1 | mirrors / mirror cooldown / size-aware concurrency / session fairness / disk budget / loud failures | released |
141
+ | `0.3.x` | v1.2 | queue de-duplication / mirror self-verification | planned |
142
+ | `1.0.0` | v2 | shell interception / torrents / own segmented engine / content addressing | planned |
143
+ | `1.1.x` | v3 | user panel + callbacks into the agent | planned |
144
+
145
+ **Why 0.x lasts until v2:** `1.0.0` means "the tool names and config keys will not change incompatibly". Only v2 touches the transport and the tool set, so that is where 1.0 belongs.
146
+
147
+ **You never have to do the conversion yourself** — install the latest and you get the newest milestone.
117
148
 
118
149
  ## License
119
150
 
package/README.zh.md CHANGED
@@ -17,18 +17,22 @@
17
17
  设置 → 插件 → 安装第三方插件 → 添加插件
18
18
  ```
19
19
 
20
- 输入框里填 **`dsh-dl-server`**,确认,然后**重启一次 DSH**。
20
+ 输入框里填 **`dsh-dl-server@0.1.1`**,确认,然后**重启一次 DSH**。
21
21
 
22
22
  ### 方式二:命令行
23
23
 
24
24
  ```sh
25
- dsh plugin add dsh-dl-server
25
+ dsh plugin add dsh-dl-server@0.1.1
26
26
  ```
27
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
+
28
32
  > `dsh` 需要先在 PATH 里。桌面版自带的那份在
29
33
  > `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`,没加过 PATH 的话用方式一。
30
34
 
31
- **要求 DSH 0.2.1-rc.5 或以上。**
35
+ **要求 DSH 0.2.1 或以上。**
32
36
  **aria2 可选** —— 装了走 16 线程分段,没装走单流兜底,一样能下,只是慢一些。
33
37
 
34
38
  ---
@@ -63,6 +67,26 @@ DLS 做的是**队列层**,不是传输层。
63
67
  | `download_cancel` | 取消,可选删掉半截文件 |
64
68
  | `download_cleanup` | 清记录(默认不动已下载的文件) |
65
69
 
70
+ ## 0.2.0 新增
71
+
72
+ | 能力 | 说明 |
73
+ | --- | --- |
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
+ ## 0.3.0 新增
82
+
83
+ | 能力 | 说明 |
84
+ | --- | --- |
85
+ | **队列去重** | 同一个落地路径不会下两份。同源且文件已在盘上时直接复用已完成的任务;不同源抢同一路径会明确报 `dest-in-use` |
86
+ | **镜像自证同源** | 用镜像前先 HEAD 一次,长度必须与原始一致;原始给了 ETag 时镜像的 ETag 也必须相等,否则不用 |
87
+
88
+ > 镜像是第三方,它给什么我们不知道。长度 + ETag 能挡住「缓存了旧版本」「返回了错误页」这类问题,**挡不住投毒** —— 那只有 `sha256` 做得到。所以镜像只是兜底,真要保证内容就对 `sha256`。
89
+
66
90
  ## 用它
67
91
 
68
92
  ```
@@ -108,12 +132,19 @@ agent 会调 `download_enqueue` 两次拿到 `J#10` / `J#11`,然后 `download_
108
132
 
109
133
  ## 路线
110
134
 
111
- ```
112
- v1 队列 + 持久化 + 七个工具 + aria2 传输 ← 已发布
113
- v1.1 磁盘余量 / 静默失败 / 会话公平 / 镜像表
114
- v2 拦截钩子 / 种子 / 自研分段 / 内容寻址
115
- v3 用户面板 + 操作回传
116
- ```
135
+ 路线图用 `v1 / v1.1 / v1.2 / v2` 讲产品阶段,npm 版本按语义化版本走。两者对不上一一对应,查这张表:
136
+
137
+ | npm 版本 | 路线图 | 内容 | 状态 |
138
+ | --- | --- | --- | --- |
139
+ | `0.1.x` | v1 | 队列 + 持久化 + 七个工具 + aria2 传输 | 已发布 |
140
+ | `0.2.x` | v1.1 | 镜像表 / 镜像冷却 / 并发分档 / 会话公平 / 磁盘余量 / 失败显式化 | 已发布 |
141
+ | `0.3.x` | v1.2 | 队列去重 / 镜像自证同源 | 计划 |
142
+ | `1.0.0` | v2 | 拦截 shell 下载 / 种子与磁力 / 自研分段引擎 / 内容寻址 | 计划 |
143
+ | `1.1.x` | v3 | 用户面板 + 操作回传 agent | 计划 |
144
+
145
+ **为什么 0.x 一直拖到 v2:** 1.0.0 在语义化版本里意味着「工具名和配置项从此不破坏性变更」。v2 才会动到传输层和工具集合,在那之前不配叫 1.0。
146
+
147
+ **升级时不用换算**:装最新版就行,`dsh plugin add dsh-dl-server` 永远指向当下最新。
117
148
 
118
149
  ## 许可
119
150
 
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,142 @@
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
+ }
103
+
104
+ /** `W/"abc"` → `abc`; weak validators and quoting differ between servers. */
105
+ export function normalizeEtag(etag) {
106
+ if (typeof etag !== 'string') return ''
107
+ return etag.trim().replace(/^W\//i, '').replace(/^"|"$/g, '').toLowerCase()
108
+ }
109
+
110
+ /**
111
+ * Can `candidate` be accepted as the same bytes the origin would serve?
112
+ *
113
+ * This is a heuristic, not a proof: an ETag is whatever the mirror declares.
114
+ * It catches the common failures — a mirror caching an older build, or answering
115
+ * with an HTML error page — and nothing more. Only `expectSha256` proves content.
116
+ *
117
+ * @param {{ length?: number, etag?: string }} origin
118
+ * @param {{ length?: number, etag?: string }} candidate
119
+ * @returns {{ ok: boolean, reason?: string }}
120
+ */
121
+ export function sameResource(origin, candidate) {
122
+ const originLength = Number(origin?.length ?? -1)
123
+ const candidateLength = Number(candidate?.length ?? -1)
124
+
125
+ // No baseline (the origin probe failed): we cannot prove anything, and the
126
+ // mirror exists precisely for the case where the origin is unreachable.
127
+ if (!(originLength > 0)) return { ok: true, reason: 'origin-unknown' }
128
+
129
+ if (!(candidateLength > 0)) return { ok: false, reason: 'mirror did not report a length' }
130
+ if (candidateLength !== originLength) {
131
+ return { ok: false, reason: `length ${candidateLength} != ${originLength}` }
132
+ }
133
+
134
+ const originEtag = normalizeEtag(origin?.etag)
135
+ if (originEtag) {
136
+ const candidateEtag = normalizeEtag(candidate?.etag)
137
+ if (!candidateEtag) return { ok: false, reason: 'mirror reports no ETag' }
138
+ if (candidateEtag !== originEtag) return { ok: false, reason: 'ETag differs' }
139
+ }
140
+
141
+ return { ok: true }
142
+ }