dsh-console 的本地只读接口后端:与 Node 版响应形状逐字段一致
| 依赖 | 版本 | 说明 |
|---|---|---|
| moonbitlang/moonback | 0.8.6 | HTTP 框架(官方组织,非已弃用的 hackwaly 源) |
| moonbitlang/async | 0.22.4 | 异步运行时、文件 IO(async/fs)、socket(async/socket) |
版本为什么不取最新:本机 ~/.moon 挂在只读文件系统上,moon update 无法刷新注册表索引, 索引停留在 2026-10-08,其中可见的最新版本是 moonback 0.8.6 / async 0.22.4(0.8.7 与 0.22.5 尚未进索引)。 只能在这两个版本上开发。要升级,需要一个可写的 MOON_HOME,见下面「构建」一节的说明。
./scripts/gen-build-info.sh # 前端 build.sh 也会自己调一次cd dsh-console/backend
moon check
moon build --target nativecd dsh-console/backend
MOON_HOME="$PWD/.sandbox/moonhome" moon check
MOON_HOME="$PWD/.sandbox/moonhome" moon build --target nativecd dsh-console/backend
./_build/native/debug/build/dsh-console-backend.exe
# 或者:moon run --target native .
# 自测换端口(不占用 5390):
DSH_CONSOLE_BACKEND_PORT=5391 ./_build/native/debug/build/dsh-console-backend.exe
# 显式指定前端构建产物(默认从可执行文件位置上溯找 dist/):
DSH_CONSOLE_DIST=/path/to/dsh-console/dist ./_build/native/debug/build/dsh-console-backend.exeGET http://127.0.0.1:5390/ # dist/index.html
GET http://127.0.0.1:5390/assets/index-xxxxxxxx.js # 构建产物,MIME 按后缀
GET http://127.0.0.1:5390/api/local/runtime
GET http://127.0.0.1:5390/api/local/profiles
GET http://127.0.0.1:5390/api/local/logs?source=host&tail=200
GET http://127.0.0.1:5390/api/local/processes
GET http://127.0.0.1:5390/api/local/instances
GET http://127.0.0.1:5390/api/local/settings
GET http://127.0.0.1:5390/api/local/consolecurl -X POST http://127.0.0.1:5390/api/local/action \
-H 'Content-Type: application/json' -H 'X-DSH-Console: 1' \
-d '{"action":"start-instance","params":{"profile":"web"}}'
curl -X PUT http://127.0.0.1:5390/api/local/settings \
-H 'Content-Type: application/json' -H 'X-DSH-Console: 1' \
-d '{"values":{"instance.basePort":8500,"logs.defaultTail":50}}'| 字段 | 类型 | 口径 |
|---|---|---|
| consoleVersion | string / null | 运行时读仓库根 VERSION 的版本(没有才退回前端 package.json,见下) |
| consolePackageName | string | dsh-console |
| consoleVersionSource | string | 上面那个版本号是怎么拿到的,或为什么没拿到 |
| dshVersion | string | pnpm 全局目录里 @deepseek-ai/dsh 的 package.json version |
| dshPackageName | string | @deepseek-ai/dsh |
| dshPackageDir | string | 命中的安装目录绝对路径 |
| dshSourceLabel | string | 本机已安装 + 找到它的那条策略,形如 本机已安装(顺 PATH 上的 dsh 命令解开包目录;与在跑实例同一份);按你指定的那一份时是 本机已安装(按你指定的版本) |
| dshCandidates | array | 这台机器上装着的 dsh(版本切换那一轮新增):[{ version, name, dir, source, active, running }]。active = 起实例会用的那一份;running = 有在跑实例的入口包就是它 |
| dshPinState | string | auto(没指定)/ pinned(按你指定的那一版)/ pin-missing(指定过,但那份已经不在这台机器上或版本对不上,已回落自动挑) |
| dshPinNote | string | 指定失效时那句人话(「你指定过 X,现在找不到了,这一版按自动挑的 Y 算」);其余空串 |
| backendRuntime | string | moonbit/native:后端自己的运行时(取代原 nodeVersion) |
| backendVersion | string | 后端自身版本,与 moon.mod 的 version 一致 |
| backendPackageName | string | conglinyizhi/dsh-console-backend |
| dshHome | string | $DSH_HOME,无则 ~/.dsh |
| dshHomeSource | string | DSH_HOME 环境变量 / 默认 ~/.dsh |
| dshProfile | string / null | $DSH_PROFILE,未设置给 null |
| platform | string | linux x64 (7.2.9-arch1-1) 形状 |
为什么加 VERSION:后端原先靠前端 package.json 读版本,而前端已经换栈成 MoonBit (不再是 Vue 那套 package.json + src/)。绑定前端实现方式的话,删掉旧前端那一刻 这个读数就会静默失效、关于页变成「未知」。
| source | 文件 |
|---|---|
| host | ~/.dsh/logs/retry-control/host.log |
| audit | ~/.dsh/linxi-guard-audit.jsonl(linxi-guard 的守卫审计) |
| actions | $XDG_STATE_HOME/dsh-console/audit.jsonl(控制台自己的动作审计;主文件读不到时退回最近一份归档)。与 audit 是两个文件:失败提示条那颗「查看日志」落在这里,失败那行的 message 是完整原文 |
| instance:<实例 id> | 该实例 DSH_HOME 下 logs/ 里挑出来的一个可读日志文件(挑法见下) |
| 输入 | 行为 |
|---|---|
| 没给 / 空串 | 缺省取设置 logs.defaultTail(缺省 200) |
| 非数字 / 小于等于 0 | 400 bad-tail(tail 只接受正整数) |
| 大于 1000 | 收敛到 1000,响应里额外带 tailNote(tail 超过上限 1000,按 1000 返回) |
| 层级 | 是什么 |
|---|---|
| 0 | 这条实例自己的日志,且有内容 |
| 1 | host.log,且有内容 |
| 2 | 别的文件,且有内容 |
| 3 | 这条实例自己的日志,但空 |
| 4 | host.log,但空 |
| 5 | 别的文件,且空 |
| code | 含义 |
|---|---|
| port-join | 端口对应的 socket inode 在 /proc/<pid>/fd 里对到了持有进程 |
| port-arg | inode 对不上,退回按 cmdline 的 --port 匹配(证据比 inode 弱) |
| process-gone | 端口仍 LISTEN,但没有可见进程持有该 socket:进程已退出,或不在本 PID 命名空间 / 属于别的用户 |
| fd-unreadable | /proc/<pid>/fd 读不到(权限不足或 hidepid) |
| cmdline-unreadable | 进程在,但 /proc/<pid>/cmdline 读不到 |
| unscanned | 没拿到该端口的 socket inode(/proc/net/tcp(6) 读不到) |
| 字段 | 类型 | 口径 |
|---|---|---|
| webUrl | string / null | 能直接握手的完整地址;读不到就是 null,绝不编 |
| webUrlSource | string | 令牌来自哪份文件的哪一段,或为什么读不到 |
| 字段 | 类型 | 口径 |
|---|---|---|
| state | string | running / fault / starting |
| stateReason | string | 为什么是这个状态;黄能分开说是 b 还是 c |
| profileHealth | object / null | { missingBundles, hasPatch };解析不出 profile 给 null |
| state | 判据 |
|---|---|
| running | 签名探测通过。绿由签名给:profile 有毛病也不把「在跑」说成「故障」,c 的事实照写进 stateReason 与 profileHealth |
| fault | b:端口在听、持有它的进程 cmdline 看起来是 dsh,但签名探测不通过(含探测没打通 / 超时);c:签名没通,且解析出的 profile 缺包 > 0 或没有 cordis.patch.yml。b 与 c 同时成立时以 c 起头(那是根因),b 的事实同句写出 |
| starting | 本控制台刚起(runtime.json 里有 pid 或端口对得上的登记,且进程起不到 start_grace_seconds = 20 秒),端口已在听但签名还没通:可能还在启动,不是坏 |
{"ok":true,"readOnly":true,"readAt":"2026-10-14T10:56:27.361Z","data":{}}{"ok":false,"readOnly":true,"error":{"code":"...","message":"..."}}| 场景 | 状态码 | code |
|---|---|---|
| 非 GET | 405(带 Allow: GET) | method-not-allowed |
| 未知路径 | 404 | not-found |
| 缺 source 参数 | 400 | missing-source |
| source 不在白名单 | 400 | unknown-source |
| tail 非正整数 | 400 | bad-tail |
| 实例 id 不存在 | 200(data 里 readOk:false) | unknown-instance |
| 实例没有可读日志 | 200(data 里 readOk:false) | no-log-file |
| DSH_HOME 不是目录 | 500 | dsh-home-missing |
| 找不到 dsh 安装 | 500 | dsh-package-missing |
| profiles 目录不存在 | 500 | profiles-missing |
| 日志文件读不动 | 200(data 里 readOk:false) | read-failed / instance-read-failed |
| 其它读取异常 | 500 | read-failed |
{"ok":true,"readOnly":false,"readAt":"2026-10-15T07:17:44.588Z","data":{"action":"start-instance","outcome":{}}}
{"ok":false,"readOnly":false,"readAt":"…","error":{"code":"conflict","message":"profile web-dev 已经在跑(PID 278412,端口 8400)…"}}POST /api/local/action
Headers(必须): X-DSH-Console: 1 ;Origin 只能缺省或本机回环(http://127.0.0.1 / http://localhost / http://[::1])
Body: { "action": "<动作名>", "params": { ... } } delete-profile 硬删时另带 "confirm":"DELETE"busy / install-failed / install-incomplete 是补包那一轮(第九轮)加的三个码: 同一只 profile 已经有一个补包在跑(busy,409)、包管理器退出码非 0 或超时(install-failed)、 装完了按 dsh.profile.bundles 重算仍缺(install-incomplete)。后两条的 message 都带日志路径。后来又拆/加了三个(都在补包这一族,message 都带日志路径或直接说清原因):
- packages-not-found(500):跑完仍缺包,且 install 日志里有 registry 说「没这个包」的证据 (ERR_PNPM_FETCH_404 / 404 Not Found / E404)。message 列出缺的包名并说「得改 package.json」—— 这一档重试多少次都没用,下一步是改声明,不是再点一次。
- no-package-manager(500):开跑之前就拦下——这只 profile 选中的包管理器不在 PATH 上。 不拦的话要等满 120 秒超时,而等下去也不会有转机。
- unsupported-manager(500):这只 profile 是 Yarn PnP(目录里有 .pnp.cjs),包不在 node_modules 里, 控制台不代跑,说明里让用它自己的 yarn。
补包用哪个包管理器:先认 package.json 的 packageManager 字段(pnpm@9.1.0 → pnpm), 再按 lock 文件(pnpm-lock.yaml → pnpm、package-lock.json → npm),都没有按 pnpm。
dsh-not-installed 是版本切换那一轮加的第八个码(原契约七码之外)。为什么单开一条: 那是调用方点了一个不是安装的目录,不是读写失败 —— message 里把「这台机器上装着哪几份」都列出来了, 归进 io-error 会把人引去查磁盘。
| 结论 | 出处(helper) | 底层原文 raw 里放什么 |
|---|---|---|
| 建目录失败 | @fsx.ensure_dir_fs | OSError("@fs.mkdir()…") 之类 |
| 写文件失败 | @fsx.write_text_atomic_fs / write_text_fs / write_bytes_fs | OSError("@fs.write()…") 之类 |
| 删文件失败 | @fsx.remove_file_fs | 同上 |
| 删目录失败 | @fsx.remove_tree_fs | 同上 |
| 建软链失败 | @fsx.make_symlink_fs | 同上 |
| 重命名失败 | @fsx.rename_path_fs | <from> -> <to>(OSError(… EXDEV / EACCES …)) |
| 改权限失败 | @fsx.chmod_path_fs | <路径>(OSError(…) |
| 统计失败 | @fsx.measure_tree_fs | 坏在哪一步、哪个目录:列目录失败:<目录>(…) / 读不到文件大小:<文件> |
| 列目录失败 | @json.list_dir_result_fs | <目录>(OSError(…));EACCES / EPERM 那几个词在原文里(/proc 的 hidepid 靠它们认) |
| 读文件状态失败 | @json.file_size_fs | <路径>(OSError(…))(stat 不动);tailread 里 fd.size() 那一处只给结论 |
| 复制失败 | @fsx.copy_tree_fs | 坏在哪一步、哪个文件:列目录失败:<目录>(…) / 读不到 <文件>:(…) / 写文件失败:<文件>(…) |
| 追加失败 | @fsx.append_text_fs | 追加写失败:<路径>(OSError(…)) |
| 读文件失败 | @json.read_text_file_fs | OSError("@fs.read()…") 之类;敏感文件是「拒绝读取敏感文件:<名字>」,raw 留空 |
| 读文件失败(字节) | @fsx.read_bytes_fs / @json.read_some_at_fs | <路径>(OSError(…))(读 fd 那一步失败);文本读不动与字节读不动常不是同一件事,所以分开一句 |
| 打开文件失败 | @json.read_some_at_fs(open 那一步)/ @actions.open_file_error | <路径>(OSError(…))(不存在 / 权限 / 是目录) |
| 打开设备文件失败 | @actions.device_error | OSError(…) (/dev/null 这类拿不到句柄时,子进程没有 stdin/stdout) |
| 起进程失败 | @actions.spawn_error | OSError(…) (ENOENT / EACCES / 资源不够之类) |
| 只读接口内部错误 | 就地(localapi/api.mbt 的 500 体) | 这一层够不到 store,原文落不了盘;体里只给这一句 |
| 读链接失败 | @json.read_link_fs | errno 名(ENOENT 目标不存在…)或「目标超过 4096 字节,被截断」 |
| action | 真实做的事 | 关键边界 |
|---|---|---|
| start-instance | node <dsh包目录>/lib/bin.js --profile <名> --no-open --port <端口>,等它答出 dsh web 签名才算起成功 | 同 profile 已在跑 → conflict(message 里有 pid 与端口);端口真被占 → conflict,message 贴的是这次 bind 报的原话(见下面「端口占用的裁判是 bind」);起不来 → 收掉子进程,message 给「参数被拒」的人话诊断(见「哪些 profile 能起成常驻实例」)或日志尾部 |
| stop-instance | SIGTERM → 等 process.stopGraceMs → 仍在则 SIGKILL | 目标必须在运行登记或进程表里认得出是 dsh,否则 not-found(不误杀别的 pid) |
| restart-instance | 先停后起,端口与 profile 沿用原来的 | 认不出 profile → not-found;老端口上只剩 TIME-WAIT 这类残留时照常起得来(残留不是占用,见下面「端口占用的裁判是 bind」) |
| fork-profile | 递归复制 profile 目录(跳过 node_modules,那是 pnpm 安装产物,提升目录仍解析得到),再给新 profile 写一条"出生证明"备注 | 新名默认 <原名>-dev,重名自动加 -2/-3…;给了 port 就顺手把新 profile 起起来。拷贝口径写明:「已拷 N 个文件,未拷 node_modules;新 profile 会显示缺包,装一次即可」(outcome.filesCopied 是真实拷过去的文件数)。备注见下面「派生出的新 profile 怎么认得出」 |
| rename-profile | 改磁盘上的目录名 | 空名 / 非法字符 / 重名 / 正在运行 都拒 |
| set-profile-note | 写备注:非空 text 原子写进 <profile 目录>/.dsh-note.txt(覆盖旧的);空串 = 删掉那个文件 | 名字先过白名单、再确认解析到 profilesRoot 之内(目录穿越拒 bad-params);目录不存在 not-found;超过 2000 字符 bad-params;写不动 io-error。text 不 trim(前后空格是用户写的),缺 text 键 ≠ 空串:缺键 bad-params |
| open-path | xdg-open <目标> 打开一个枚举目标:kind:"dir"(缺省)= profile 目录,kind:"config" = 它的 cordis.patch.yml。直接 exec(不经 shell、不做任何字符串插值),spawn 完不等它干活 | params 只认 profile 与 kind,多一个键(比如 path)就 bad-params——客户端给不了路径。名字过白名单 + 解析结果必须落在 profilesRoot 之内(两道,与 set-profile-note 同款);目标不存在 not-found;PATH 里没有 xdg-open / 没有图形环境(DISPLAY 与 WAYLAND_DISPLAY 都没设)/ xdg-open 起来就非零退出 → io-error,message 写清是哪一种 |
| archive-profile | 往 archive.json 追加一条 { profile, home, archivedAt }。home 可选(不给 = 控制台自己的 DSH_HOME) | 不动磁盘上的 profile;一份档案的身份是 (home, 名字):同一个 home 的同名 profile 已在归档区 → conflict,同名另一 home 各归各的。同一个 home 里同名的那只正在跑 → conflict(message 里给 pid、端口与 home),别的 home 里同名实例在跑不挡这一份 |
| unarchive-profile | 撤掉那一份(profile + 可选 home)的记录 | 没有这一份 → not-found;同名另一 home 的那条留着不动 |
| delete-profile | safety.deleteToTrash=true 时整目录移进 <状态目录>/trash/<名>-<毫秒>;false 时递归硬删 | 硬删必须带 "confirm":"DELETE",否则 bad-params;正在运行 → conflict |
| set-row-order | 把整份手动顺序原子写进 <状态目录>/order.json:一个行 id 的字符串数组,元素只认 port:<端口号> 与 profile:<名字>@<home>(老形状 profile:<名字> 仍然认,见下面「状态文件」那一节) | 不是字符串 / id 形状不对 / 有重复 / 超 5000 条 → bad-params;写不动 io-error。空数组合法(= 清空顺序表,回到自动排序)。不做增量合并:调用方给的就是要落盘的那一份 —— 增量式接口要后端先读一份当前顺序再算,两次并发拖动会互相覆盖 |
| pin-dsh | 指定用哪一版 dsh 起实例:把 { dir, version, pinnedAt } 原子写进 <状态目录>/dsh-pin.json。只改控制台自己的记录,不碰系统上的 dsh 命令、不碰 pnpm 全局安装 | params 只认 dir,多一个键 bad-params;dir 必须在本机候选里(按真实路径比,.pnpm 里那个写法也认),不在 → dsh-not-installed(message 里列出这台机器上装着哪几份);写不动 io-error。outcome 是 { dir, version, pinPath } |
| unpin-dsh | 撤掉指定,回到自动挑:删掉 <状态目录>/dsh-pin.json | 不收参数(多一个键 bad-params);文件本来就不在也算成功,outcome.removed=false 如实说「不是刚删掉的」。outcome 是 { removed, previousVersion, pinPath } |
| repair-packages | 在 profile 目录里跑包管理器补 node_modules:有 pnpm-lock.yaml → pnpm install --prefer-offline,有 package-lock.json → npm install --prefer-offline,都没有按 pnpm。stdout/stderr 追加进 <状态目录>/logs/repair-<名>-<毫秒>.log;跑完重读 bundles 算剩余缺包 | 120 秒上限(到点 SIGTERM,2 秒后 SIGKILL);同一只 profile 同时只允许一个(第二个 busy);目录不在 / 没有 package.json → not-found;命令退出码非 0 或超时 → install-failed;装完仍缺 → install-incomplete。outcome 带 command / exitCode / durationMs / logPath / missingBefore / missingAfter / remaining(失败那笔审计也带,见下面的 failure_outcome) |
从 <源 profile> 派生 · <本地时间>
<源 profile 原有的备注,原样接在后面>为什么覆盖源备注而不是留着不管:派生是整目录拷贝,源 profile 的备注会跟着拷过来,
而那条备注讲的是源的事("这个是干啥用的"),留在新 profile 里会误导 —— 所以第一行必须是它的来历,
源的那段原文退居第二行起、继续留着供参考。总长按备注上限 2000 字符截断
(第一行短,真正被截掉的只会是尾部)。
备注写失败不算派生失败:目录已经拷好了,为一个备注把整次派生报成失败是假报 ——
outcome 里 noteFile.written=false + noteFile.error 写清原因,顶层 note 也照实说。| 场景 | 改前(basePort=8420) | 改后(basePort=8430) |
|---|---|---|
| basePort 上有 TIME-WAIT、没人听 | 跳过,起在 basePort+1 | 直接用 basePort |
| 指定端口上有 TIME-WAIT | conflict:「端口 8421 已被占用(/proc/net/tcp 里有这条记录)」 | 起得来,实例就在 8421 上 |
| 指定端口上真有 LISTEN | conflict(同上那句猜的) | conflict:「这次 bind 没成,进程写下的原话是 Error: listen EADDRINUSE: address already in use 127.0.0.1:8450」 |
| 带着一条活连接重启(事故形状) | conflict,实例没起来 | 重启成功,TIME-WAIT 与新监听并存 |
| profile 的形状 | 例子 | 起实例的结果 |
|---|---|---|
| 服务型 app(bundles 里有 @deepseek-ai/dsh-web-app) | web、review-dev | 认这两个参数,起得来:等它答出 dsh web 签名,返回 pid / 端口 / 日志路径 |
| 非服务型 app | headless(@deepseek-ai/dsh-headless:答一个任务、打印结果、退出) | 不认:子进程立刻回 error: unknown option '--no-open' 并以退出码 1 结束 |
{"ok":false,"readOnly":false,"readAt":"2026-10-15T07:40:00.000Z","error":{
"code":"spawn-failed",
"message":"profile headless 的 app 不接受服务参数(--no-open),它不是服务型 profile,起不成常驻实例。子进程已经退出(pid 4321,日志 /home/u/.dsh/logs/console-headless-8400.log)。日志里写着:error: unknown option '--no-open'。要跑这种 profile,按它自己的用法跑一次(dsh --profile headless …),别拿它起常驻实例"}}{"ok":false,"readOnly":false,"readAt":"2026-10-16T12:16:00.000Z","error":{
"code":"conflict",
"message":"端口 8450 起不来:这次 bind 没成,进程写下的原话是「Error: listen EADDRINUSE: address already in use 127.0.0.1:8450」。这句话来自这次 bind(errno 与端口都在里面),不是我们按 /proc/net/tcp 猜的。换一个端口,或先腾出这个端口(profile iso,pid 21034,日志 …/logs/console-iso-8450.log,子进程已经退出)"}}{"action":"open-path","params":{"profile":"web","kind":"dir"}}| 情形 | message 里写什么 |
|---|---|
| PATH 里没有 xdg-open | PATH 里找不到 xdg-open(这台机器上没装 xdg-utils),打不开 <路径> |
| 没有图形环境 | 没有图形环境:DISPLAY 与 WAYLAND_DISPLAY 都没设置,xdg-open 打不开 <路径>… |
| xdg-open 起来就非零退出 | xdg-open 起来就退出了(退出码 N),<路径> 没打开;它说:<stderr 尾部> |
为什么不是「app.serve 之后写收尾代码」:moonbitlang/async 在全局取消信号之后会把信号重新抬起来交给 默认处理,实测控制台退出码 143,serve 之后的语句一句都跑不到。因此 detach=false 只能挂在任务组上。
| key | 缺省 | 接到哪(都实测过) |
|---|---|---|
| probe.timeoutMs | 500 | 实例签名探测的单口超时(instances.probe.timeoutMs 回显的就是生效值) |
| probe.concurrency | 8 | 探测并发(instances.probe.concurrency 回显) |
| probe.maxCandidates | 40 | 候选端口上限(instances.candidateLimit 回显,超了照旧截断并带 truncatedNote) |
| instance.basePort | 8400 | 起实例 / 派生时找空闲端口的起点(实测改 8500 后新实例就在 8500);空闲 = 该端口在 /proc/net/tcp(6) 里没有 LISTEN,且运行登记里没有 —— TIME-WAIT 这类残留不算占用 |
| process.detach | true | 起实例是否脱离控制台(见上一节) |
| process.stopGraceMs | 5000 | 停止时 SIGTERM 到 SIGKILL 的宽限(实测 grace=0 → 直接 SIGKILL,sigkillSent:true;grace=5000 → 只发 SIGTERM) |
| logs.defaultTail | 200 | 日志接口不带 tail 参数时的缺省(实测 50 → 回 50 行,500 → 回 500 行;显式 tail 不受影响) |
| ui.archiveOpenByDefault | false | 前端读 GET /api/local/settings 决定归档区默认展开(后端负责存取与回显) |
| ui.sidebarCollapsed | false | 前端读它决定宽屏下侧栏的形态:展开成 190px,或收成 56px 的图标栏。窄屏不看这一项,那一档的侧栏从顶部汉堡滑出、不占位(后端负责存取与回显) |
| safety.deleteToTrash | true | 删除进回收站还是硬删(见上表) |
| profile.ignoreMissingBundles | true | 缺包不参与状态灯(§40):前端读它决定「缺 N 个包」算不算警告。缺省 true = 不报警;关掉之后缺包重新算作黄灯。后端只负责存取与回显,缺包仍然可以在抽屉里装(repair-packages 不受这一项影响) |
| 文件 | 内容 | 写时机 |
|---|---|---|
| runtime.json | 控制台自己起过的实例:{ pid, port, home, profile, startedAt, logPath, detached } | 起实例成功 / 停止 / 重启 |
| archive.json | 归档记录:{ profile, home, archivedAt }(第二轮删掉了 merged;旧文件里带 merged 仍能读,那个键被忽略。没有 home 的旧记录也照读:读成空串,按名字参与判重与取消归档) | 归档 / 取消归档 |
| trash-origins.json | 回收站条目各自的来源 home:{ "<目录名>": "<home>" },恢复按它归位(没有来源的旧条目落回控制台自己的 home,并说出来) | 删除进回收站 / 恢复 / 彻底删除 |
| audit.jsonl | 每个动作一行:{ at, action, params, ok, code, message, outcome },失败那笔另带 raw(原始 OSError 之类;空则整个键都不出现),超阈值轮转(口径见下) | 每个动作(含失败与 403)、以及 settings 的 PUT |
| audit.jsonl 的归档 | .archive/audit-<UTC 时间戳>.jsonl:滚出去的那份原样搬过去(含它自己的说明条),目录里最多留 40 份 | 每次轮转 |
| 常数 | 值 | 含义 |
|---|---|---|
| 滚动阈值 | 8 MiB(8388608 字节) | 文件字节数超过它就滚一次(等于不滚) |
| 保留条数 | 2000 条 | 滚完新文件保留旧文件最近 2000 条,本次要追加的那行接在后面 |
| 归档保留份数 | 40 份 | 归档目录里只留最近 40 份,更老的删掉 |
GET /api/local/console → { ok, readOnly:true, readAt, data:{
stateDir, settingsPath, runtimePath, archivePath, auditPath, orderPath, trashDir,
deleteToTrash, deleteToTrashSource,
runtime:[{ pid, port, home, profile, startedAt, logPath, detached, alive }],
archive:[{ profile, archivedAt }],
order:["port:3080", "profile:web", ...],
lastStarted:[{ id, at, source }],
notes:[] } }| 写的东西 | 路径 | 谁在写 |
|---|---|---|
| 设置 | $XDG_STATE_HOME/dsh-console/settings.json(缺省 ~/.local/state/dsh-console/) | store/settings.mbt |
| 运行登记 | $XDG_STATE_HOME/dsh-console/runtime.json | store/registry.mbt |
| 归档记录 | $XDG_STATE_HOME/dsh-console/archive.json | store/archive.mbt |
| 审计 | $XDG_STATE_HOME/dsh-console/audit.jsonl(追加;超 8 MiB 滚动,归档进同目录 .archive/) | store/audit.mbt |
| 手动顺序 | $XDG_STATE_HOME/dsh-console/order.json(顶层就是字符串数组,元素是 port:<端口号> / profile:<名字>@<home>;老形状 profile:<名字> 仍认;空数组 = 没拖过) | store/order.mbt |
| dsh 版本指定 | $XDG_STATE_HOME/dsh-console/dsh-pin.json({ dir, version, pinnedAt };文件不在 / 读不动 / 字段不全 = 没指定,不报错也不偷偷写) | store/dshpin.mbt(动作在 actions/dsh_version.mbt) |
| 回收站 | $XDG_STATE_HOME/dsh-console/trash/<profile>-<毫秒>/ | actions/profile_fs.mbt(deleteToTrash=true) |
| 回收站来源 | $XDG_STATE_HOME/dsh-console/trash-origins.json | store/trash_origins.mbt |
| profile 目录 | 被动作直接作用的那一个:派生(新建)、重命名(改名)、删除(移走或删掉) | actions/profile_fs.mbt |
| profile 备注 | <profile 目录>/.dsh-note.txt(空串语义 = 删掉它;派生时也写这一个文件) | actions/profile_fs.mbt(set-profile-note 与 fork-profile) |
| 实例日志 | <该实例 DSH_HOME>/logs/console-<profile>-<port>.log | actions/instance.mbt(spawn 时重定向 stdout/stderr) |
| 打开窗口 | 控制台自己不写盘:目标路径交给桌面环境(xdg-open),后面开什么窗口是那个会话的事 | actions/open_path.mbt(第二处建进程处)。它给 xdg-open 的 stderr 落一个一次性捕获文件 <状态目录>/open-path-<毫秒>.log,探测窗过了就读出来、随即删掉(建不了就退回 /dev/null) |
cd dsh-console/backend
# 写调用点与建进程处只在三个文件里:fsx/writeio.mbt、actions/instance.mbt、actions/open_path.mbt
grep -rn '@fs\.write_file\|@fs\.mkdir\|@fs\.rename\|@fs\.remove\|@fs\.rmdir\|@process\.spawn\|spawn_orphan' --include=*.mbt . | grep -v .mooncakes | grep -v _build
# 只读层干净(无输出)
grep -rn 'write_file\|mkdir\|rename\|remove\|rmdir\|spawn' json/*.mbt json/*.cbackend/
├── moon.mod # 模块定义、依赖、native 目标
├── moon.pkg.json # 主包(可执行入口)
├── main.mbt # 起服务、中间件顺序(API 闸门 → 静态 → 路由)、监听地址解析
├── json/ # 共用基础层(本工程唯一的只读文件 IO 入口)
│ ├── encode.mbt # 有序 JSON 编码 + ISO8601 格式化 + ISO 时间取时分秒
│ ├── text.mbt # 字符串/字节工具、级别切词、错误类型
│ ├── fsio.mbt # 只读文件访问入口(含定向读 read_some_at、read_link)
│ ├── fd_link.c # 唯一一处 C:readlink(2) 的 FFI 桥(一次系统调用)
│ ├── tailread.mbt # 反向分块读文件尾部
│ ├── version.mbt # 后端自身版本 + 前端 package.json 的运行时读取
│ ├── env.mbt # /proc/self/environ、HOME 解析
│ └── value.mbt # core Json 的只读取值
├── localapi/
│ ├── api.mbt # 路由分发、查询参数、错误码、方法名、API 优先闸门
│ ├── respond.mbt # 响应外壳与响应头
│ ├── probe.mbt # 单端口只读探测 + 批量并发探测(保序;超时与并发由设置给)
│ └── rawhttp.mbt # 极简 HTTP/1.1 客户端(回环探测专用)
├── fsx/ # 写侧基础层(全工程唯一的写入口)
│ ├── writeio.mbt # 原子写 / 追加 / 建目录 / 改名 / 删树 / 复制树 / 发信号
│ └── proc_signal.c # 唯一另一处 C:kill(2) / kill(pid,0) / getpid(2)
├── store/ # 状态文件($XDG_STATE_HOME/dsh-console)
│ ├── state.mbt # 状态目录与 JSON 文件读取(坏文件给原因)
│ ├── settings.mbt # 十项设置的键、缺省、取值范围、原子落盘
│ ├── registry.mbt # runtime.json:控制台起过的实例
│ ├── archive.mbt # archive.json:归档记录((home, 名字) 为身份)
│ ├── trash_origins.mbt # trash-origins.json:回收站条目各自从哪个 home 删的
│ ├── order.mbt # order.json:手动顺序(行 id 带 home)
│ ├── dshpin.mbt # dsh-pin.json:用哪一版 dsh 起实例的指定记录
│ ├── laststarted.mbt # 「上次启动」表(行 id 同一套)
│ └── audit.mbt # audit.jsonl:动作审计(超阈值滚动 + 归档 + 读侧回退)
├── actions/ # 可写层(唯一入口 POST /api/local/action)
│ ├── guard.mbt # X-DSH-Console + Origin 守卫
│ ├── respond.mbt # 写侧响应外壳与错误码 → 状态码
│ ├── action.mbt # 动作分发、参数解析、审计、GET /api/local/console
│ ├── instance.mbt # 起 / 停 / 重启、端口分配、签名等就绪、进程判活
│ ├── dsh_version.mbt # pin-dsh / unpin-dsh:指定用哪一版 dsh 起实例
│ ├── profile_fs.mbt # 派生 / 重命名 / 删除(回收站或硬删)
│ ├── settings_api.mbt # GET / PUT /api/local/settings
│ └── console_api.mbt # GET /api/local/console
├── dist_static/
│ └── dist_static.mbt # 静态根解析、dist 静态中间件、SPA 回退、解析失败时的 503
├── runtime/runtime.mbt # GET /api/local/runtime
├── profiles/profiles.mbt # GET /api/local/profiles
├── logs/logs.mbt # GET /api/local/logs
├── processes/processes.mbt # GET /api/local/processes
├── processes/procjoin.mbt # 端口 → socket inode → PID → Profile/DSH_HOME/版本/时长
├── instances/instances.mbt # GET /api/local/instances
└── instances/weburl.mbt # 带令牌的访问地址(webUrl / webUrlSource)