@gavinc-cn/touchstone-dsh 0.2.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/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 +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -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 +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -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 +405 -0
- package/backend/lib/proc.js +278 -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 +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -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 +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -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 +498 -0
- package/board.py +30 -1
- 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/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 +0 -0
- package/dsh-plugin/lib/index.js +299 -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 +0 -0
- 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_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/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -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,237 @@
|
|
|
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
|
+
* - `interactionOfSid` (`board.interaction_of_sid`:4688 的 M3 空实例口径)
|
|
13
|
+
*
|
|
14
|
+
* 命名口径:**函数 camelCase、返回字段沿用 Python 的 snake_case**(字段直接进 JSON
|
|
15
|
+
* 响应,改名就是改契约)。`{state,pos,total}` / `{pending,kind,…}` 这类形状逐字同 Python。
|
|
16
|
+
*
|
|
17
|
+
* ## M3 的两条边界(方案 §四,逐条在这里落地并注明)
|
|
18
|
+
* ① `board.runningMap()` / `chat.state` 的进程内登记表在 M3 恒空 ⇒ `boardSessionRunning`
|
|
19
|
+
* 的「平台在管」分支恒 false(与「刚起的 Python 实例」等价),实况 busy 分支照常走
|
|
20
|
+
* `dshevents`;真驱动下的非空分支由 M4/M5 接线后自然生效。
|
|
21
|
+
* ② `board.interaction_of_sid` 读调和器缓存 `_IW_CACHE`(属 M5)⇒ 本文件按**空缓存**
|
|
22
|
+
* 返回 `null`(`interaction: null` 仍在 payload 里,键集合与 Python 一致)。
|
|
23
|
+
*
|
|
24
|
+
* 数据层调用口径:M3 约定「自由函数 + 末位可选 `{db, dbPath, env}`」,本文件一律把
|
|
25
|
+
* `options` 原样透传给 `runner`/`board`/`db` 的读口(路由层传 `{db: ctx.db}`)。
|
|
26
|
+
*/
|
|
27
|
+
import * as board from './board.js';
|
|
28
|
+
import * as dbmod from './db.js';
|
|
29
|
+
import * as runner from './runner.js';
|
|
30
|
+
|
|
31
|
+
/** Python `int(s)` 的**严格**形态(`s` 是数字串才给值,其余 null) */
|
|
32
|
+
function pyIntOrNull(text) {
|
|
33
|
+
return /^[+-]?\d+$/.test(String(text)) ? Number.parseInt(text, 10) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `_project_busy(project_id)`:项目是否已有单元在跑(统一队列视角)。
|
|
38
|
+
*
|
|
39
|
+
* Python 在 `runner.INSTANCE is None`(单测/独立脚本)或异常时按 False;Node 侧
|
|
40
|
+
* `runner.unitBusy` 是自由函数(总是可读 wait_items),**没有「实例缺位」这一态**,
|
|
41
|
+
* 但对假值 projectId 与异常同样返回 False(调用方不因展示字段失败)。
|
|
42
|
+
*
|
|
43
|
+
* @param {number} projectId
|
|
44
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function projectBusy(projectId, options = {}) {
|
|
48
|
+
if (!projectId) return false;
|
|
49
|
+
try {
|
|
50
|
+
return Boolean(runner.unitBusy(projectId, options));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`[serverstate] projectBusy 降级为 false:${(error && error.message) || error}`);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `_unit_state(project_id, key)`:单元在统一队列中的态(会话窗「队列」徽标)。
|
|
59
|
+
*
|
|
60
|
+
* 任务会话传 `t:<task_id>`、看板卡片会话传 `c:<card_id>`;
|
|
61
|
+
* **已作答·待送达的卡片改按 `a:<cid>` 键求值**(P6 解冻:answer 等待项才有真实
|
|
62
|
+
* 位次;形状 `{state,pos,total}` 不变)。runner 异常 → `null`(前端按「未下发」不渲染)。
|
|
63
|
+
*
|
|
64
|
+
* 注意改键判定的位置与 Python 一致:`board.isAnswerPending` 的调用**不在** try 内
|
|
65
|
+
* (它自己有兜底),try 只包 `inst.unit_state`(Node:`runner.unitState`)。
|
|
66
|
+
*
|
|
67
|
+
* @param {number} projectId
|
|
68
|
+
* @param {string} key `t:<id>` / `c:<id>` / `a:<id>` / `m:<id>`
|
|
69
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
70
|
+
* @returns {{state:string,pos:number,total:number}|null}
|
|
71
|
+
*/
|
|
72
|
+
export function unitState(projectId, key, options = {}) {
|
|
73
|
+
if (!projectId) return null;
|
|
74
|
+
let resolved = String(key === null || key === undefined ? '' : key);
|
|
75
|
+
if (resolved.startsWith('c:')) {
|
|
76
|
+
const cid = pyIntOrNull(resolved.slice(2));
|
|
77
|
+
if (cid !== null && board.isAnswerPending(cid, options)) resolved = `a:${cid}`;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return runner.unitState(resolved, projectId, options);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error(`[serverstate] unitState 降级为 null:${(error && error.message) || error}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `_board_session_running(owner_cid, proj, sid)`:看板会话运行态(meta.running)。
|
|
89
|
+
*
|
|
90
|
+
* 判定序与 Python 逐点同:无卡 → false;**已作答·待送达 → false**(平台已收下答案、
|
|
91
|
+
* 等空闲送达;会话实况仍 busy 但不算运行);平台在管(`board.runningMap()`)→ true;
|
|
92
|
+
* 否则会话实况 busy(含用户在 dsh 侧直跑的外部会话)。
|
|
93
|
+
*
|
|
94
|
+
* @param {number} ownerCid
|
|
95
|
+
* @param {object} proj 项目行
|
|
96
|
+
* @param {string} sid
|
|
97
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function boardSessionRunning(ownerCid, proj, sid, options = {}) {
|
|
101
|
+
if (!ownerCid) return false;
|
|
102
|
+
if (board.isAnswerPending(ownerCid, options)) return false;
|
|
103
|
+
// `board.runningMap()` 是 **plain object**(Python dict 的对应物,M3 恒空),不是 Map
|
|
104
|
+
const running = board.runningMap();
|
|
105
|
+
const key = String(ownerCid);
|
|
106
|
+
if (Object.prototype.hasOwnProperty.call(running, key) && running[key]) return true;
|
|
107
|
+
return board.webSessionBusy(proj, sid);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `_session_queue_state(...)`:session meta 的 `queue_state` 派生(八枚举,枚举值见
|
|
112
|
+
* `board.QS_*`)。
|
|
113
|
+
*
|
|
114
|
+
* 判定序(高→低):`answer_pending` > `server_queued` > `interaction_pending` >
|
|
115
|
+
* `running` > `starting` > `queued_serial`/`foreign_busy` > `idle`。
|
|
116
|
+
* 三个「pending」仅看板卡片会话在场(任务会话恒 false);`unit_state=running` 同样判
|
|
117
|
+
* running(**行口径**:本单元有运行中行);`queued` 分叉按项目是否有活跃 `ext:` 行
|
|
118
|
+
* (外部会话在跑)分 `foreign_busy`/`queued_serial`。
|
|
119
|
+
*
|
|
120
|
+
* @param {number} projectId
|
|
121
|
+
* @param {{state:string,pos:number,total:number}|null|undefined} unitStateValue
|
|
122
|
+
* @param {{running?:boolean, answerPending?:boolean, serverQueued?:boolean,
|
|
123
|
+
* starting?:boolean, interactionPending?:boolean}} [flags]
|
|
124
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
125
|
+
* @returns {string} `board.QS_*`
|
|
126
|
+
*/
|
|
127
|
+
export function sessionQueueState(projectId, unitStateValue, flags = {}, options = {}) {
|
|
128
|
+
const {
|
|
129
|
+
running = false, answerPending = false, serverQueued = false,
|
|
130
|
+
starting = false, interactionPending = false,
|
|
131
|
+
} = flags;
|
|
132
|
+
if (answerPending) return board.QS_ANSWER;
|
|
133
|
+
if (serverQueued) return board.QS_SERVER;
|
|
134
|
+
if (interactionPending) return board.QS_INTERACTION;
|
|
135
|
+
const st = (unitStateValue || {}).state;
|
|
136
|
+
if (running) return board.QS_RUNNING;
|
|
137
|
+
if (starting) return board.QS_STARTING;
|
|
138
|
+
if (st === 'running') return board.QS_RUNNING;
|
|
139
|
+
if (st === 'queued') {
|
|
140
|
+
if (projectId && board.extActive(projectId, options)) return board.QS_FOREIGN;
|
|
141
|
+
return board.QS_QUEUED;
|
|
142
|
+
}
|
|
143
|
+
return board.QS_IDLE;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `sessions` 列(JSON 数组文本)→ 字符串数组;坏 JSON 按空数组(Python 会抛,见文件头) */
|
|
147
|
+
function cardSessions(row) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(row.sessions || '[]');
|
|
150
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `_board_owns_sid(row, sid)`:sid 是否归属本项目卡片的会话并集
|
|
158
|
+
* (每卡 `sessions ∪ {session_id}`)。返回**归属卡片行**(调用方可读
|
|
159
|
+
* `id`/`column_key`/`worktree`/`block_kind` 等);不归属返回 `null`
|
|
160
|
+
* ——会话端点据此 404,防经会话端点读任意会话(跨项目读的红线)。
|
|
161
|
+
*
|
|
162
|
+
* @param {object} projectRow
|
|
163
|
+
* @param {string} sid
|
|
164
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
165
|
+
* @returns {object|null}
|
|
166
|
+
*/
|
|
167
|
+
export function boardOwnsSid(projectRow, sid, options = {}) {
|
|
168
|
+
if (!projectRow || !sid) return null;
|
|
169
|
+
for (const c of dbmod.listBoardCards(projectRow.id, options)) {
|
|
170
|
+
if (cardSessions(c).includes(sid)) return c;
|
|
171
|
+
if (c.session_id && sid === c.session_id) return c;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `_board_owned(project_id, card_id)` 的**只读版**:项目 + 卡片联合归属校验。
|
|
178
|
+
*
|
|
179
|
+
* 与 Python 的差异:Python 直接 `self._respond(404, …)`(有副作用),本函数只返回行,
|
|
180
|
+
* 由调用方决定回哪条 404——路由层已有 `ctx.ownedProject`(会回 `project not found`),
|
|
181
|
+
* 卡片缺失时调用方回 `card not found`,状态码/正文与 Python 一致。
|
|
182
|
+
*
|
|
183
|
+
* @param {number} projectId
|
|
184
|
+
* @param {number|null} [cardId] 不传=只校验项目
|
|
185
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
186
|
+
* @returns {{project: object|null, card: object|null}}
|
|
187
|
+
*/
|
|
188
|
+
export function boardOwned(projectId, cardId = null, options = {}) {
|
|
189
|
+
const project = dbmod.getProject(projectId, options);
|
|
190
|
+
if (project === null || project === undefined) return { project: null, card: null };
|
|
191
|
+
if (cardId === null || cardId === undefined) return { project, card: null };
|
|
192
|
+
const card = dbmod.getBoardCard(cardId, options);
|
|
193
|
+
if (card === null || card === undefined || card.project_id !== project.id) {
|
|
194
|
+
return { project, card: null };
|
|
195
|
+
}
|
|
196
|
+
return { project, card };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `board.interaction_of_sid(sid)` 的 M3 口径:调和器缓存 `_IW_CACHE` 属 M5 ⇒ **恒 null**
|
|
201
|
+
* (=「刚起的 Python 实例」)。
|
|
202
|
+
*
|
|
203
|
+
* 为什么仍然发这个键:Python payload 里 `interaction` 一定在场(值可能是 null),
|
|
204
|
+
* 少发键会被对拍器按「键集合差异」抓到,也会让前端多一处 `undefined` 分支。
|
|
205
|
+
*/
|
|
206
|
+
export function interactionOfSid(sid) {
|
|
207
|
+
return sid ? null : null; // 保留 sid 形参只为与 Python 同名同签名
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `_task_since(task)`:任务起始时间(epoch **秒**,本地时区,运行中会话探测的时间下界)。
|
|
212
|
+
* `started_at` 优先,回退 `created_at`;格式异常返回 `0`(探测据此直接放弃)。
|
|
213
|
+
*
|
|
214
|
+
* 与 Python `time.mktime(time.strptime(val, "%Y-%m-%d %H:%M:%S"))` 同口径:按**本地时区**
|
|
215
|
+
* 解释(两侧跑在同一台机器、同一 TZ)。
|
|
216
|
+
*
|
|
217
|
+
* @param {object} task 任务行
|
|
218
|
+
* @returns {number}
|
|
219
|
+
*/
|
|
220
|
+
export function taskSince(task) {
|
|
221
|
+
for (const key of ['started_at', 'created_at']) {
|
|
222
|
+
const val = task ? task[key] : '';
|
|
223
|
+
const ms = parseLocalSeconds(val);
|
|
224
|
+
if (ms !== null) return ms;
|
|
225
|
+
}
|
|
226
|
+
return 0.0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `%Y-%m-%d %H:%M:%S`(允许 1-2 位月/日/时分秒与两端空白)→ 本地时区 epoch 秒;不符 → null */
|
|
230
|
+
function parseLocalSeconds(val) {
|
|
231
|
+
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 ?? ''));
|
|
232
|
+
if (!m) return null;
|
|
233
|
+
const [, y, mo, d, h, mi, s] = m.map(Number);
|
|
234
|
+
const date = new Date(y, mo - 1, d, h, mi, s, 0);
|
|
235
|
+
const ms = date.getTime();
|
|
236
|
+
return Number.isNaN(ms) ? null : ms / 1000;
|
|
237
|
+
}
|