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 +2 -67
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/cordis.patch.yml +7 -7
- package/icon.svg +0 -0
- package/lib/client.js +153 -2
- package/lib/index.js +53 -4
- package/locale/en.json +0 -0
- package/locale/zh.json +0 -0
- package/package.json +6 -3
- package/prompts/admission.md +0 -1
- package/prompts/cli.md +0 -0
- package/prompts/dispatch.md +0 -0
- package/prompts/handoff-orchestrator.md +0 -0
- package/prompts/handoff-worker.md +0 -0
- package/prompts/handoff.md +0 -0
- package/prompts/orchestrator.md +0 -0
- package/prompts/replies.md +0 -0
- package/prompts/shared.md +0 -0
- package/prompts/subscriptions.md +0 -0
- package/prompts/team/communication.md +0 -0
- package/prompts/team/delivery.md +0 -0
- package/prompts/team/scope.md +0 -0
- package/prompts/work.md +0 -0
- package/prompts/worker.md +0 -0
- package/scripts/add-device.mjs +122 -0
- package/scripts/dsh-foreman +250 -0
- package/scripts/migrate.mjs +188 -0
- package/skills/dsh-foreman-feishu/SKILL.md +0 -0
- package/skills/dsh-foreman-github/SKILL.md +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,73 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
下面 `0.2.0` 一节是从代码基线 `dsh-superagent-local` 沿袭下来的、那一份代码相对上游
|
|
5
|
-
`dsh-superagent-prototype` 的改动记录,保留它是为了说明基线里已经有什么;**它不是本项目的发布版本**。
|
|
3
|
+
本仓库是 dsh-foreman(研究方向原型)的本地改造版。下面是相对上游 0.1.0 的改动。
|
|
6
4
|
|
|
7
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# 飞书 IM 来源:群消息与私聊进工单。没有配置机器人 CLI 时它自己报「未设置机器人 CLI」,
|
|
10
|
+
# 不影响别的来源,所以默认开着,让人能直接在设置里填。
|
|
11
11
|
- id: dsh-foreman-feishu
|
|
12
12
|
name: dsh-foreman/feishu-watch
|
|
13
|
-
#
|
|
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
|
|
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
|
|
133
|
-
description: dsh-foreman
|
|
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: ["
|
|
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,
|
|
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 =
|
|
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
|
|
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.
|
|
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.
|
|
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"
|
package/prompts/admission.md
CHANGED
|
@@ -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
|
package/prompts/dispatch.md
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/prompts/handoff.md
CHANGED
|
File without changes
|
package/prompts/orchestrator.md
CHANGED
|
File without changes
|
package/prompts/replies.md
CHANGED
|
File without changes
|
package/prompts/shared.md
CHANGED
|
File without changes
|
package/prompts/subscriptions.md
CHANGED
|
File without changes
|
|
File without changes
|
package/prompts/team/delivery.md
CHANGED
|
File without changes
|
package/prompts/team/scope.md
CHANGED
|
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
|