dsh-openwrite 0.2.11 → 0.2.14
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 +11 -3
- package/cordis.patch.yml +9 -0
- package/docs/INSTALL.md +11 -1
- package/docs/RELEASE_ACCEPTANCE.md +28 -0
- package/package.json +5 -4
- package/packages/openwrite-bridge/lib/client.d.ts +2 -0
- package/packages/openwrite-bridge/lib/client.js +31 -3
- package/packages/openwrite-bridge/lib/domain.js +4 -2
- package/packages/openwrite-bridge/lib/managed-runtime.d.ts +18 -0
- package/packages/openwrite-bridge/lib/managed-runtime.js +114 -2
- package/packages/studio-panel/lib/client.js +577 -507
- package/packages/studio-panel/lib/client.js.map +1 -1
- package/packages/studio-panel/lib/types/client/workspace-context.d.ts +19 -1
- package/packages/studio-panel/lib/types/client/writing-scope.d.ts +9 -1
- package/plugin.mjs +7 -0
- package/preset-definition.mjs +31 -0
- package/release/licenses/argparse@2.0.1/LICENSE +254 -0
- package/release/licenses/js-yaml@4.3.1/LICENSE +21 -0
- package/release/{openwrite-5.8.2-py3-none-any.whl → openwrite-5.8.5-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +4 -4
- package/release/source.json +21 -3
- package/vendor/dsh-dog/UPSTREAM.md +7 -0
- package/vendor/dsh-dog/lib/client.js +34 -11
- package/vendor/dsh-dog/lib/client.js.map +1 -1
- package/vendor/dsh-dog/lib/index.js +39 -27
- package/vendor/dsh-dog/package.json +6 -3
package/README.md
CHANGED
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
|
|
30
30
|
## 快速安装
|
|
31
31
|
|
|
32
|
-
**0.2.
|
|
32
|
+
**0.2.13 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2**。0.2.0-rc.2 已完成 Web 工作台渲染与 DoG 数据读取验证,Windows Electron 桌面端启动/恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。默认使用 npm `latest` 渠道;商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
|
|
33
33
|
|
|
34
34
|
npm 包和对应 GitHub Release 使用同一份验收产物。也可安装精确版本的 Release 包:
|
|
35
35
|
|
|
36
|
+
**0.2.13 的 GitHub 安装包已发布,npm `latest` 暂时仍为 0.2.12,等待本次发布的 2FA 验证后同步发布。** 0.2.12 不包含 #57 的创作页刷新循环修复;请使用下方已发布的 0.2.13 Release 包。
|
|
37
|
+
|
|
36
38
|
```sh
|
|
37
|
-
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.
|
|
39
|
+
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.13/dsh-openwrite-0.2.13.tgz
|
|
38
40
|
dsh web
|
|
39
41
|
```
|
|
40
42
|
|
|
@@ -64,9 +66,15 @@ dsh web
|
|
|
64
66
|
|
|
65
67
|
### dsh 0.2 桌面端升级
|
|
66
68
|
|
|
69
|
+
`0.2.14` 候选包修复 [#60](https://github.com/LiPu-jpg/Openwrite/issues/60) 和 [#61](https://github.com/LiPu-jpg/Openwrite/issues/61):系统代理下 `NO_PROXY` 含 `[::1]` 不再让所有模型连接测试误报"服务商拒绝了请求"(桥接层消毒 + Core 5.8.5 归类为本地配置错误);Embedding 连接测试的依赖预热、硬超时与收尾兜底,加上桥接层的健康探活与 stderr 落盘,不再出现"后端活着但不服务、只能重启 dsh"的僵尸态。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
|
|
70
|
+
|
|
71
|
+
`0.2.13` 修复 [#57](https://github.com/LiPu-jpg/Openwrite/issues/57) 的创作页刷新循环:批注与版本读取不再作为写操作通知刷新,Core 的只读请求也不再增加工作区变更序号。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
|
|
72
|
+
|
|
73
|
+
`0.2.12` 进一步修复 [#50](https://github.com/LiPu-jpg/Openwrite/issues/50)、[#51](https://github.com/LiPu-jpg/Openwrite/issues/51)、[#52](https://github.com/LiPu-jpg/Openwrite/issues/52) 的预设注册、导航、工作台挂载和 DoG 接口。连续审读按内容修订刷新,避免轮询闪烁([#54](https://github.com/LiPu-jpg/Openwrite/issues/54))。
|
|
74
|
+
|
|
67
75
|
`0.2.11` 修复 [#45](https://github.com/LiPu-jpg/Openwrite/issues/45),适配精确宿主版本 `0.2.0-rc.2`:工作台兼容新的 turn-tail list 槽位,DoG 调试面板使用新的 `sessionStatus`;根包和所有子包的兼容声明同步更新。已验证 0.2.0-rc.2 的 Web 客户端启动、OpenWrite 入口、DoG 面板及其他插件共存;Windows Electron 桌面端恢复链路尚未实机复测。
|
|
68
76
|
|
|
69
|
-
**`dsh-openwrite@0.2.10` 不包含这项修复,请升级到 `0.2.
|
|
77
|
+
**`dsh-openwrite@0.2.10` 不包含这项修复,请升级到 `0.2.13` 的 GitHub Release 包。** 不要通过给旧子包授权来代替升级:`dsh-openwrite` 的版本豁免不会覆盖 `@dsh-novel/studio-panel@<ver>` 和 `@dsh-external/dsh-dog@<ver>`,而旧客户端在 0.2.0-rc.2 上激活失败可能触发桌面端的 profile 恢复。
|
|
70
78
|
|
|
71
79
|
若已发生恢复,先关闭桌面端并备份当前 profile,再从自己的 `cordis.patch.yml.bak-*` 备份恢复 patch,同时恢复原 `package.json` 的 `dsh.profile.bundles` 列表。安装包和作品目录通常仍在;不要用出厂模板覆盖其他插件配置。详细排查见 [安装指南](docs/INSTALL.md#dsh-02-客户端兼容排查)。
|
|
72
80
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
# 0.2's carrier-neutral Connection does not require webServer itself, but its
|
|
2
|
+
# custom RPC channel registrar reads that service from its declaring context.
|
|
3
|
+
# OpenWrite targets Web/Desktop, where the carrier is present; declare it here
|
|
4
|
+
# so authenticated DoG RPC channels can register without changing transport.
|
|
5
|
+
- id: connection
|
|
6
|
+
inject:
|
|
7
|
+
- webRuntime
|
|
8
|
+
- webServer
|
|
9
|
+
|
|
1
10
|
- insert:
|
|
2
11
|
- id: openwrite-bundle
|
|
3
12
|
name: dsh-openwrite
|
package/docs/INSTALL.md
CHANGED
|
@@ -29,6 +29,10 @@ Git 是可选的作品归档能力。未安装 Git 或 Git 初始化失败时,
|
|
|
29
29
|
|
|
30
30
|
dsh 对话模型在宿主中配置。小说生成、评审与测试模型在「任务 → 模型」配置;安装健康检查不调用这些模型。
|
|
31
31
|
|
|
32
|
+
托管模式的模型档案、路由和凭据读取 `$DSH_HOME/openwrite/state/config/`,不读取独立 Core 的 `~/.config/openwrite/`。优先使用上述界面配置;需要迁移时先停机、备份该目录,再核对「模型 → 路由」指向的档案 ID。新建自定义名称的档案后请核对路由,不能只改文件名。不要将配置或凭据提交进作品仓库。
|
|
33
|
+
|
|
34
|
+
0.2.12 的连接测试与生成请求使用同一个已配置的输出 token 上限,推理模型可在该额度内完成思考并返回正文。连接测试会发起真实模型请求;安装与自动验收使用替身,不产生模型费用。
|
|
35
|
+
|
|
32
36
|
## 安装影响
|
|
33
37
|
|
|
34
38
|
本轮原生验收覆盖 macOS 15、Ubuntu 和 Windows GitHub 原生运行器。
|
|
@@ -129,7 +133,13 @@ node scripts/client-boot-smoke.mjs --host-cli=/absolute/path/to/dsh-0.2.0-rc.2/n
|
|
|
129
133
|
node scripts/client-boot-smoke.mjs dsh-openwrite-<version>.tgz --host-cli=/absolute/path/to/dsh-0.2.0-rc.2/node_modules/@deepseek-ai/dsh/lib/bin.js
|
|
130
134
|
```
|
|
131
135
|
|
|
132
|
-
脚本需要 Playwright Chromium,创建临时 Web profile
|
|
136
|
+
脚本需要 Playwright Chromium,创建临时 Web profile,检查无豁免版本闸门、两个客户端加载、真实预设选择、会话导航、三个工作台页签出现和创作视图激活、DoG 面板及其他插件共存,后端就绪与未初始化响应由测试夹具提供,不准备 Python 环境、不调用模型;实际安装与后端动作由独立发布验收覆盖。它验证 Web 客户端兼容性;Windows Electron 的原生启动和恢复流程仍须在对应平台完成发布验收。
|
|
137
|
+
|
|
138
|
+
## 0.2.12 的预设与审读修复
|
|
139
|
+
|
|
140
|
+
0.2.11 仅补齐了客户端启动与版本声明,仍存在 #50–#52 的预设注册和导航断点。0.2.12 在新宿主中注册同一份版本化预设,并保留旧宿主的目录副本;不要通过内联手工补丁或版本豁免替代升级。升级不重绑已有会话的预设,新的作品入口使用对应版本的预设。
|
|
141
|
+
|
|
142
|
+
连续审读在后台工作区轮询和任务失效时保持正文与滚动位置,仅在章节身份/顺序/修订变化或手动刷新时重载。重载期间保留上次正文,失败时显示错误。切换作品会清空旧作品的阅读状态。
|
|
133
143
|
|
|
134
144
|
## 作品信息与推理模型预算
|
|
135
145
|
|
|
@@ -6,8 +6,36 @@
|
|
|
6
6
|
|
|
7
7
|
新建作品的验收请求最多等待 60 秒(原为 15 秒),保留成功耗时和失败诊断;不重试写请求,超时仍失败。此调整容纳慢速运行器,不代表初始化耗时问题已被修复。
|
|
8
8
|
|
|
9
|
+
## 0.2.14 候选包
|
|
10
|
+
|
|
11
|
+
修复 #60 系统代理场景下 `NO_PROXY` 含 `[::1]` 导致所有模型连接测试误报"服务商拒绝了请求":桥接层 `buildChildEnv()` 转发前剥掉带方括号的 IPv6 字面量,Core 5.8.5 把 httpx 客户端构造期异常归类为本地配置错误(HTTP 412),`Studio request failed` 附带 `error.cause`。
|
|
12
|
+
|
|
13
|
+
修复 #61 Embedding 连接测试在请求线程里懒加载 numpy 把后端变成"活着但不服务"的僵尸:Core 5.8.5 启动主线程预热 numpy、后台线程预热 fastembed,`run_embedding_probe()` 硬超时 `timeout_seconds + 30 s`,收尾限时 10 秒超时强制退出;桥接层复用就绪连接前做带超时的 `/api/health` 探活,探活失败的僵尸进程强制终止并自动恢复,子进程 stderr 落盘 `state/logs/backend.log`(末尾 64 KiB)。
|
|
14
|
+
|
|
15
|
+
新增回归覆盖 no_proxy 消毒矩阵、僵尸连接探活恢复、stderr 落盘、错误分类矩阵与探测硬超时;wheel 重打包后全量 RECORD 哈希校验(498 文件)与 manifest 一致性检查通过。完整发布验收正在运行,正式发布将使用通过门禁的同一份 CI 产物。Windows 上的依赖预热与僵尸恢复链路本机未复测,保留 0.2.13 的同一限制。
|
|
16
|
+
|
|
17
|
+
## 0.2.13
|
|
18
|
+
|
|
19
|
+
修复 #57 的只读 POST 失效循环。Core 5.8.4 在 HTTP action 契约声明是否改变公开状态,通过 `X-OpenWrite-Mutated` 统一驱动浏览器代理与 Agent 客户端;旧 Core 的正文版本和批注查询保留兼容处理。保存、批注写入及恢复版本按 manuscript 资源通知,批注读取不再监听无关的 workspace 失效。
|
|
20
|
+
|
|
21
|
+
新增回归覆盖连续只读请求不增加 invalidation revision、SSE 和 Core context_epoch;真实写入仍增加序号。创作页验证重复 workspace 事件不会反复请求批注,实际编辑会刷新,切换作品清空旧批注并忽略迟到响应。[完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37483398522) 全部通过,六组原生平台/Node、源码安装、alpha 兼容及三代宿主客户端共 11 份报告均绑定同一产物。来源提交 `72d19c1a1494da13b5b89d652746e13f13e90e1a`,SHA-256 为 `091dcb7201a88240d8282ef31526d7d88f6d63a390d0813a39f580f9be990a58`。[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.13) 已发布并核对四份附件的校验值;npm 本次发布认证已过期(404),等待重新验证,`latest` 暂时仍为 0.2.12。
|
|
22
|
+
|
|
23
|
+
#50、#57 已随 [PR #59](https://github.com/LiPu-jpg/Openwrite/pull/59) 合并关闭。#50 报告的预设注册、主视图挂载、会话导航及托管模型配置问题已由 0.2.12 修复,并经三代精确 Web 宿主和原生 Windows 安装检查验证。Windows Electron 的实机启动/恢复尚未复测,该限制继续保留;本版不将其计为已通过的验收。
|
|
24
|
+
|
|
25
|
+
## 0.2.12
|
|
26
|
+
|
|
27
|
+
[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.12) 已发布,直接使用[完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37389772004)验收的同一份包。来源提交 a349ea964e43d75f9fc98d1f83354a237bc2ff51,SHA-256 为 2545031292d80689f80d4c0d8c99e007c18ff0c477d662e2813cf67bc876246c。六组原生平台/Node、源码安装、alpha 兼容及三代宿主客户端共 11 份报告全部通过;详见 Release 的 release-acceptance.json。npm `dsh-openwrite@0.2.12` 和 GitHub Release 均已发布,`latest` 已更新到 0.2.12;从公共 npm registry 无认证重新下载后,SHA-256 与上述 CI / Release 包一致。合并后的[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37390503895)也已通过。
|
|
28
|
+
|
|
29
|
+
本版修复 #50–#52 的 0.2 宿主兼容问题及 #54 阅读模式刷新闪烁,纳入 #53 的导航和会话作用域改动,并保留旧宿主的预设插件。
|
|
30
|
+
|
|
31
|
+
- Core 5.8.3:模型连接测试使用所选配置的输出预算;108 项 Core 回归通过。
|
|
32
|
+
- 本地 297 项组件测试、50 项 DoG 测试通过。三代精确宿主的浏览器流程纳入正式发布门禁:选择目录、预设选择、会话导航、工作台标签、点击创作后实际渲染、DoG 面板与插件共存。该客户端检查使用后端就绪及未初始化响应替身;原生后端安装和作品初始化由独立平台任务验证。
|
|
33
|
+
- 完整 CI 和唯一产物校验已通过;合并提交 60949df412f10be004fe6bb5b4fb992dd65c3c60 的 tree 与 CI 来源一致。Windows Electron 桌面端尚未实机复测,真实模型验证未运行。
|
|
34
|
+
|
|
9
35
|
## 0.2.11
|
|
10
36
|
|
|
37
|
+
[Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.11) 使用 [完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37012171411) 验收的同一份包,来源提交为 39454dbcd14ea672c6d9fa25eddea26f8ea71591,SHA-256 为 d11f2a3e9abbd0aaaaa42d5ffb4268e9c192f608236178a48cd112d09e03f1f9。六组原生平台、源码安装及精确 alpha 宿主验收均通过;对该产物另完成精确 0.2.0-rc.2 Web 客户端启动验证。npm `dsh-openwrite@0.2.11` 和 GitHub Release 均已发布,`latest` 已更新到 0.2.11;从公共 npm registry 重新下载后,SHA-256 与上述 Release 包一致。
|
|
38
|
+
|
|
11
39
|
- 修复 #45 的子包兼容声明和两个客户端 API 差异,增加初始化异常隔离、回滚与错误诊断;包含 #44 的 minimap CSS 修复。
|
|
12
40
|
- 精确 `0.2.0-rc.2` Web 宿主已验证两个客户端激活、工作台入口、DoG 面板和其他插件共存,无需版本豁免。
|
|
13
41
|
- Core 仍为 5.8.2 / contract 1。正式发布直接使用本版本完整 CI 验收通过的同一份产物,SHA-256、来源提交和原生平台结果以 Release 附件为准。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-openwrite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "OpenWrite 长篇小说领域后端接入 DeepSeek Harness:统一创作 Agent 预设 + 原生创作工作台",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -28,8 +28,7 @@
|
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@deepseek-ai/cordis-plugin-include": "1.0.7",
|
|
31
|
-
"@deepseek-ai/dsh": "0.1.2-rc.1"
|
|
32
|
-
"js-yaml": "4.3.1"
|
|
31
|
+
"@deepseek-ai/dsh": "0.1.2-rc.1"
|
|
33
32
|
},
|
|
34
33
|
"engines": {
|
|
35
34
|
"node": ">=22.19.0"
|
|
@@ -71,6 +70,7 @@
|
|
|
71
70
|
},
|
|
72
71
|
"files": [
|
|
73
72
|
"plugin.mjs",
|
|
73
|
+
"preset-definition.mjs",
|
|
74
74
|
"plugin.d.ts",
|
|
75
75
|
"cordis.patch.yml",
|
|
76
76
|
"packages/studio-panel/vendor",
|
|
@@ -108,7 +108,8 @@
|
|
|
108
108
|
"ajv-formats": "^3.0.1",
|
|
109
109
|
"fflate": "0.8.2",
|
|
110
110
|
"proper-lockfile": "4.1.2",
|
|
111
|
-
"tar": "7.5.7"
|
|
111
|
+
"tar": "7.5.7",
|
|
112
|
+
"js-yaml": "4.3.1"
|
|
112
113
|
},
|
|
113
114
|
"peerDependencies": {
|
|
114
115
|
"@deepseek-ai/cordis": "4.0.2 || ~4.0.4",
|
|
@@ -60,6 +60,8 @@ export interface StudioDownload {
|
|
|
60
60
|
}
|
|
61
61
|
/** Query parameters; array values become repeated `key=v1&key=v2` entries. */
|
|
62
62
|
export type QueryParams = Record<string, string | string[]>;
|
|
63
|
+
/** Legacy Core fallback; newer servers declare mutation semantics in a response header. */
|
|
64
|
+
export declare function mutationAffectsState(path: string, body: unknown, headers?: Headers): boolean;
|
|
63
65
|
export declare class StudioClient {
|
|
64
66
|
private readonly connect;
|
|
65
67
|
private readonly baseUrl;
|
|
@@ -55,6 +55,30 @@ function applyParams(url, params) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Legacy Core fallback; newer servers declare mutation semantics in a response header. */
|
|
59
|
+
export function mutationAffectsState(path, body, headers) {
|
|
60
|
+
const declared = headers?.get('X-OpenWrite-Mutated');
|
|
61
|
+
if (declared === '0' || declared === '1')
|
|
62
|
+
return declared === '1';
|
|
63
|
+
const endpoint = path.split('?')[0]?.replace(/^\/+/, '').replace(/^api\//, '');
|
|
64
|
+
if (endpoint !== 'manuscript-editing')
|
|
65
|
+
return true;
|
|
66
|
+
let payload = body;
|
|
67
|
+
if (Buffer.isBuffer(payload))
|
|
68
|
+
payload = payload.toString('utf8');
|
|
69
|
+
if (typeof payload === 'string') {
|
|
70
|
+
try {
|
|
71
|
+
payload = JSON.parse(payload);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
|
|
78
|
+
return true;
|
|
79
|
+
const action = payload['action'];
|
|
80
|
+
return typeof action !== 'string' || !['versions', 'version', 'compare', 'annotations'].includes(action);
|
|
81
|
+
}
|
|
58
82
|
export class StudioClient {
|
|
59
83
|
connect;
|
|
60
84
|
baseUrl;
|
|
@@ -112,7 +136,8 @@ export class StudioClient {
|
|
|
112
136
|
body: JSON.stringify(body),
|
|
113
137
|
}, signal);
|
|
114
138
|
const result = await this.readJson(response);
|
|
115
|
-
|
|
139
|
+
if (mutationAffectsState(path, body, response.headers))
|
|
140
|
+
this.onMutation?.(path, this.context);
|
|
116
141
|
return result;
|
|
117
142
|
}
|
|
118
143
|
/** PUT a JSON object with the Studio write protocol header. */
|
|
@@ -123,7 +148,8 @@ export class StudioClient {
|
|
|
123
148
|
body: JSON.stringify(body),
|
|
124
149
|
}, signal);
|
|
125
150
|
const result = await this.readJson(response);
|
|
126
|
-
|
|
151
|
+
if (mutationAffectsState(path, body, response.headers))
|
|
152
|
+
this.onMutation?.(path, this.context);
|
|
127
153
|
return result;
|
|
128
154
|
}
|
|
129
155
|
/** GET a file-download endpoint (export), returning the raw bytes and filename. */
|
|
@@ -156,7 +182,9 @@ export class StudioClient {
|
|
|
156
182
|
catch (error) {
|
|
157
183
|
if (signal?.aborted)
|
|
158
184
|
throw error;
|
|
159
|
-
|
|
185
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
186
|
+
const detail = cause instanceof Error ? ` (${cause.message})` : cause === undefined ? '' : ` (${String(cause)})`;
|
|
187
|
+
throw new StudioError(`Studio request failed: ${error instanceof Error ? error.message : String(error)}${detail}`, 0, 'NETWORK_ERROR');
|
|
160
188
|
}
|
|
161
189
|
}
|
|
162
190
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service } from '@deepseek-ai/cordis';
|
|
2
|
-
import { StudioClient, workspaceRootHeaders } from './client.js';
|
|
2
|
+
import { StudioClient, workspaceRootHeaders, mutationAffectsState } from './client.js';
|
|
3
3
|
import { workspaceContextFromExec } from './tools.js';
|
|
4
4
|
export const CONFIG_ROUTE = '/studio-panel/config.json';
|
|
5
5
|
export const API_PROXY_ROUTE = '/studio-panel/api';
|
|
@@ -98,6 +98,8 @@ function resourceForPath(path) {
|
|
|
98
98
|
return 'models';
|
|
99
99
|
if (path.startsWith('/api/research'))
|
|
100
100
|
return 'research';
|
|
101
|
+
if (path.startsWith('/api/manuscript-editing'))
|
|
102
|
+
return 'manuscript';
|
|
101
103
|
if (path.startsWith('/api/revisions'))
|
|
102
104
|
return 'revisions';
|
|
103
105
|
return 'workspace';
|
|
@@ -481,7 +483,7 @@ export class NovelDomainService extends Service {
|
|
|
481
483
|
responseHeaders['content-disposition'] = disposition;
|
|
482
484
|
res.writeHead(upstream.status, responseHeaders);
|
|
483
485
|
res.end(bytes);
|
|
484
|
-
if (isWrite && upstream.ok) {
|
|
486
|
+
if (isWrite && upstream.ok && mutationAffectsState(pathPart, body, upstream.headers)) {
|
|
485
487
|
this.notifyMutation(`/api/${pathPart}`, { workspaceRoot: resolved.root, workspaceId: resolved.id });
|
|
486
488
|
}
|
|
487
489
|
}
|
|
@@ -52,6 +52,14 @@ export declare const DEFAULT_BACKOFF_MS: number[];
|
|
|
52
52
|
export declare function sha256(file: string): Promise<string>;
|
|
53
53
|
/** Diagnostics must not echo provider credentials or authenticated download URLs. */
|
|
54
54
|
export declare function sanitizeDiagnostic(message: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Drop bracketed IPv6 literals (e.g. `[::1]`) from a no_proxy value. DSH's
|
|
57
|
+
* loopback bypass list is written for undici as well as generic consumers, but
|
|
58
|
+
* httpx parses each entry as host:port and fails at client construction with
|
|
59
|
+
* `Invalid port: ':1]'`, so the request never leaves the process. Plain
|
|
60
|
+
* entries such as `::1` stay — httpx handles them fine.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sanitizeNoProxy(value: string): string;
|
|
55
63
|
/** Windows needs SystemRoot/PATH to start; proxy and CA vars stay when set. Credential names never copy. */
|
|
56
64
|
export declare function buildChildEnv(source: NodeJS.ProcessEnv, extra?: Record<string, string | undefined>): NodeJS.ProcessEnv;
|
|
57
65
|
/** Stop only a child we spawned. POSIX groups and Windows trees include grandchildren. */
|
|
@@ -78,6 +86,16 @@ export declare class ManagedRuntime {
|
|
|
78
86
|
status(): RuntimeStatus;
|
|
79
87
|
private update;
|
|
80
88
|
ensure(): Promise<BackendConnection>;
|
|
89
|
+
/**
|
|
90
|
+
* The child process can outlive its listening socket (a stuck request thread
|
|
91
|
+
* during shutdown leaves it alive but refusing connections), and no exit is
|
|
92
|
+
* ever observed in that state, so the auto-restart logic never fires. Probe
|
|
93
|
+
* the cached connection before handing it out; a dead backend is torn down
|
|
94
|
+
* forcefully and pushed through the normal recovery path.
|
|
95
|
+
*/
|
|
96
|
+
private verifyConnection;
|
|
97
|
+
/** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
|
|
98
|
+
private probeHealth;
|
|
81
99
|
cancel(): Promise<void>;
|
|
82
100
|
dispose(): Promise<void>;
|
|
83
101
|
private exitDiagnostic;
|
|
@@ -10,6 +10,47 @@ import { unzipSync } from 'fflate';
|
|
|
10
10
|
import { lock as acquireLock } from 'proper-lockfile';
|
|
11
11
|
export const DEFAULT_RESTART_LIMIT = 5;
|
|
12
12
|
export const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
|
|
13
|
+
/** Budget for the per-request liveness probe of a previously-ready backend. */
|
|
14
|
+
const HEALTH_PROBE_TIMEOUT_MS = 1_500;
|
|
15
|
+
/** The backend stderr log on disk keeps only the newest chunk. */
|
|
16
|
+
const STDERR_LOG_LIMIT = 64 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Keep the managed backend's stderr on disk (last 64 KiB) so a hung or zombie
|
|
19
|
+
* process can be diagnosed after the fact; the in-memory tail alone is lost
|
|
20
|
+
* with the host. Writes are debounced and serialized, and never throw —
|
|
21
|
+
* diagnostics must not break the backend lifecycle.
|
|
22
|
+
*/
|
|
23
|
+
class BackendStderrLog {
|
|
24
|
+
path;
|
|
25
|
+
tail = '';
|
|
26
|
+
timer;
|
|
27
|
+
pending = Promise.resolve();
|
|
28
|
+
constructor(path) {
|
|
29
|
+
this.path = path;
|
|
30
|
+
}
|
|
31
|
+
write(text) {
|
|
32
|
+
this.tail = (this.tail + text).slice(-STDERR_LOG_LIMIT);
|
|
33
|
+
if (this.timer !== undefined)
|
|
34
|
+
return;
|
|
35
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, 200);
|
|
36
|
+
}
|
|
37
|
+
tailText() { return this.tail; }
|
|
38
|
+
flush() {
|
|
39
|
+
if (this.timer !== undefined) {
|
|
40
|
+
clearTimeout(this.timer);
|
|
41
|
+
this.timer = undefined;
|
|
42
|
+
}
|
|
43
|
+
const tail = this.tail;
|
|
44
|
+
this.pending = this.pending.then(async () => {
|
|
45
|
+
try {
|
|
46
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
47
|
+
await writeFile(this.path, tail);
|
|
48
|
+
}
|
|
49
|
+
catch { /* best-effort diagnostics */ }
|
|
50
|
+
});
|
|
51
|
+
return this.pending;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
13
54
|
const CREDENTIAL_ENV = /(?:API[_-]?KEY|ACCESS[_-]?TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTHORIZATION)/i;
|
|
14
55
|
const ALLOWED_ENV = new Set([
|
|
15
56
|
'PATH', 'Path', 'PATHEXT',
|
|
@@ -36,6 +77,17 @@ export function sanitizeDiagnostic(message) {
|
|
|
36
77
|
.replace(/((?:api[_-]?key|authorization|password|secret|token)\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]')
|
|
37
78
|
.replace(/\b[a-f0-9]{32,}\b/gi, '[redacted]');
|
|
38
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Drop bracketed IPv6 literals (e.g. `[::1]`) from a no_proxy value. DSH's
|
|
82
|
+
* loopback bypass list is written for undici as well as generic consumers, but
|
|
83
|
+
* httpx parses each entry as host:port and fails at client construction with
|
|
84
|
+
* `Invalid port: ':1]'`, so the request never leaves the process. Plain
|
|
85
|
+
* entries such as `::1` stay — httpx handles them fine.
|
|
86
|
+
*/
|
|
87
|
+
export function sanitizeNoProxy(value) {
|
|
88
|
+
return value.split(',').map(entry => entry.trim())
|
|
89
|
+
.filter(entry => entry !== '' && !/^\[.*\]$/.test(entry)).join(',');
|
|
90
|
+
}
|
|
39
91
|
/** Windows needs SystemRoot/PATH to start; proxy and CA vars stay when set. Credential names never copy. */
|
|
40
92
|
export function buildChildEnv(source, extra = {}) {
|
|
41
93
|
const env = {};
|
|
@@ -52,6 +104,12 @@ export function buildChildEnv(source, extra = {}) {
|
|
|
52
104
|
continue;
|
|
53
105
|
env[key] = value;
|
|
54
106
|
}
|
|
107
|
+
// The Python backend reads NO_PROXY through httpx; bracketed IPv6 literals
|
|
108
|
+
// break client construction there even though Node's undici accepts them.
|
|
109
|
+
for (const key of Object.keys(env)) {
|
|
110
|
+
if (key.toLowerCase() === 'no_proxy' && typeof env[key] === 'string')
|
|
111
|
+
env[key] = sanitizeNoProxy(env[key]);
|
|
112
|
+
}
|
|
55
113
|
return env;
|
|
56
114
|
}
|
|
57
115
|
/** Stop only a child we spawned. POSIX groups and Windows trees include grandchildren. */
|
|
@@ -119,7 +177,7 @@ export class ManagedRuntime {
|
|
|
119
177
|
if (this.closed)
|
|
120
178
|
return Promise.reject(new Error('OpenWrite 已卸载'));
|
|
121
179
|
if (this.connection)
|
|
122
|
-
return
|
|
180
|
+
return this.verifyConnection(this.connection);
|
|
123
181
|
if (this.pending)
|
|
124
182
|
return this.pending;
|
|
125
183
|
this.allowRevive = true;
|
|
@@ -137,6 +195,52 @@ export class ManagedRuntime {
|
|
|
137
195
|
}).finally(() => { this.pending = undefined; this.controller = undefined; });
|
|
138
196
|
return this.pending;
|
|
139
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* The child process can outlive its listening socket (a stuck request thread
|
|
200
|
+
* during shutdown leaves it alive but refusing connections), and no exit is
|
|
201
|
+
* ever observed in that state, so the auto-restart logic never fires. Probe
|
|
202
|
+
* the cached connection before handing it out; a dead backend is torn down
|
|
203
|
+
* forcefully and pushed through the normal recovery path.
|
|
204
|
+
*/
|
|
205
|
+
async verifyConnection(connection) {
|
|
206
|
+
if (await this.probeHealth(connection)) {
|
|
207
|
+
if (this.closed)
|
|
208
|
+
throw new Error('OpenWrite 已卸载');
|
|
209
|
+
const current = this.connection;
|
|
210
|
+
if (current)
|
|
211
|
+
return current;
|
|
212
|
+
return this.queueRestart();
|
|
213
|
+
}
|
|
214
|
+
if (this.connection !== connection)
|
|
215
|
+
return this.queueRestart();
|
|
216
|
+
this.connection = undefined;
|
|
217
|
+
const child = this.child;
|
|
218
|
+
this.child = undefined;
|
|
219
|
+
if (child)
|
|
220
|
+
await stopOwnedProcess(child).catch(() => { });
|
|
221
|
+
if (this.closed)
|
|
222
|
+
throw new Error('OpenWrite 已卸载');
|
|
223
|
+
if (!this.allowRevive)
|
|
224
|
+
throw new Error('准备已取消,可重试');
|
|
225
|
+
this.update('recovering', '写作后端无响应,正在恢复');
|
|
226
|
+
return this.queueRestart();
|
|
227
|
+
}
|
|
228
|
+
/** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
|
|
229
|
+
async probeHealth(connection) {
|
|
230
|
+
const child = this.child;
|
|
231
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
232
|
+
return false;
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetch(connection.baseUrl + '/api/health', {
|
|
235
|
+
headers: { Authorization: `Bearer ${connection.token}` },
|
|
236
|
+
signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
|
|
237
|
+
});
|
|
238
|
+
return response.ok;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
140
244
|
async cancel() {
|
|
141
245
|
this.allowRevive = false;
|
|
142
246
|
this.controller?.abort();
|
|
@@ -434,8 +538,16 @@ export class ManagedRuntime {
|
|
|
434
538
|
env: buildChildEnv(process.env, { PYTHONNOUSERSITE: '1', PYTHONUTF8: '1' }),
|
|
435
539
|
});
|
|
436
540
|
this.child = child;
|
|
541
|
+
// Mirror stderr to state/logs/backend.log (last 64 KiB): a stuck backend
|
|
542
|
+
// prints nothing and its in-memory tail dies with the host, leaving no way
|
|
543
|
+
// to diagnose "alive but not serving" failures after the fact.
|
|
544
|
+
const stderrLog = new BackendStderrLog(join(this.root, 'state', 'logs', 'backend.log'));
|
|
437
545
|
let stderrTail = '';
|
|
438
|
-
child.stderr?.on('data', bytes => {
|
|
546
|
+
child.stderr?.on('data', bytes => {
|
|
547
|
+
stderrLog.write(String(bytes));
|
|
548
|
+
stderrTail = stderrLog.tailText().slice(-500);
|
|
549
|
+
});
|
|
550
|
+
child.once('exit', () => { void stderrLog.flush(); });
|
|
439
551
|
const lines = createInterface({ input: child.stdout });
|
|
440
552
|
let timer;
|
|
441
553
|
let started = false;
|