dsh-foreman 0.1.0 → 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/CHANGELOG.md CHANGED
@@ -1,73 +1,8 @@
1
1
  # Changelog
2
2
 
3
- 本仓库的历史从本项目自己的初始提交开始,**不携带上游 git 历史**(见 README 的「由来与致谢」)。
4
- 下面 `0.2.0` 一节是从代码基线 `dsh-superagent-local` 沿袭下来的、那一份代码相对上游
5
- `dsh-superagent-prototype` 的改动记录,保留它是为了说明基线里已经有什么;**它不是本项目的发布版本**。
3
+ 本仓库是 dsh-foreman(研究方向原型)的本地改造版。下面是相对上游 0.1.0 的改动。
6
4
 
7
- 本项目自己的版本从 `0.1.0` 开始。
8
-
9
- ## 0.1.0 — 2026-11-07
10
-
11
- 首个公开发布。**一个常驻主 loop,把工单派给多台在线设备上各自完整运行的 DSH,账本只有一份。**
12
-
13
- ### 新增:设备与派单
14
-
15
- - **设备注册表**(`src/foreman/devices.ts`):同一份 SQLite 里的 `foreman-device:` 家族,
16
- 令牌只存 SHA-256。加设备、换令牌、吊销都不需要重启主 loop。
17
- CLI:`scripts/add-device.mjs add | list | rotate | revoke`。
18
- - **Selector**(`src/foreman/selector.ts`):纯函数。吊销的、离线的、没有对应项目 root 的设备先出局;
19
- 显式点名或标签要求再收窄;剩下的按在跑数量最少,平局取最近心跳。
20
- - **选择落在 accept 上**:`Ticket.target` 在 accept 时确定,`newAssignment` 复制到
21
- `Assignment.target`。选设备在 `prepareTicket` 缝上,**不在** Executor 里——Executor 没有写库通道。
22
- - **ForemanExecutor**(`src/foreman/executor.ts`):本机目标原样交给 `DshExecutor`;
23
- 远端目标变成一个 `assign` 帧。设备不可达时报 `blocked`,**不伪造 failed,也不悄悄改成本机执行**。
24
- - **Foreman Hub**(`src/foreman/hub.ts` + `ws.ts`):设备主动外连,`ws` 只出现在一个文件里,
25
- 只监听 `127.0.0.1`(对外由隧道终结 TLS)。握手校验令牌与协议版本;吊销在下一帧就生效。
26
- - **文本渲染**(`src/foreman/render.ts`):任务文本在主 loop 侧定稿,**不带任何主 loop 路径**;
27
- 附件走 base64 字节,按编码后大小卡 256 KiB 上限。
28
-
29
- ### 新增:断线与恢复
30
-
31
- - **断线绝不自动重派**。`assignmentInterrupted` 会走 `Tickets.restart` 立刻重开一张,
32
- 而设备可能还在跑——那是「同一件事执行两遍」。超时只转 `blocked`,交给人。
33
- - Hub 重启时,在飞的远端 assignment 一律先转 `blocked`,等设备 `hello` 对齐;
34
- `DshExecutor.resume()` 按 target 跳过远端项。
35
- - 设备重连后先 `hello` 报「我在跑哪些」,Hub 对齐而不是假设。
36
-
37
- ### 新增:设备侧插件(另一个包)
38
-
39
- - [`dsh-foreman-node`](https://www.npmjs.com/package/dsh-foreman-node):连出去、接单、回报三件事。
40
- job 会话是 `job-<assignmentId>`,权限是 **`workspace-write`**(不是主 loop 自己的 `danger-full-access`:
41
- 笔记本前没人,弹审批等于卡死)。
42
- - 设备上注册 `foreman_report` 工具(`progress` / `ready` / `blocked` / `failed`)。
43
- **`dropped` 不允许**:那是判断,设备没有判断权。
44
-
45
- ### 新增:面板
46
-
47
- - 「设置 › 设备」:在线点、在跑数、最近活动、项目名(**不显示设备自己的路径**)、吊销与换令牌。
48
-
49
- ### 新增:飞书群即项目
50
-
51
- - Policy 的 `routing.chats`:群 → 工作区,同时约束设备选择(内网群只选带 `intranet` 标签的设备)。
52
- 映射按 settings 修订号缓存,改了对新到的消息生效。
53
-
54
- ### 新增:迁移
55
-
56
- - `scripts/migrate.mjs`:`plan` / `checkpoint` / `pack` / `verify`。
57
- 必须先 checkpoint——库跑在 WAL 模式,只拷 `.sqlite` 会**静默丢掉**最近的工单。
58
- - [docs/migration.md](docs/migration.md) 五步 runbook。
59
-
60
- ### 依赖
61
-
62
- - 唯一新增的运行时依赖是 `ws@8`。
63
- - `@deepseek-ai/*` 全是 `peerDependencies`,由装了本插件的 DSH 实例提供。
64
-
65
- ---
66
-
67
- ## 0.2.0 — 2026-11-01(上游基线,非本项目发布)
68
-
69
- > 这一节记录的是代码基线 `dsh-superagent-local` 相对上游 `dsh-superagent-prototype` 的改动。
70
- > 本项目的 `0.1.0` 已包含其中全部内容。
5
+ ## 0.2.0 — 2026-11-01
71
6
 
72
7
  ### 新增:需求来自本机操作者
73
8
 
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/cordis.patch.yml CHANGED
@@ -6,11 +6,11 @@
6
6
  name: dsh-foreman
7
7
  - id: dsh-foreman-github
8
8
  name: dsh-foreman/github-watch
9
- # 椋炰功 IM 鏉ユ簮锛氱兢娑堟伅涓庣鑱婅繘宸ュ崟銆傛病鏈夐厤缃満鍣ㄤ汉 CLI 鏃跺畠鑷繁鎶ャ€屾湭璁剧疆鏈哄櫒浜?CLI銆嶏紝
10
- # 涓嶅奖鍝嶅埆鐨勬潵婧愶紝鎵€浠ラ粯璁ゅ紑鐫€锛岃浜鸿兘鐩存帴鍦ㄨ缃噷濉€?
9
+ # 飞书 IM 来源:群消息与私聊进工单。没有配置机器人 CLI 时它自己报「未设置机器人 CLI」,
10
+ # 不影响别的来源,所以默认开着,让人能直接在设置里填。
11
11
  - id: dsh-foreman-feishu
12
12
  name: dsh-foreman/feishu-watch
13
- # 鏈湴 Source锛氭妸鎿嶄綔鑰呭湪涓讳細璇濋噷璇寸殑璇濆綋浣滈渶姹傛潵婧愶紱涓€涓疄渚嬭鐩栨墍鏈夊伐浣滃尯銆?
13
+ # 本地 Source:把操作者在主会话里说的话当作需求来源;一个实例覆盖所有工作区。
14
14
  - id: dsh-foreman-dsh
15
15
  name: dsh-foreman/local-watch
16
16
  # The agent preset of every Executor Session (WORKER_PRESET in src/app/sessions.ts); the main Session has its own, below.
@@ -24,8 +24,8 @@
24
24
  name: '@deepseek-ai/dsh-agent-preset'
25
25
  config:
26
26
  id: dsh-foreman-worker
27
- name: dsh-foreman 鎵ц鍣?
28
- description: dsh-foreman 鐨勬墽琛屽櫒 Session 涓撶敤锛屼笉渚涙墜鍔ㄩ€夋嫨銆傚墠鍙板懡浠よ秴 5 绉掕浆鍏ュ悗鍙帮紝鍙惈鎵ц宸ヤ綔鎵€闇€鐨勫伐鍏枫€?
27
+ name: dsh-foreman 执行器
28
+ description: dsh-foreman 的执行器 Session 专用,不供手动选择。前台命令超 5 秒转入后台,只含执行工作所需的工具。
29
29
  order: 90
30
30
  plugins:
31
31
  - id: persona
@@ -129,8 +129,8 @@
129
129
  name: '@deepseek-ai/dsh-agent-preset'
130
130
  config:
131
131
  id: dsh-foreman-router
132
- name: dsh-foreman 涓?Session
133
- description: dsh-foreman 鐨勪富 Session 涓撶敤锛屼笉渚涙墜鍔ㄩ€夋嫨銆傚墠鍙板懡浠ゅ拰浠诲姟绛夊緟瓒?5 绉掕浆鍏ュ悗鍙帮紝鍙惈璺敱鎵€闇€鐨勫伐鍏枫€?
132
+ name: dsh-foreman 主 Session
133
+ description: dsh-foreman 的主 Session 专用,不供手动选择。前台命令和任务等待超 5 秒转入后台,只含路由所需的工具。
134
134
  order: 91
135
135
  plugins:
136
136
  - id: agent-instructions
package/icon.svg CHANGED
File without changes
package/lib/client.js CHANGED
@@ -4598,7 +4598,18 @@ window.__ModuleLoader__.load({
4598
4598
  devicesOnline: ["在线", "Online"],
4599
4599
  devicesRunning: ["在跑", "Running"],
4600
4600
  devicesEmpty: ["还没有登记任何设备", "No device is registered yet"],
4601
- devicesEmptyHint: ["在服务器上运行 dsh-foreman 的注册命令生成令牌,再把域名、设备 id 与令牌填进设备自己的配置。", "Mint a token with the hub's registration command, then put the address, device id and token into that device's own configuration."],
4601
+ devicesEmptyHint: ["在下面登记一台设备,它会给出三样东西填进那台设备的配置页。", "Register a machine below; it gives you the three values to put on that machine's own configuration page."],
4602
+ devicesAdd: ["登记一台设备", "Register a machine"],
4603
+ devicesAddHint: ["登记后这台设备才能接单。登记不会连上它——连接由那台设备自己发起。", "A machine takes work only after it is registered here. Registering does not connect it: the machine dials out itself."],
4604
+ devicesAddId: ["设备 id", "Device id"],
4605
+ devicesAddName: ["显示名", "Display name"],
4606
+ devicesAddRoots: ["它能接的项目", "Projects it can take"],
4607
+ devicesAddRoot: ["加一个项目", "Add a project"],
4608
+ devicesAddProject: ["项目名", "Project"],
4609
+ devicesAddPath: ["那台机器上的路径", "Path on that machine"],
4610
+ devicesAddSubmit: ["登记", "Register"],
4611
+ devicesAddMinted: ["把这四样填进那台设备的配置页", "Put these values on that machine's configuration page"],
4612
+ devicesAddMintedHint: ["令牌只显示这一次,库里只存哈希。关掉或刷新这一页就再也拿不回来了;丢了只能换一个。", "The token is shown once; only its hash is stored. Closing or refreshing this page loses it for good, and the only way back is a new one."],
4602
4613
  deviceOnline: ["在线", "Online"],
4603
4614
  deviceRevoked: ["已吊销", "Revoked"],
4604
4615
  deviceRunning: ["在跑 {count} 件", "{count} running"],
@@ -5545,7 +5556,7 @@ window.__ModuleLoader__.load({
5545
5556
  //#endregion
5546
5557
  //#region src/client/devices.tsx
5547
5558
  /** Devices settings group: which machines may take work, whether each is online, and what it is running. */
5548
- /** The device list, read-only apart from revoking and rotating a token. */
5559
+ /** The device list, the registration form, and the two token actions. */
5549
5560
  function DevicesSettings() {
5550
5561
  const { t, call } = usePanel();
5551
5562
  const { data, error, reload } = usePoll(() => call("GET devices"), []);
@@ -5585,6 +5596,9 @@ window.__ModuleLoader__.load({
5585
5596
  })
5586
5597
  ]
5587
5598
  }),
5599
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddDevice, { onAdded: () => {
5600
+ reload();
5601
+ } }),
5588
5602
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Section, {
5589
5603
  title: t("devicesList"),
5590
5604
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ListSection, {
@@ -5697,6 +5711,143 @@ window.__ModuleLoader__.load({
5697
5711
  })]
5698
5712
  });
5699
5713
  }
5714
+ /**
5715
+ * Registering a machine, in place.
5716
+ *
5717
+ * The three values the machine needs are shown together and once: the hub keeps only a token hash, so a token that
5718
+ * leaves this card is gone. That is why the card is not dismissible: closing it is losing the token, and the page
5719
+ * says so instead of letting someone find out at the device.
5720
+ */
5721
+ function AddDevice({ onAdded }) {
5722
+ const { t, call } = usePanel();
5723
+ const { data: state } = usePoll(() => call("GET state"), []);
5724
+ const [id, setId] = (0, react.useState)("");
5725
+ const [name, setName] = (0, react.useState)("");
5726
+ const [roots, setRoots] = (0, react.useState)([]);
5727
+ const [minted, setMinted] = (0, react.useState)();
5728
+ const [refusal, setRefusal] = (0, react.useState)();
5729
+ const [busy, setBusy] = (0, react.useState)(false);
5730
+ const register = async () => {
5731
+ setBusy(true);
5732
+ setRefusal(void 0);
5733
+ try {
5734
+ const result = await call("POST devices", { body: {
5735
+ id: id.trim(),
5736
+ name: name.trim(),
5737
+ labels: [],
5738
+ roots: roots.map((root) => ({
5739
+ project: root.project.trim(),
5740
+ path: root.path.trim()
5741
+ }))
5742
+ } });
5743
+ setMinted({
5744
+ id: result.id,
5745
+ token: result.token
5746
+ });
5747
+ setId("");
5748
+ setName("");
5749
+ setRoots([]);
5750
+ onAdded();
5751
+ } catch (error) {
5752
+ setRefusal(errorText(error));
5753
+ } finally {
5754
+ setBusy(false);
5755
+ }
5756
+ };
5757
+ const edit = (index, patch) => {
5758
+ setRoots((current) => current.map((root, at) => at === index ? {
5759
+ ...root,
5760
+ ...patch
5761
+ } : root));
5762
+ };
5763
+ const hubPort = state?.setup.machine.hubPort ?? 0;
5764
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5765
+ title: t("devicesAdd"),
5766
+ hint: t("devicesAddHint"),
5767
+ children: [
5768
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(FieldGrid, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5769
+ label: t("devicesAddId"),
5770
+ required: true,
5771
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5772
+ value: id,
5773
+ onChange: (event) => setId(event.target.value),
5774
+ placeholder: "laptop"
5775
+ })
5776
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5777
+ label: t("devicesAddName"),
5778
+ required: true,
5779
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5780
+ value: name,
5781
+ onChange: (event) => setName(event.target.value)
5782
+ })
5783
+ })] }),
5784
+ roots.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldGrid, { children: roots.map((root, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5785
+ label: index === 0 ? t("devicesAddProject") : `${t("devicesAddProject")} ${index + 1}`,
5786
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5787
+ value: root.project,
5788
+ onChange: (event) => edit(index, { project: event.target.value }),
5789
+ placeholder: "shop"
5790
+ })
5791
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5792
+ label: index === 0 ? t("devicesAddPath") : `${t("devicesAddPath")} ${index + 1}`,
5793
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5794
+ value: root.path,
5795
+ onChange: (event) => edit(index, { path: event.target.value }),
5796
+ placeholder: "F:\\\\dsh\\\\shop"
5797
+ })
5798
+ })] }, index)) }),
5799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5800
+ className: "dt-form-actions",
5801
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5802
+ size: "sm",
5803
+ onClick: () => setRoots((current) => [...current, {
5804
+ project: "",
5805
+ path: ""
5806
+ }]),
5807
+ children: t("devicesAddRoot")
5808
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5809
+ size: "sm",
5810
+ variant: "primary",
5811
+ busy,
5812
+ disabled: id.trim() === "" || name.trim() === "",
5813
+ onClick: () => {
5814
+ register();
5815
+ },
5816
+ children: t("devicesAddSubmit")
5817
+ })]
5818
+ }),
5819
+ refusal !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorLine, {
5820
+ error: refusal,
5821
+ title: t("loadFailed")
5822
+ })
5823
+ ]
5824
+ }), minted && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5825
+ title: t("devicesAddMinted"),
5826
+ tone: "warn",
5827
+ children: [
5828
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Hint, { children: t("devicesAddMintedHint") }),
5829
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValues, { rows: mintedRows(minted, hubPort) }),
5830
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
5831
+ className: "dt-mono",
5832
+ "data-device-token": true,
5833
+ children: minted.token
5834
+ })
5835
+ ]
5836
+ })] });
5837
+ }
5838
+ /**
5839
+ * The rows a device needs, with the hub's real port.
5840
+ *
5841
+ * A hub with `hubPort: 0` has its listener switched off, so no address a card could print would work; saying so is
5842
+ * more use than a plausible-looking port that nothing listens on.
5843
+ */
5844
+ function mintedRows(minted, hubPort) {
5845
+ return [
5846
+ ["url", hubPort > 0 ? `ws://127.0.0.1:${hubPort}` : "the hub is off: set hubPort in the plugin configuration"],
5847
+ ["deviceId", minted.id],
5848
+ ["token", minted.token]
5849
+ ];
5850
+ }
5700
5851
  //#endregion
5701
5852
  //#region src/client/logs.tsx
5702
5853
  /**
package/lib/index.js CHANGED
@@ -8802,6 +8802,10 @@ var RouteError = class extends Error {
8802
8802
  this.status = status;
8803
8803
  }
8804
8804
  };
8805
+ /** Whether a registration failed because the id is taken, the only failure that is the caller's to fix. */
8806
+ function isDuplicateDevice(error) {
8807
+ return error instanceof Error && /is already registered/.test(error.message);
8808
+ }
8805
8809
  async function readBody(req, maxBytes = 131072) {
8806
8810
  let size = 0;
8807
8811
  const chunks = [];
@@ -8924,12 +8928,13 @@ function failure(error, internal) {
8924
8928
  id
8925
8929
  }];
8926
8930
  }
8927
- function machineView({ stateDir, promptMaxChars, admissionContextMaxPages, admissionContextTimeoutMs }) {
8931
+ function machineView({ stateDir, promptMaxChars, admissionContextMaxPages, admissionContextTimeoutMs, hubPort }) {
8928
8932
  return {
8929
8933
  stateDir,
8930
8934
  promptMaxChars: promptMaxChars.get(),
8931
8935
  admissionContextMaxPages: admissionContextMaxPages.get(),
8932
- admissionContextTimeoutMs: admissionContextTimeoutMs.get()
8936
+ admissionContextTimeoutMs: admissionContextTimeoutMs.get(),
8937
+ hubPort
8933
8938
  };
8934
8939
  }
8935
8940
  /** Every setup problem the panel shows: global first, then the ones that pause a single Source, each with that Source's title. */
@@ -9169,6 +9174,24 @@ function routes(runtime) {
9169
9174
  devices: runtime.devices(),
9170
9175
  online: runtime.onlineDevices()
9171
9176
  }),
9177
+ /** 登记一台新设备。令牌只在这一次响应里出现,刷新就没了。 */
9178
+ "POST devices": async (input) => {
9179
+ const body = z.object({
9180
+ id: z.string().trim().min(1),
9181
+ name: z.string().trim().min(1),
9182
+ labels: z.array(z.string().trim().min(1)).optional(),
9183
+ roots: z.array(z.object({
9184
+ project: z.string().trim().min(1),
9185
+ path: z.string().trim().min(1)
9186
+ }))
9187
+ }).parse(await input.body());
9188
+ try {
9189
+ return runtime.addDevice(body);
9190
+ } catch (error) {
9191
+ if (isDuplicateDevice(error)) throw new RouteError(400, error.message);
9192
+ throw error;
9193
+ }
9194
+ },
9172
9195
  /** 吊销一台设备:登记保留(审计要看),下一次心跳就断开。 */
9173
9196
  "POST devices/:id/revoke": ({ params }) => {
9174
9197
  runtime.revokeDevice(decodeURIComponent(params.id));
@@ -9962,7 +9985,8 @@ var SocketChannel = class {
9962
9985
  constructor(socket) {
9963
9986
  this.socket = socket;
9964
9987
  socket.on("message", (data) => {
9965
- const text = typeof data === "string" ? data : data.toString();
9988
+ const text = textOf(data);
9989
+ if (text === void 0) return;
9966
9990
  if (this.messages.length === 0) {
9967
9991
  this.pending.push(text);
9968
9992
  return;
@@ -10030,6 +10054,18 @@ async function serve(input) {
10030
10054
  };
10031
10055
  }
10032
10056
  /**
10057
+ * The text of one frame, or undefined when the frame carries none.
10058
+ *
10059
+ * `ws` hands over a string, a Buffer, an array of Buffers, or — for a binary frame this protocol never sends — a raw
10060
+ * Buffer. Converting blindly would turn an absent payload into the literal string "undefined", which then reaches a
10061
+ * parser as a malformed frame.
10062
+ */
10063
+ function textOf(data) {
10064
+ if (typeof data === "string") return data;
10065
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
10066
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
10067
+ }
10068
+ /**
10033
10069
  * The device id the first frame claims, and why it is missing when it is.
10034
10070
  *
10035
10071
  * Reading it here rather than in the hub keeps `Hub` free of `ws` types without weakening the handshake: the hub
@@ -10047,7 +10083,12 @@ function firstFrame(socket) {
10047
10083
  const timer = setTimeout(() => settle({ reason: "the connection did not open with a hello within 10 seconds" }), 1e4);
10048
10084
  const closed = () => settle({ reason: "the connection closed before it said hello" });
10049
10085
  socket.once("message", (data) => {
10050
- const deviceId = deviceIdOf(typeof data === "string" ? data : data.toString());
10086
+ const text = textOf(data);
10087
+ if (text === void 0) {
10088
+ settle({ reason: "the first frame carried no text" });
10089
+ return;
10090
+ }
10091
+ const deviceId = deviceIdOf(text);
10051
10092
  settle(deviceId === void 0 ? { reason: "the first frame must be a hello naming a device" } : {
10052
10093
  deviceId,
10053
10094
  reason: ""
@@ -12358,6 +12399,14 @@ var Runtime = class extends Service {
12358
12399
  models: () => this.ctx.sessionController.modelCatalog(),
12359
12400
  devices: () => runtime.devices.list(),
12360
12401
  onlineDevices: () => runtime.hub?.snapshot() ?? [],
12402
+ /** 登记一台设备并铸一枚令牌;和 `scripts/add-device.mjs` 走同一个 `Devices.add`。 */
12403
+ addDevice: (input) => {
12404
+ const { device, token } = runtime.devices.add(input);
12405
+ return {
12406
+ id: device.id,
12407
+ token
12408
+ };
12409
+ },
12361
12410
  revokeDevice: (id) => {
12362
12411
  runtime.devices.revoke(id);
12363
12412
  runtime.hub?.detach(id);
package/locale/en.json CHANGED
File without changes
package/locale/zh.json CHANGED
File without changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-foreman",
3
3
  "author": "niechao136",
4
4
  "license": "MIT",
5
- "version": "0.1.0",
5
+ "version": "0.3.0",
6
6
  "description": "dsh-foreman:主 loop 在多设备上派单的 DSH bundle:需求来自本机操作者的主会话而不是 GitHub/飞书,每个工作区各有一条主会话,主会话只负责路由,真正的活儿派给 Worker 在该工作区目录里执行。",
7
7
  "type": "module",
8
8
  "main": "lib/index.js",
@@ -27,7 +27,10 @@
27
27
  "CHANGELOG.md",
28
28
  "README.md",
29
29
  "prompts",
30
- "skills"
30
+ "skills",
31
+ "scripts/add-device.mjs",
32
+ "scripts/migrate.mjs",
33
+ "scripts/dsh-foreman"
31
34
  ],
32
35
  "dsh": {
33
36
  "bundle": {
@@ -59,7 +62,7 @@
59
62
  },
60
63
  "packageManager": "pnpm@11.7.0",
61
64
  "dependencies": {
62
- "dsh-foreman-node": "0.1.0",
65
+ "dsh-foreman-node": "0.2.0",
63
66
  "proper-lockfile": "4.1.2",
64
67
  "ws": "8.22.0",
65
68
  "zod": "4.4.3"
@@ -28,7 +28,6 @@ The host posts it as a reply to the triggering message; do not quote that messag
28
28
  Return rule_assessments with exactly one entry for every enabled rule: rule_id, matched and a factual reason.
29
29
  Include negative judgments as matched=false; a reason saying that a rule does not apply cannot be a positive match.
30
30
  Never include disabled, unknown or repeated rule IDs.
31
- Fill device only when the request itself says where the work has to happen — a named machine, an office-only system, a machine the person says they are at. Otherwise leave it out: the host picks the least busy online machine that can reach the project, and a guess here can send work to a machine that cannot do it.
32
31
  The host derives positive-only matches from those booleans.
33
32
  One source message produces at most one ticket.
34
33
  If matching rules conflict or the scope is ambiguous, ignore quietly.
package/prompts/cli.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/prompts/shared.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
package/prompts/work.md CHANGED
File without changes
package/prompts/worker.md CHANGED
File without changes
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Register a device on the hub, or manage the ones already registered.
4
+ *
5
+ * Adding a device must not mean editing plugin configuration and restarting the hub — that is an anti-goal of the
6
+ * design — so this writes the registry row directly and the running hub picks it up on the device's next hello.
7
+ *
8
+ * Plain ESM on `node:sqlite`, no dependencies: it has to run on a server where only the hub is installed.
9
+ *
10
+ * node scripts/add-device.mjs add --home <dir> --id laptop --name "公司笔记本" \
11
+ * --root shop=/home/me/work/shop --label intranet
12
+ * node scripts/add-device.mjs list --home <dir>
13
+ * node scripts/add-device.mjs rotate --home <dir> --id laptop
14
+ * node scripts/add-device.mjs revoke --home <dir> --id laptop
15
+ */
16
+ import { createHash, randomBytes } from 'node:crypto'
17
+ import { existsSync } from 'node:fs'
18
+ import { join, resolve } from 'node:path'
19
+ import { DatabaseSync } from 'node:sqlite'
20
+
21
+ const DATABASE = 'state/superagent/orchestrator.sqlite'
22
+ const PREFIX = 'foreman-device:'
23
+
24
+ const usage = `Usage:
25
+ add --id <id> --name <name> [--label <label>]... [--root <project>=<path>]...
26
+ list
27
+ rotate --id <id>
28
+ revoke --id <id>
29
+ All commands take --home <dir> (default: $DSH_HOME, else ~/.dsh) and --db <path> to override the database.`
30
+
31
+ const hashToken = token => createHash('sha256').update(token, 'utf8').digest('hex')
32
+
33
+ /** Reads repeating and single `--name value` options into a list of pairs. */
34
+ function options(argv) {
35
+ const single = new Map()
36
+ const many = { label: [], root: [] }
37
+ for (let index = 0; index < argv.length; index++) {
38
+ const name = argv[index]
39
+ if (!name.startsWith('--')) throw new Error(`unexpected argument ${name}\n\n${usage}`)
40
+ const key = name.slice(2)
41
+ const value = argv[index + 1]
42
+ if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value\n\n${usage}`)
43
+ if (key in many) many[key].push(value)
44
+ else single.set(key, value)
45
+ index++
46
+ }
47
+ return { single, many }
48
+ }
49
+
50
+ /** Opens the hub's database, refusing a path that is not one so a typo cannot write anywhere. */
51
+ function open(single) {
52
+ const home = resolve(single.get('home') ?? process.env.DSH_HOME ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.dsh'))
53
+ const path = single.get('db') ? resolve(single.get('db')) : join(home, DATABASE)
54
+ if (!existsSync(path)) throw new Error(`no hub database at ${path}; is --home the DSH home of the loop instance?`)
55
+ const db = new DatabaseSync(path)
56
+ // The registry is a kv family, so this works without importing the hub's schema: the key format is the contract.
57
+ db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
58
+ return { db, path }
59
+ }
60
+
61
+ const read = (db, id) => {
62
+ const row = db.prepare('SELECT value FROM kv WHERE key=?').get(PREFIX + id)
63
+ return row === undefined ? undefined : JSON.parse(String(row.value))
64
+ }
65
+ const write = (db, record) => {
66
+ db.prepare('INSERT INTO kv VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(PREFIX + record.id, JSON.stringify(record))
67
+ }
68
+ const all = db => db.prepare('SELECT value FROM kv WHERE key LIKE ? ORDER BY key').all(`${PREFIX}%`).map(row => JSON.parse(String(row.value)))
69
+
70
+ /** `project=path`, split on the first `=` so a Windows path's own `=` survives. */
71
+ function rootsOf(values) {
72
+ return values.map(value => {
73
+ const at = value.indexOf('=')
74
+ if (at <= 0) throw new Error(`--root needs <project>=<path>, not ${value}`)
75
+ return { project: value.slice(0, at), path: value.slice(at + 1) }
76
+ })
77
+ }
78
+
79
+ const [command, ...rest] = process.argv.slice(2)
80
+ if (command === undefined || ['-h', '--help', 'help'].includes(command)) {
81
+ console.log(usage)
82
+ process.exit(0)
83
+ }
84
+ try {
85
+ const { single, many } = options(rest)
86
+ const { db, path } = open(single)
87
+ let output
88
+ try {
89
+ if (command === 'add') {
90
+ const id = single.get('id'), name = single.get('name')
91
+ if (!id) throw new Error('add needs --id')
92
+ if (read(db, id) !== undefined) throw new Error(`a device with id ${id} is already registered; use rotate to replace its token`)
93
+ const token = randomBytes(32).toString('hex')
94
+ write(db, { id, name: name ?? id, labels: many.label, roots: rootsOf(many.root), tokenHash: hashToken(token) })
95
+ // The token is printed once and never stored in readable form: there is nothing to look it up from later.
96
+ output = JSON.stringify({ device: id, token, note: 'this token is shown once; only its hash is stored' }, null, 2)
97
+ } else if (command === 'list') {
98
+ output = all(db).map(({ tokenHash: _hidden, ...record }) => record)
99
+ .map(record => JSON.stringify(record)).join('\n') || '(no device is registered)'
100
+ } else if (command === 'rotate') {
101
+ const id = single.get('id')
102
+ const device = id === undefined ? undefined : read(db, id)
103
+ if (device === undefined) throw new Error(`no device with id ${String(id)}`)
104
+ const token = randomBytes(32).toString('hex')
105
+ write(db, { ...device, tokenHash: hashToken(token) })
106
+ output = JSON.stringify({ device: device.id, token }, null, 2)
107
+ } else if (command === 'revoke') {
108
+ const id = single.get('id')
109
+ const device = id === undefined ? undefined : read(db, id)
110
+ if (device === undefined) throw new Error(`no device with id ${String(id)}`)
111
+ write(db, { ...device, revokedAt: new Date().toISOString() })
112
+ output = `revoked ${device.id}: its next frame is refused and its connection is dropped`
113
+ } else throw new Error(`unknown command ${command}\n\n${usage}`)
114
+ } finally {
115
+ db.close()
116
+ }
117
+ console.log(output)
118
+ if (command !== 'list') console.log(`\ndatabase: ${path}\nthe running hub needs no restart; the device picks this up when it next connects.`)
119
+ } catch (error) {
120
+ console.error(`add-device: ${error instanceof Error ? error.message : String(error)}`)
121
+ process.exit(1)
122
+ }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """Manage a local dsh-foreman service (one DSH profile, default `superagent`) without affecting other tmux sessions.
3
+
4
+ `status`, `start`, and `restart` exit 0 while the service runs normally; 1 when it is stopped, duplicated, unreachable, or
5
+ reporting a runtime error, which `restart` may fix; 2 when it runs while a component such as a Source reports an error;
6
+ and 3 when it runs and waits for setup. A restart fixes neither 2 nor 3: they need a person.
7
+ """
8
+
9
+ import argparse
10
+ import fcntl
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import shlex
16
+ import shutil
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import urllib.error
22
+ import urllib.request
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ ARTIFACTS = ROOT / '.artifacts'
26
+ CONTROL = ARTIFACTS / 'control'
27
+ KEEP_LOGS = 20
28
+
29
+
30
+ def control(profile):
31
+ """Return the control directory for `profile`, creating it if needed."""
32
+ directory = CONTROL / 'profiles' / profile
33
+ directory.mkdir(parents=True, exist_ok=True)
34
+ return directory
35
+
36
+
37
+ def stopped(profile):
38
+ """Return whether `profile` was intentionally stopped."""
39
+ return (control(profile) / 'manually-stopped').exists()
40
+
41
+
42
+ def serves(args, profile):
43
+ """Return whether command-line arguments belong to the main DSH service process for `profile`."""
44
+ words, matched, value = [], False, False
45
+ for word in args[1:]:
46
+ if value:
47
+ matched, value = matched or word == profile, False
48
+ elif word == '--profile':
49
+ value = True
50
+ elif word == '--profile=' + profile:
51
+ matched = True
52
+ elif not word.startswith('-'):
53
+ words.append(word)
54
+ return matched and 'plugin' not in words
55
+
56
+
57
+ def ps(*options, check=False):
58
+ """Yield `(pid, comm, args)` tuples from `ps`; arguments are split on whitespace, so paths with spaces become several words and only `--profile` and subcommands are inspected."""
59
+ result = subprocess.run(['ps', '-ww', *options, '-o', 'pid=,ucomm=,args='], check=check, capture_output=True, text=True)
60
+ for line in result.stdout.splitlines():
61
+ parts = line.split(None, 2)
62
+ if len(parts) == 3 and parts[0].isdigit():
63
+ yield int(parts[0]), parts[1], parts[2].split()
64
+
65
+
66
+ def running(pid, profile):
67
+ """Return whether `pid` is currently running the DSH service for `profile` (`ps -p` returns nothing once exited)."""
68
+ return any(found == pid and name in ('node', 'dsh') and serves(args, profile) for found, name, args in ps('-p', str(pid)))
69
+
70
+
71
+ def processes(profile):
72
+ """Return PIDs of all running DSH service processes for `profile`, including instances started outside this script and the recorded `service.pid` while still running."""
73
+ found = [pid for pid, name, args in ps('-ax', check=True) if name in ('node', 'dsh') and serves(args, profile)]
74
+ try:
75
+ pid = int((control(profile) / 'service.pid').read_text())
76
+ except (OSError, ValueError):
77
+ return found
78
+ return found + [pid] if pid not in found and running(pid, profile) else found
79
+
80
+
81
+ def state(url):
82
+ """Fetch health and setup status from the management API, or return None if unreachable."""
83
+ request = urllib.request.Request(url.rstrip('/') + '/orchestrator/api/state',
84
+ headers={'x-orchestrator-request': '1'})
85
+ try:
86
+ with urllib.request.urlopen(request, timeout=2) as response:
87
+ value = json.load(response)
88
+ # Keys omitted by another version read as empty so the service is not mistaken for unreachable.
89
+ health, setup = value.get('health') or {}, value.get('setup') or {}
90
+ entries = {name: entry if isinstance(entry, dict) else {} for name, entry in health.items()}
91
+ return {'health': {name: entry.get('state') for name, entry in entries.items()},
92
+ 'errors': {name: str(entry.get('detail', '')) for name, entry in entries.items() if entry.get('state') == 'error'},
93
+ 'setup': setup.get('problem'), 'feishu': setup.get('feishuProblem')}
94
+ except (OSError, ValueError, AttributeError):
95
+ return None
96
+
97
+
98
+ PART_ERROR = 2
99
+ SETUP_WAITS = 3
100
+
101
+
102
+ def runtime_up(current):
103
+ """Return whether the runtime is ready or paused awaiting operator action; a component still starting (such as history recovery) does not hold this up."""
104
+ return current is not None and current['health'].get('runtime') in ('ready', 'paused')
105
+
106
+
107
+ def part_errors(current):
108
+ """Return non-runtime components that are in an error state."""
109
+ return {name: detail for name, detail in sorted(current['errors'].items()) if name != 'runtime'}
110
+
111
+
112
+ def outcome(pids, current):
113
+ """Compute the CLI exit code from running PIDs and management API state."""
114
+ if len(pids) != 1 or not runtime_up(current):
115
+ return 1
116
+ if current['setup']:
117
+ return SETUP_WAITS
118
+ # A component's lasting error, unlike a Feishu bot check that failed during setup, requires operator action.
119
+ return PART_ERROR if part_errors(current) else 0
120
+
121
+
122
+ def status(args):
123
+ pids = processes(args.profile)
124
+ current = state(args.url) if pids else None
125
+ print(json.dumps({'pids': pids, 'cwd': str(ROOT), 'url': args.url,
126
+ 'manually_stopped': stopped(args.profile), 'health': current and current['health'],
127
+ 'errors': current and current['errors'], 'setup': current and current['setup'],
128
+ 'feishu': current and current['feishu']}, ensure_ascii=False, indent=2))
129
+ return outcome(pids, current)
130
+
131
+
132
+ def stop(args, intentional=True):
133
+ """Stop the service gracefully, recording a manual stop marker when `intentional` is True."""
134
+ pids = processes(args.profile)
135
+ if len(pids) > 1:
136
+ raise RuntimeError('Multiple dsh-foreman processes found; refusing to choose one.')
137
+ # Write the stop marker before waiting so monitors respect the intentional stop even if graceful shutdown times out.
138
+ if intentional:
139
+ (control(args.profile) / 'manually-stopped').write_text(time.strftime('%Y-%m-%dT%H:%M:%S%z') + '\n')
140
+ for pid in pids:
141
+ os.kill(pid, signal.SIGINT)
142
+ deadline = time.monotonic() + args.stop_timeout
143
+ while any(running(pid, args.profile) for pid in pids):
144
+ if time.monotonic() >= deadline:
145
+ raise RuntimeError('Graceful stop timed out; no forced kill was sent. Check status.')
146
+ time.sleep(0.5)
147
+ (control(args.profile) / 'service.pid').unlink(missing_ok=True)
148
+ print('dsh-foreman stopped. Automatic recovery is paused until start/restart.' if intentional else 'dsh-foreman stopped.')
149
+
150
+
151
+ def prune_logs(profile):
152
+ """Retain only the newest `KEEP_LOGS` log files for `profile`."""
153
+ name = re.compile(re.escape(f'dsh-foreman-{profile}-') + r'\d{8}-\d{6}-\d+\.log')
154
+ logs = sorted((path for path in ARTIFACTS.iterdir() if name.fullmatch(path.name)), key=lambda path: path.stat().st_mtime)
155
+ for path in logs[:-KEEP_LOGS]:
156
+ path.unlink(missing_ok=True)
157
+
158
+
159
+ def start(args):
160
+ session = 'dsh-' + args.profile
161
+ pids = processes(args.profile)
162
+ if len(pids) > 1:
163
+ raise RuntimeError('Multiple dsh-foreman processes found; refusing to start another.')
164
+ if not pids:
165
+ dsh = shutil.which('dsh')
166
+ if not dsh or not shutil.which('tmux'):
167
+ raise RuntimeError('Both dsh and tmux must be on PATH.')
168
+ exists = subprocess.run(['tmux', 'has-session', '-t', '=' + session],
169
+ capture_output=True).returncode == 0
170
+ if state(args.url) is not None:
171
+ raise RuntimeError('An existing orchestrator answers at this URL; refusing duplicate startup.')
172
+ stamp = time.strftime('%Y%m%d-%H%M%S')
173
+ log = ARTIFACTS / f'dsh-foreman-{args.profile}-{stamp}-{os.getpid()}.log'
174
+ log.touch()
175
+ prune_logs(args.profile)
176
+ # `exec` replaces the shell so the pane PID printed by tmux is the DSH service process itself.
177
+ command = ('umask 077; exec ' + shlex.join([dsh, '--profile', args.profile, '--no-open'])
178
+ + ' >> ' + shlex.quote(str(log)) + ' 2>&1')
179
+ # Reuse an existing tmux session without disturbing other windows.
180
+ target = ['new-window', '-t', '=' + session + ':'] if exists else ['new-session', '-s', session]
181
+ pane = subprocess.run(['tmux', *target, '-d', '-P', '-F', '#{pane_pid}', '-n', 'server', '-c', str(ROOT), command],
182
+ check=True, capture_output=True, text=True).stdout.strip()
183
+ if pane.isdigit():
184
+ (control(args.profile) / 'service.pid').write_text(pane + '\n')
185
+ print('Log: ' + str(log), flush=True)
186
+ else:
187
+ (control(args.profile) / 'service.pid').write_text(f'{pids[0]}\n')
188
+ print(f'dsh-foreman already running (PID {pids[0]}); no duplicate started.', flush=True)
189
+ (control(args.profile) / 'manually-stopped').unlink(missing_ok=True)
190
+ deadline = time.monotonic() + args.timeout
191
+ while time.monotonic() < deadline:
192
+ current = state(args.url)
193
+ if processes(args.profile) and runtime_up(current):
194
+ code = status(args)
195
+ if current['setup']:
196
+ print('dsh-foreman is waiting for setup: ' + str(current['setup'].get('detail', '')))
197
+ print('Open the URL DSH printed in the log, then the dsh-foreman overview.')
198
+ if current['feishu']:
199
+ print('dsh-foreman runs without Feishu: ' + str(current['feishu'].get('detail', '')))
200
+ print('Fix it on the Feishu card of the Source settings.')
201
+ for name, detail in part_errors(current).items():
202
+ print(f'{name} reports an error, which a restart does not fix: {detail}')
203
+ return code
204
+ time.sleep(1)
205
+ status(args)
206
+ raise RuntimeError('Startup health check timed out; inspect the log. Any running process was left intact.')
207
+
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser(description=__doc__)
211
+ parser.add_argument('action', choices=['start', 'stop', 'restart', 'status'])
212
+ parser.add_argument('--profile', default=os.environ.get('DSH_SUPERAGENT_PROFILE') or 'superagent',
213
+ help='DSH profile that loads dsh-foreman; its tmux session is dsh-<profile>.')
214
+ parser.add_argument('--url', default=os.environ.get('DSH_SUPERAGENT_URL') or 'http://127.0.0.1:3082',
215
+ help='Health-check URL; override after changing the profile port.')
216
+ parser.add_argument('--timeout', type=float, default=60, help='Startup timeout in seconds.')
217
+ parser.add_argument('--stop-timeout', type=float, default=300,
218
+ help='Graceful shutdown timeout in seconds; running Executor Sessions finish their step first.')
219
+ parser.add_argument('--respect-stop', action='store_true',
220
+ help='For monitoring: never start/restart after an intentional stop.')
221
+ args = parser.parse_args()
222
+ if args.timeout <= 0 or args.stop_timeout <= 0:
223
+ parser.error('--timeout and --stop-timeout must be positive')
224
+ if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]*', args.profile):
225
+ parser.error('--profile must be a DSH profile name')
226
+ os.umask(0o077)
227
+ with (control(args.profile) / 'operation.lock').open('a') as lock:
228
+ try:
229
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
230
+ except BlockingIOError:
231
+ raise RuntimeError('Another dsh-foreman control operation is in progress.') from None
232
+ if args.respect_stop and args.action in ('start', 'restart') and stopped(args.profile):
233
+ print('Intentional stop is active; automatic recovery skipped.')
234
+ return 0
235
+ if args.action == 'status':
236
+ return status(args)
237
+ if args.action in ('stop', 'restart'):
238
+ # Only a stop keeps automatic recovery off; a restart whose stop timed out leaves it on.
239
+ stop(args, intentional=args.action == 'stop')
240
+ if args.action in ('start', 'restart'):
241
+ return start(args)
242
+ return 0
243
+
244
+
245
+ if __name__ == '__main__':
246
+ try:
247
+ sys.exit(main())
248
+ except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
249
+ print(str(error), file=sys.stderr)
250
+ sys.exit(1)
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pack and verify a hub migration.
4
+ *
5
+ * Moving the hub is "stop, pack, copy, unpack at the same absolute paths, start". The one step that cannot be
6
+ * skipped is the SQLite checkpoint: the database runs in WAL mode, so the newest writes live in a `-wal` file, and
7
+ * copying only the `.sqlite` silently loses them. srv-01 has no `sqlite3` command, so the checkpoint goes through
8
+ * `node:sqlite` here.
9
+ *
10
+ * The script is plain ESM with no dependencies, because it has to run on a machine where the hub may not be
11
+ * installed. On Windows it only checkpoints and prints what to copy: `tar` is not guaranteed there, and a silent
12
+ * half-copy is worse than a printed list.
13
+ */
14
+ import { spawnSync } from 'node:child_process'
15
+ import { existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs'
16
+ import { dirname, join, resolve } from 'node:path'
17
+ import { DatabaseSync } from 'node:sqlite'
18
+
19
+ /** The directories a move must carry. `storages` is not optional: it holds the workspace UUIDs the main Sessions are keyed by. */
20
+ const DIRECTORIES = ['state/superagent', 'storages', 'sessions', 'attachments', 'profiles']
21
+ /** The database inside the state directory. */
22
+ const DATABASE = 'state/superagent/orchestrator.sqlite'
23
+
24
+ const usage = `Usage:
25
+ node scripts/migrate.mjs plan --home <dir>
26
+ node scripts/migrate.mjs checkpoint --home <dir>
27
+ node scripts/migrate.mjs pack --home <dir> --out <tar>
28
+ node scripts/migrate.mjs verify --home <dir>
29
+
30
+ --home is the DSH_HOME of the instance being moved (default: $DSH_HOME, else ~/.dsh).`
31
+
32
+ /** Reads `--name value` pairs; a missing value is an error rather than an empty string. */
33
+ function options(argv) {
34
+ const values = new Map()
35
+ for (let index = 0; index < argv.length; index++) {
36
+ const name = argv[index]
37
+ if (!name.startsWith('--')) throw new Error(`unexpected argument ${name}\n\n${usage}`)
38
+ const value = argv[index + 1]
39
+ if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value\n\n${usage}`)
40
+ values.set(name.slice(2), value)
41
+ index++
42
+ }
43
+ return values
44
+ }
45
+
46
+ /** The DSH home to act on: `--home`, else `$DSH_HOME`, else `~/.dsh`. */
47
+ function homeOf(values) {
48
+ const given = values.get('home') ?? process.env.DSH_HOME ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.dsh')
49
+ return resolve(given)
50
+ }
51
+
52
+ /** Refuses a directory that is not a DSH home, so a typo cannot checkpoint or pack the wrong database. */
53
+ function requireHome(home) {
54
+ const database = join(home, DATABASE)
55
+ if (!existsSync(database)) throw new Error(`${home} is not a DSH home: ${database} does not exist`)
56
+ return database
57
+ }
58
+
59
+ /**
60
+ * Checkpoints the WAL into the database file and closes it.
61
+ *
62
+ * `TRUNCATE` writes the frames back and empties the `-wal`, which is what makes a plain file copy complete. A
63
+ * checkpoint that reports it could not finish throws: packing a database that still has unmerged writes is exactly
64
+ * the silent data loss this step exists to prevent.
65
+ */
66
+ function checkpoint(home) {
67
+ const database = requireHome(home)
68
+ const db = new DatabaseSync(database)
69
+ try {
70
+ const row = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()
71
+ // The pragma answers { busy, log, checkpointed }: `busy` non-zero means another connection still holds the WAL.
72
+ if (row && Number(row.busy) !== 0) throw new Error(`the database is still in use by another process (busy=${row.busy}); stop the hub first`)
73
+ } finally {
74
+ db.close()
75
+ }
76
+ return database
77
+ }
78
+
79
+ /** Prints what a move carries, with each directory's size, so a partial copy is visible before it happens. */
80
+ function plan(home) {
81
+ requireHome(home)
82
+ const lines = [`home: ${home}`, `database: ${DATABASE}`, '']
83
+ let total = 0
84
+ for (const directory of DIRECTORIES) {
85
+ const path = join(home, directory)
86
+ if (!existsSync(path)) { lines.push(` ${directory}: absent`); continue }
87
+ const bytes = sizeOf(path)
88
+ total += bytes
89
+ lines.push(` ${directory}: ${bytes} bytes`)
90
+ }
91
+ lines.push('', `total: ${total} bytes`, '', 'Copy these to the same absolute paths on the new machine; the database must keep its path or the ledger\'s absolute paths break.')
92
+ return lines.join('\n')
93
+ }
94
+
95
+ /** Total bytes of a file or directory tree, without following links. */
96
+ /**
97
+ * Total bytes under a path.
98
+ *
99
+ * Walks the tree itself rather than shelling out to `du`: `du -sb` is GNU-specific, and on a platform whose `du`
100
+ * lacks `-b` (macOS/BSD) the command fails and the old fallback reported only the directory inode's size — a number
101
+ * small enough to look like the tree was empty, on the one page an operator reads before deciding the copy is small.
102
+ * Links are counted as their own size and not followed, so a symlink loop cannot hang the walk.
103
+ */
104
+ function sizeOf(path) {
105
+ let info
106
+ try { info = lstatSync(path) }
107
+ catch { return 0 }
108
+ if (!info.isDirectory()) return info.size
109
+ let total = info.size
110
+ let entries
111
+ try { entries = readdirSync(path) } catch { return total }
112
+ for (const name of entries) total += sizeOf(join(path, name))
113
+ return total
114
+ }
115
+
116
+ /** Packs the directories into a tar, after the checkpoint. Windows has no guaranteed `tar`, so it prints instead. */
117
+ function pack(home, out) {
118
+ const database = checkpoint(home)
119
+ if (process.platform === 'win32') {
120
+ throw new Error(`on Windows this script only checkpoints (${database}); copy ${DIRECTORIES.join(', ')} to the new machine with the same absolute paths, then run verify there`)
121
+ }
122
+ mkdirSync(dirname(out), { recursive: true })
123
+ const existing = DIRECTORIES.filter(directory => existsSync(join(home, directory)))
124
+ const result = spawnSync('tar', ['-czf', out, '-C', home, ...existing], { stdio: 'inherit' })
125
+ if (result.status !== 0) throw new Error(`tar failed with status ${result.status}`)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * Checks that what a finished move produced is usable: the database opens, the checkpoint took, and the workspace
131
+ * copy is present. It prints counts rather than asserting a specific number, because the number depends on the
132
+ * instance — the operator compares it with the old machine's.
133
+ */
134
+ function verify(home) {
135
+ const database = requireHome(home)
136
+ // Re-checkpointing is safe and tells us whether the WAL came back after the copy.
137
+ const db = new DatabaseSync(database)
138
+ let checkpointed
139
+ try {
140
+ checkpointed = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()
141
+ const busy = checkpointed ? Number(checkpointed.busy) : 0
142
+ if (busy !== 0) throw new Error('the database is in use; stop the hub before verifying a move')
143
+ } finally {
144
+ db.close()
145
+ }
146
+ const counts = {}
147
+ const counting = new DatabaseSync(database)
148
+ try {
149
+ for (const table of ['kv', 'trigger', 'ticket']) {
150
+ // A fresh or partially built database may lack a table; report that rather than failing the whole verify.
151
+ try { counts[table] = String(counting.prepare(`SELECT count(*) AS n FROM ${table}`).get()?.n ?? 0) } catch { counts[table] = 'absent' }
152
+ }
153
+ } finally {
154
+ counting.close()
155
+ }
156
+ const lines = [
157
+ `home: ${home}`,
158
+ 'checkpoint ok',
159
+ `workspace.json: ${existsSync(join(home, 'storages/workspace.json')) ? 'present' : 'MISSING'}`,
160
+ `tickets: ${counts.ticket}`,
161
+ `rows: kv=${counts.kv} trigger=${counts.trigger} ticket=${counts.ticket}`,
162
+ ]
163
+ for (const directory of DIRECTORIES) {
164
+ if (!existsSync(join(home, directory))) lines.push(`missing directory: ${directory}`)
165
+ }
166
+ return lines.join('\n')
167
+ }
168
+
169
+ const [command, ...rest] = process.argv.slice(2)
170
+ if (command === undefined || ['-h', '--help', 'help'].includes(command)) {
171
+ console.log(usage)
172
+ process.exit(0)
173
+ }
174
+ try {
175
+ const values = options(rest)
176
+ const home = homeOf(values)
177
+ if (command === 'plan') console.log(plan(home))
178
+ else if (command === 'checkpoint') console.log(`checkpoint ok: ${checkpoint(home)}`)
179
+ else if (command === 'pack') {
180
+ const out = values.get('out')
181
+ if (out === undefined) throw new Error(`pack needs --out <tar>\n\n${usage}`)
182
+ console.log(`packed: ${pack(home, resolve(out))}`)
183
+ } else if (command === 'verify') console.log(verify(home))
184
+ else throw new Error(`unknown command ${command}\n\n${usage}`)
185
+ } catch (error) {
186
+ console.error(`migrate: ${error instanceof Error ? error.message : String(error)}`)
187
+ process.exit(1)
188
+ }
File without changes
File without changes