@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +426 -0
- package/backend/lib/proc.js +361 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动横幅的默认账号提示(`server.py:5457-5472 _seed_hint` 的对应物,2026-10-16 补)。
|
|
3
|
+
*
|
|
4
|
+
* ── 三档与 `db.js:seedAdmin()` 的口令来源一一对应(逐条对齐 Python) ──────────────
|
|
5
|
+
* ① 随机一次性口令(`seedPassword` 非空)→ 打印口令本身 + 首登须改密(读后即清)
|
|
6
|
+
* ② `TS_ADMIN_PASSWORD` 非空 → 只指向变量名,**绝不回显口令值**
|
|
7
|
+
* ③ 都没有(`TS_ADMIN_PASSWORDLESS=1`,插件形态)→ 空口令,提示可在设置页设密码
|
|
8
|
+
*
|
|
9
|
+
* ── 为什么单列第 ③ 档(2026-10-16 修) ────────────────────────────────────────
|
|
10
|
+
* 第 ③ 档此前**落进第 ② 档的文案**:插件形态(薄壳默认下发 `TS_ADMIN_PASSWORDLESS=1`)
|
|
11
|
+
* 首启横幅写的是「初始口令取自 TS_ADMIN_PASSWORD」,可那个变量根本没设、账号实际是空口令
|
|
12
|
+
* ——首装排障按这句话去找变量会白找。Python 侧同一分支的旧文案是「其一次性口令已在上一次
|
|
13
|
+
* 启动时打印,本次不再显示」,同样对不上:该分支**结构上不可达**于「口令已被取走」——
|
|
14
|
+
* `seeded=false` 时 `_seed_hint` 直接返回空串,而 `seeded=true` 且 `initial_pw` 为空只可能
|
|
15
|
+
* 是空口令种子。两版现在给同一句、且逐字节相同(`tests/test_seed_admin.py` 与
|
|
16
|
+
* `test/seed-hint.test.mjs` 各钉一半)。
|
|
17
|
+
*
|
|
18
|
+
* 纯函数:不读全局、无副作用(`env` 由调用方传入)⇒ 与 Python 一样可函数级单测,
|
|
19
|
+
* 不必真起进程。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 组装本次种子后的横幅片段。
|
|
24
|
+
* @param {{seeded?: boolean, seedPassword?: string, env?: NodeJS.ProcessEnv}} [boot]
|
|
25
|
+
* `seeded`/`seedPassword` 取自 `initDb()` 的返回值;`env` 缺省 `process.env`
|
|
26
|
+
* ——种子用的就是它,横幅判断与种子判断必须同源
|
|
27
|
+
* @returns {string} 本次实际种子时返回横幅片段(含前导两空格);日常启动返回空串
|
|
28
|
+
*/
|
|
29
|
+
export function seedHint({ seeded = false, seedPassword = '', env = process.env } = {}) {
|
|
30
|
+
if (!seeded) return '';
|
|
31
|
+
if (seedPassword) {
|
|
32
|
+
return ` (首次启动已创建默认账号 admin,一次性初始口令:${seedPassword}`
|
|
33
|
+
+ ',首次登录须修改;仅本次显示,请立即记录)';
|
|
34
|
+
}
|
|
35
|
+
if (env.TS_ADMIN_PASSWORD) {
|
|
36
|
+
return ' (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)';
|
|
37
|
+
}
|
|
38
|
+
return ' (首次启动已创建默认账号 admin,空口令(插件形态免登)'
|
|
39
|
+
+ ';如需口令登录请在设置页设置密码)';
|
|
40
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `server.py` 顶部那组「会话元信息派生」函数的 Node 对应物(M3 读路径):
|
|
3
|
+
* 能力声明、会话解析族归一、默认模型、有效模型、权限档反查、会话归属三态。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独一个文件而不是塞进路由:看板族(`routes/board.js`)与会话族
|
|
6
|
+
* (`routes/sessions.js`)**都要**用它们(`_api_get_board` 下发 family/capabilities,
|
|
7
|
+
* `_session_payload` 下发 model/capabilities/owned…),而这两族由不同的人写。
|
|
8
|
+
* 放在 Lead 名下的小文件里,两边都 import 同一份,不会出现「两处各写一份 capability 表」。
|
|
9
|
+
*
|
|
10
|
+
* 白名单常量(权限档/思考等级/反查表)**复用 `dshdriver.js` 的导出**,不另抄一份:
|
|
11
|
+
* Python 侧同样是 `DSH_PERMISSION_PRESETS = dshdriver.PERMISSION_PRESETS` 的别名。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
|
|
16
|
+
import * as dbmod from './db.js';
|
|
17
|
+
import * as dshevents from './dshevents.js';
|
|
18
|
+
import * as dshdriver from './dshdriver.js';
|
|
19
|
+
|
|
20
|
+
/** 思考等级白名单(`dshdriver.EFFORT_LEVELS`,与 Python 同源) */
|
|
21
|
+
export const DSH_EFFORT_LEVELS = dshdriver.EFFORT_LEVELS;
|
|
22
|
+
/** 平台三档 → 宿主 preset(`server.py:84`) */
|
|
23
|
+
export const DSH_PERMISSION_PRESETS = dshdriver.PERMISSION_PRESETS;
|
|
24
|
+
/** 宿主 preset → 平台三档近似反查(`server.py:88`) */
|
|
25
|
+
export const DSH_PRESET_MODES = dshdriver.PRESET_MODES;
|
|
26
|
+
|
|
27
|
+
/** `~/.dsh/settings.yaml` 的 agent-default-model 段:provider 行后的 model 行 */
|
|
28
|
+
const DSH_DEFAULT_MODEL_RE =
|
|
29
|
+
/^agent-default-model:[ \t]*$\n^[ \t]+provider:[ \t]*\S+[ \t]*$\n^[ \t]+model:[ \t]*([^\n#]+)/m;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Python `value or ""`:**按 Python 真值**判空(`0` / `False` / `''` / 空容器 → `""`)。
|
|
33
|
+
*
|
|
34
|
+
* 别写成 `String(value ?? '')`:那会把 `0` 归一成 `"0"`、把 `{}` 归一成 `"[object Object]"`
|
|
35
|
+
* ——`normalize_*` 的下一步是白名单检查,`"0"` 不在白名单 ⇒ 白白把一个「不指定」判成 400。
|
|
36
|
+
* (M5-4 实装 `POST /board/sessions/<sid>/profile` 时核出:Python 侧两个 normalize 都是
|
|
37
|
+
* `str(value or "")`,Node 侧原写法在 `0/False/{}` 三种入参上有分歧。)
|
|
38
|
+
*/
|
|
39
|
+
function pyOrEmpty(value) {
|
|
40
|
+
if (value === null || value === undefined || value === false || value === 0) return '';
|
|
41
|
+
if (typeof value === 'string') return value;
|
|
42
|
+
if (Array.isArray(value)) return value.length ? value : '';
|
|
43
|
+
if (typeof value === 'object') return Object.keys(value).length ? value : '';
|
|
44
|
+
return value; // 数字(含 NaN,Python 里为真)/ true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `normalize_effort`:''=不指定;白名单内原样;**非法返回 null**(调用方报 400,不静默丢弃)。
|
|
49
|
+
* 返回 `null` 与返回 `''` 语义不同,别用 `|| ''` 抹平。
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeEffort(value) {
|
|
52
|
+
const v = String(pyOrEmpty(value)).trim();
|
|
53
|
+
if (!v) return '';
|
|
54
|
+
return DSH_EFFORT_LEVELS.includes(v) ? v : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `normalize_permission_mode`:''=不指定;manual/yolo/auto 原样;非法返回 null */
|
|
58
|
+
export function normalizePermissionMode(value) {
|
|
59
|
+
const v = String(pyOrEmpty(value)).trim();
|
|
60
|
+
if (!v) return '';
|
|
61
|
+
return Object.prototype.hasOwnProperty.call(DSH_PERMISSION_PRESETS, v) ? v : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `_dsh_default_model`:读 `~/.dsh/settings.yaml` 的默认模型,任何失败返回 ''(不抛) */
|
|
65
|
+
export function dshDefaultModel() {
|
|
66
|
+
try {
|
|
67
|
+
const conf = `${os.homedir()}/.dsh/settings.yaml`;
|
|
68
|
+
if (fs.existsSync(conf)) {
|
|
69
|
+
const m = DSH_DEFAULT_MODEL_RE.exec(fs.readFileSync(conf, 'utf8'));
|
|
70
|
+
if (m && m[1].trim()) return m[1].trim().replace(/^["']|["']$/g, '');
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// 读不到/解析不了都按「无法判定」——与 Python 的 try/except pass 同口径
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `effective_model`:任务实际使用的模型名。
|
|
80
|
+
* ① 任务显式指定;② 首轮日志 `### CMD ` 行里的 `-m/--model`(存量 CLI 轮次日志);
|
|
81
|
+
* ③ dsh 部署默认模型。返回 '' = 完全无法判定(前端显示「默认」回退文案)。
|
|
82
|
+
*
|
|
83
|
+
* 注意 Python 的两处 `break`:**只看第一条有 log_path 且文件存在的轮次**,且该文件里
|
|
84
|
+
* 只看第一个 `### CMD ` 行(避免多次读文件)——这里逐点照抄,包括「文件不存在就跳到下一条」。
|
|
85
|
+
*/
|
|
86
|
+
export function effectiveModel(taskId, model, { db } = {}) {
|
|
87
|
+
const explicit = String(model ?? '').trim();
|
|
88
|
+
if (explicit) return explicit;
|
|
89
|
+
try {
|
|
90
|
+
for (const round of dbmod.listRounds(taskId, { db })) {
|
|
91
|
+
const logPath = round.log_path;
|
|
92
|
+
if (!logPath || !fs.existsSync(logPath) || !fs.statSync(logPath).isFile()) continue;
|
|
93
|
+
const text = fs.readFileSync(logPath, 'utf8');
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
if (line.startsWith('### CMD ')) {
|
|
96
|
+
const m = /(?:-m|--model)\s+(\S+)/.exec(line);
|
|
97
|
+
if (m) return m[1];
|
|
98
|
+
break; // 首个 CMD 行没带模型:不再往下找
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
break; // 只看第一条可读轮次(与 Python 同)
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// 日志读失败不阻断展示(Python 同为 except Exception: pass)
|
|
105
|
+
}
|
|
106
|
+
return dshDefaultModel();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `_sess_family`:dsh_plugin 的会话就是 dsh 宿主的会话,按 `dsh` 解析族读取 */
|
|
110
|
+
export function sessFamily(family) {
|
|
111
|
+
return family === 'dsh_plugin' ? 'dsh' : family;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `family_capabilities`:会话窗口能力声明(dsh_plugin 全开;退场族全 False) */
|
|
115
|
+
export function familyCapabilities(family) {
|
|
116
|
+
if (family === 'dsh_plugin') {
|
|
117
|
+
return {
|
|
118
|
+
stream: true, chat: true, queue: true, steer: true, abort: true,
|
|
119
|
+
compact: true, fork: true, rewind: true, profile: true, permission: true,
|
|
120
|
+
events: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
stream: false, chat: false, queue: false, steer: false, abort: false,
|
|
125
|
+
compact: false, fork: false, rewind: false, profile: false, permission: false,
|
|
126
|
+
events: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `_session_permission_mode`:会话级权限档。
|
|
132
|
+
* ① 平台记下的 `mode`(唯一能区分 yolo/auto 的来源);② 反查宿主 `preset`(老插件/外部改过)。
|
|
133
|
+
* 两者都无 → `''`(调用方**不下发** `permission`,前端保持置灰:不猜)。
|
|
134
|
+
*/
|
|
135
|
+
export function sessionPermissionMode(state) {
|
|
136
|
+
const perm = (state || {}).permission || {};
|
|
137
|
+
const mode = String(perm.mode || '');
|
|
138
|
+
if (mode) return mode;
|
|
139
|
+
return DSH_PRESET_MODES[String(perm.preset || '')] || '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `_session_owned`:会话归属**三态**。
|
|
144
|
+
* - `true` = 平台自持(驱动池内);
|
|
145
|
+
* - `false` = 外部会话(用户在 dsh GUI 直跑/接管,平台停不了 ⇒ 前端置灰「停止」);
|
|
146
|
+
* - `null` = 注册表未知(未连接/热对齐中/没见过该 sid)⇒ 前端按「池内」渲染。
|
|
147
|
+
*
|
|
148
|
+
* 为什么不是 `Boolean(...)`:外部会话上报的也是 `false`,与「未知」的兜底值撞车,
|
|
149
|
+
* 会把断连窗口里的池内会话误判成外部(误出提示条 + 误停用停止按钮)。**未知 ≠ 外部**。
|
|
150
|
+
*/
|
|
151
|
+
export function sessionOwned(sid) {
|
|
152
|
+
const st = dshevents.get(sid);
|
|
153
|
+
if (st === null || st === undefined || !dshevents.aligned()) return null;
|
|
154
|
+
return Boolean(st.owned);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `_dsh_live_sid`:dsh 插件族「运行中会话」精确探测(零 mtime 启发式)。
|
|
159
|
+
* 判据=驱动池自持(owned)+ cwd 匹配 + 最近创建;驱动不可用返回 ''(交调用方兜底)。
|
|
160
|
+
*
|
|
161
|
+
* **形态差异(有意)**:Node 的 `dshdriver.live()` 是 async(M2 的调用约定:
|
|
162
|
+
* 毫秒 + opts 对象 + async),故本函数也是 async;Python 侧是同步函数。
|
|
163
|
+
* 调用方(`_session_payload` 的 Node 对应物)本来就是 async,多一个 await 无碍。
|
|
164
|
+
*/
|
|
165
|
+
export async function dshLiveSid(cwd) {
|
|
166
|
+
let rows;
|
|
167
|
+
try {
|
|
168
|
+
rows = await dshdriver.live();
|
|
169
|
+
} catch {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
const want = String(cwd || '').replace(/\/+$/, '');
|
|
173
|
+
let best = '';
|
|
174
|
+
let bestTs = -1;
|
|
175
|
+
for (const row of rows || []) {
|
|
176
|
+
if (!row.owned) continue;
|
|
177
|
+
if (want && String(row.cwd || '').replace(/\/+$/, '') !== want) continue;
|
|
178
|
+
const ts = Number.parseInt(row.started_at, 10) || 0;
|
|
179
|
+
if (ts > bestTs) { best = row.session_id || ''; bestTs = ts; }
|
|
180
|
+
}
|
|
181
|
+
return best;
|
|
182
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `server.py` 顶层「会话/队列派生」helper 的 Node 对应物(M3 读路径 · 会话族)。
|
|
3
|
+
*
|
|
4
|
+
* 归属函数(Python 源在括号里):
|
|
5
|
+
* - `projectBusy` (`_project_busy`:319)
|
|
6
|
+
* - `unitState` (`_unit_state`:336,含 `c:` → `a:` 改键规则)
|
|
7
|
+
* - `boardSessionRunning` (`_board_session_running`:368)
|
|
8
|
+
* - `sessionQueueState` (`_session_queue_state`:428,八枚举判定序)
|
|
9
|
+
* - `boardOwnsSid` (`_board_owns_sid`:2765)
|
|
10
|
+
* - `boardOwned` (`_board_owned`:2748 的只读版)
|
|
11
|
+
* - `taskSince` (`_task_since`:4747)
|
|
12
|
+
*
|
|
13
|
+
* 命名口径:**函数 camelCase、返回字段沿用 Python 的 snake_case**(字段直接进 JSON
|
|
14
|
+
* 响应,改名就是改契约)。`{state,pos,total}` / `{pending,kind,…}` 这类形状逐字同 Python。
|
|
15
|
+
*
|
|
16
|
+
* ## M3 的边界(方案 §四,逐条在这里落地并注明)
|
|
17
|
+
* ① `board.runningMap()` / `chat.state` 的进程内登记表在 M3 恒空 ⇒ `boardSessionRunning`
|
|
18
|
+
* 的「平台在管」分支恒 false(与「刚起的 Python 实例」等价),实况 busy 分支照常走
|
|
19
|
+
* `dshevents`;真驱动下的非空分支由 M4/M5 接线后自然生效。
|
|
20
|
+
* ② 原先这里还有一个 `interactionOfSid` 占位(「调和器缓存属 M5 ⇒ 恒 null」)——M5 已把
|
|
21
|
+
* 调和器搬进 `board.js`,该占位成了**同名的错误实现**:路由误 import 它 ⇒ Node 后端下
|
|
22
|
+
* 会话窗永远拿不到待答提问/审批(用户报障卡 980)。2026-10-16 删除,读口只用
|
|
23
|
+
* `board.interactionOfSid`(缓存为空/无等待时自然为 null,键仍在 payload 里)。
|
|
24
|
+
*
|
|
25
|
+
* 数据层调用口径:M3 约定「自由函数 + 末位可选 `{db, dbPath, env}`」,本文件一律把
|
|
26
|
+
* `options` 原样透传给 `runner`/`board`/`db` 的读口(路由层传 `{db: ctx.db}`)。
|
|
27
|
+
*/
|
|
28
|
+
import * as board from './board.js';
|
|
29
|
+
import * as dbmod from './db.js';
|
|
30
|
+
import * as runner from './runner.js';
|
|
31
|
+
|
|
32
|
+
/** Python `int(s)` 的**严格**形态(`s` 是数字串才给值,其余 null) */
|
|
33
|
+
function pyIntOrNull(text) {
|
|
34
|
+
return /^[+-]?\d+$/.test(String(text)) ? Number.parseInt(text, 10) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `_project_busy(project_id)`:项目是否已有单元在跑(统一队列视角)。
|
|
39
|
+
*
|
|
40
|
+
* Python 在 `runner.INSTANCE is None`(单测/独立脚本)或异常时按 False;Node 侧
|
|
41
|
+
* `runner.unitBusy` 是自由函数(总是可读 wait_items),**没有「实例缺位」这一态**,
|
|
42
|
+
* 但对假值 projectId 与异常同样返回 False(调用方不因展示字段失败)。
|
|
43
|
+
*
|
|
44
|
+
* @param {number} projectId
|
|
45
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function projectBusy(projectId, options = {}) {
|
|
49
|
+
if (!projectId) return false;
|
|
50
|
+
try {
|
|
51
|
+
return Boolean(runner.unitBusy(projectId, options));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`[serverstate] projectBusy 降级为 false:${(error && error.message) || error}`);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `_unit_state(project_id, key)`:单元在统一队列中的态(会话窗「队列」徽标)。
|
|
60
|
+
*
|
|
61
|
+
* 任务会话传 `t:<task_id>`、看板卡片会话传 `c:<card_id>`;
|
|
62
|
+
* **已作答·待送达的卡片改按 `a:<cid>` 键求值**(P6 解冻:answer 等待项才有真实
|
|
63
|
+
* 位次;形状 `{state,pos,total}` 不变)。runner 异常 → `null`(前端按「未下发」不渲染)。
|
|
64
|
+
*
|
|
65
|
+
* 注意改键判定的位置与 Python 一致:`board.isAnswerPending` 的调用**不在** try 内
|
|
66
|
+
* (它自己有兜底),try 只包 `inst.unit_state`(Node:`runner.unitState`)。
|
|
67
|
+
*
|
|
68
|
+
* @param {number} projectId
|
|
69
|
+
* @param {string} key `t:<id>` / `c:<id>` / `a:<id>` / `m:<id>`
|
|
70
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
71
|
+
* @returns {{state:string,pos:number,total:number}|null}
|
|
72
|
+
*/
|
|
73
|
+
export function unitState(projectId, key, options = {}) {
|
|
74
|
+
if (!projectId) return null;
|
|
75
|
+
let resolved = String(key === null || key === undefined ? '' : key);
|
|
76
|
+
if (resolved.startsWith('c:')) {
|
|
77
|
+
const cid = pyIntOrNull(resolved.slice(2));
|
|
78
|
+
if (cid !== null && board.isAnswerPending(cid, options)) resolved = `a:${cid}`;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return runner.unitState(resolved, projectId, options);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`[serverstate] unitState 降级为 null:${(error && error.message) || error}`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `_board_session_running(owner_cid, proj, sid)`:看板会话运行态(meta.running)。
|
|
90
|
+
*
|
|
91
|
+
* 判定序与 Python 逐点同:无卡 → false;**已作答·待送达 → false**(平台已收下答案、
|
|
92
|
+
* 等空闲送达;会话实况仍 busy 但不算运行);平台在管(`board.runningMap()`)→ true;
|
|
93
|
+
* 否则会话实况 busy(含用户在 dsh 侧直跑的外部会话)。
|
|
94
|
+
*
|
|
95
|
+
* @param {number} ownerCid
|
|
96
|
+
* @param {object} proj 项目行
|
|
97
|
+
* @param {string} sid
|
|
98
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function boardSessionRunning(ownerCid, proj, sid, options = {}) {
|
|
102
|
+
if (!ownerCid) return false;
|
|
103
|
+
if (board.isAnswerPending(ownerCid, options)) return false;
|
|
104
|
+
// `board.runningMap()` 是 **plain object**(Python dict 的对应物,M3 恒空),不是 Map
|
|
105
|
+
const running = board.runningMap();
|
|
106
|
+
const key = String(ownerCid);
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(running, key) && running[key]) return true;
|
|
108
|
+
return board.webSessionBusy(proj, sid);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `_session_queue_state(...)`:session meta 的 `queue_state` 派生(八枚举,枚举值见
|
|
113
|
+
* `board.QS_*`)。
|
|
114
|
+
*
|
|
115
|
+
* 判定序(高→低):`answer_pending` > `server_queued` > `interaction_pending` >
|
|
116
|
+
* `running` > `starting` > `queued_serial`/`foreign_busy` > `idle`。
|
|
117
|
+
* 三个「pending」仅看板卡片会话在场(任务会话恒 false);`unit_state=running` 同样判
|
|
118
|
+
* running(**行口径**:本单元有运行中行);`queued` 分叉按项目是否有活跃 `ext:` 行
|
|
119
|
+
* (外部会话在跑)分 `foreign_busy`/`queued_serial`。
|
|
120
|
+
*
|
|
121
|
+
* @param {number} projectId
|
|
122
|
+
* @param {{state:string,pos:number,total:number}|null|undefined} unitStateValue
|
|
123
|
+
* @param {{running?:boolean, answerPending?:boolean, serverQueued?:boolean,
|
|
124
|
+
* starting?:boolean, interactionPending?:boolean}} [flags]
|
|
125
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
126
|
+
* @returns {string} `board.QS_*`
|
|
127
|
+
*/
|
|
128
|
+
export function sessionQueueState(projectId, unitStateValue, flags = {}, options = {}) {
|
|
129
|
+
const {
|
|
130
|
+
running = false, answerPending = false, serverQueued = false,
|
|
131
|
+
starting = false, interactionPending = false,
|
|
132
|
+
} = flags;
|
|
133
|
+
if (answerPending) return board.QS_ANSWER;
|
|
134
|
+
if (serverQueued) return board.QS_SERVER;
|
|
135
|
+
if (interactionPending) return board.QS_INTERACTION;
|
|
136
|
+
const st = (unitStateValue || {}).state;
|
|
137
|
+
if (running) return board.QS_RUNNING;
|
|
138
|
+
if (starting) return board.QS_STARTING;
|
|
139
|
+
if (st === 'running') return board.QS_RUNNING;
|
|
140
|
+
if (st === 'queued') {
|
|
141
|
+
if (projectId && board.extActive(projectId, options)) return board.QS_FOREIGN;
|
|
142
|
+
return board.QS_QUEUED;
|
|
143
|
+
}
|
|
144
|
+
return board.QS_IDLE;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `sessions` 列(JSON 数组文本)→ 字符串数组;坏 JSON 按空数组(Python 会抛,见文件头) */
|
|
148
|
+
function cardSessions(row) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(row.sessions || '[]');
|
|
151
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `_board_owns_sid(row, sid)`:sid 是否归属本项目卡片的会话并集
|
|
159
|
+
* (每卡 `sessions ∪ {session_id}`)。返回**归属卡片行**(调用方可读
|
|
160
|
+
* `id`/`column_key`/`worktree`/`block_kind` 等);不归属返回 `null`
|
|
161
|
+
* ——会话端点据此 404,防经会话端点读任意会话(跨项目读的红线)。
|
|
162
|
+
*
|
|
163
|
+
* @param {object} projectRow
|
|
164
|
+
* @param {string} sid
|
|
165
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
166
|
+
* @returns {object|null}
|
|
167
|
+
*/
|
|
168
|
+
export function boardOwnsSid(projectRow, sid, options = {}) {
|
|
169
|
+
if (!projectRow || !sid) return null;
|
|
170
|
+
for (const c of dbmod.listBoardCards(projectRow.id, options)) {
|
|
171
|
+
if (cardSessions(c).includes(sid)) return c;
|
|
172
|
+
if (c.session_id && sid === c.session_id) return c;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `_board_owned(project_id, card_id)` 的**只读版**:项目 + 卡片联合归属校验。
|
|
179
|
+
*
|
|
180
|
+
* 与 Python 的差异:Python 直接 `self._respond(404, …)`(有副作用),本函数只返回行,
|
|
181
|
+
* 由调用方决定回哪条 404——路由层已有 `ctx.ownedProject`(会回 `project not found`),
|
|
182
|
+
* 卡片缺失时调用方回 `card not found`,状态码/正文与 Python 一致。
|
|
183
|
+
*
|
|
184
|
+
* @param {number} projectId
|
|
185
|
+
* @param {number|null} [cardId] 不传=只校验项目
|
|
186
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
187
|
+
* @returns {{project: object|null, card: object|null}}
|
|
188
|
+
*/
|
|
189
|
+
export function boardOwned(projectId, cardId = null, options = {}) {
|
|
190
|
+
const project = dbmod.getProject(projectId, options);
|
|
191
|
+
if (project === null || project === undefined) return { project: null, card: null };
|
|
192
|
+
if (cardId === null || cardId === undefined) return { project, card: null };
|
|
193
|
+
const card = dbmod.getBoardCard(cardId, options);
|
|
194
|
+
if (card === null || card === undefined || card.project_id !== project.id) {
|
|
195
|
+
return { project, card: null };
|
|
196
|
+
}
|
|
197
|
+
return { project, card };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `_task_since(task)`:任务起始时间(epoch **秒**,本地时区,运行中会话探测的时间下界)。
|
|
202
|
+
* `started_at` 优先,回退 `created_at`;格式异常返回 `0`(探测据此直接放弃)。
|
|
203
|
+
*
|
|
204
|
+
* 与 Python `time.mktime(time.strptime(val, "%Y-%m-%d %H:%M:%S"))` 同口径:按**本地时区**
|
|
205
|
+
* 解释(两侧跑在同一台机器、同一 TZ)。
|
|
206
|
+
*
|
|
207
|
+
* @param {object} task 任务行
|
|
208
|
+
* @returns {number}
|
|
209
|
+
*/
|
|
210
|
+
export function taskSince(task) {
|
|
211
|
+
for (const key of ['started_at', 'created_at']) {
|
|
212
|
+
const val = task ? task[key] : '';
|
|
213
|
+
const ms = parseLocalSeconds(val);
|
|
214
|
+
if (ms !== null) return ms;
|
|
215
|
+
}
|
|
216
|
+
return 0.0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `%Y-%m-%d %H:%M:%S`(允许 1-2 位月/日/时分秒与两端空白)→ 本地时区 epoch 秒;不符 → null */
|
|
220
|
+
function parseLocalSeconds(val) {
|
|
221
|
+
const m = /^\s*(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/.exec(String(val ?? ''));
|
|
222
|
+
if (!m) return null;
|
|
223
|
+
const [, y, mo, d, h, mi, s] = m.map(Number);
|
|
224
|
+
const date = new Date(y, mo - 1, d, h, mi, s, 0);
|
|
225
|
+
const ms = date.getTime();
|
|
226
|
+
return Number.isNaN(ms) ? null : ms / 1000;
|
|
227
|
+
}
|