dsh-mobile-plugin 0.1.96 → 0.1.98
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/README.md +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/index.js +23 -29
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +153 -6
- package/lib/update-install.cjs +11 -0
- package/package.json +3 -2
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「定时任务」API:把宿主 `ScheduleService` 的四件事转给手机。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端有一套完整的定时任务:任务挂在会话上,到点把 prompt 投进会话,运行记录留在
|
|
7
|
+
* 宿主里。手机端此前一条都看不到 —— 既不知道这个会话排了什么,也没法读「上次跑到
|
|
8
|
+
* 什么时候」或者删掉一条排错的任务。本文件是宿主 `schedule` 服务的移动端代理。
|
|
9
|
+
*
|
|
10
|
+
* 服务走同进程直查 `ctx.get('schedule')`,与 mobile/directory-browse.js 取
|
|
11
|
+
* `directoryPicker` 是同一个先例:它是 Typert Remote 服务,不在 apiProxy 的命名空间
|
|
12
|
+
* 里,写进 `inject` 反而会因为老宿主没有这个服务而挡住整个插件。
|
|
13
|
+
*
|
|
14
|
+
* 四条接口对应宿主的四个公开方法:
|
|
15
|
+
* - GET /api/mobile/v1/schedule/catalog → `catalog()` 全部任务(active + inactive)
|
|
16
|
+
* - GET /api/mobile/v1/schedule/list → `catalog()` 里筛出该会话的任务(**active + inactive**)
|
|
17
|
+
* - GET /api/mobile/v1/schedule/history → `history({...})` 一条任务的运行记录
|
|
18
|
+
* - POST /api/mobile/v1/schedule/delete → `delete({...})` 硬删
|
|
19
|
+
*
|
|
20
|
+
* `update` 这一批不做,别顺手接上:手机端暂时没有编辑入口,接一条没人调的路只会多一份
|
|
21
|
+
* 要维护的参数校验。
|
|
22
|
+
*
|
|
23
|
+
* 宿主 catalog 的每项是「裸记录 + sessionId + status + 可选 lastDelivery」。手机只认一种
|
|
24
|
+
* 形状:sessionId 必须带上,状态统一收成 `state`,删除 / 历史 / 修改都必须带行上的
|
|
25
|
+
* sessionId,否则宿主恒回 not_found。
|
|
26
|
+
*
|
|
27
|
+
* **`list` 为什么不用宿主的 `list({sessionId})`**(2026-10-23 改):宿主那个方法只回
|
|
28
|
+
* **还在跑**的任务、而且回的是裸记录(没有 sessionId / status)。于是手机上按对话看任务时,
|
|
29
|
+
* 一次性任务跑完就从这个入口消失了 —— 用户看到的是「任务凭空没了」,实际只是「结束了」。
|
|
30
|
+
* 现在改成从 `catalog()`(active + inactive 全都有、行上带 status)里按 sessionId 筛,
|
|
31
|
+
* 手机端因此能列出这条会话的全部任务(含已结束),筛选里那三档也才真正有意义。
|
|
32
|
+
* 代价是一次多拉这份目录,它是宿主内存里的账、不是磁盘扫描,与统一页那一发等价。
|
|
33
|
+
*
|
|
34
|
+
* 收敛规则与 mobile/jobs.js 同口径:必填(id / kind / title / sessionId)坏了丢整条,
|
|
35
|
+
* 可选字段坏了只丢字段 —— 少一个「下次运行时间」只是不显示,整条消失才是信息丢失。
|
|
36
|
+
* 超长一律截断而不是丢整条:宿主已经校验过形状,这里的上限只防异常宿主把手机端列表
|
|
37
|
+
* 撑爆。
|
|
38
|
+
*
|
|
39
|
+
* 服务不存在(老宿主)必须回 503 而不是空列表:App 要能分辨「这台电脑没有这个功能」
|
|
40
|
+
* 和「一条任务都没有」,后者画空状态、前者画「升级宿主」。宿主调用抛错走 502,把原文
|
|
41
|
+
* 带给 App —— 定时任务这块的错误多半是宿主的,插件不该换成一句「操作失败」。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
45
|
+
|
|
46
|
+
const SCHEDULE_MAX_ENTRIES = 500; // 单帧条数上限
|
|
47
|
+
const SCHEDULE_MAX_RECORDS = 100; // 单次历史记录条数上限(宿主 limit 最大 100)
|
|
48
|
+
const SCHEDULE_ID_MAX = 200; // 任务 id / messageId 长度上限
|
|
49
|
+
const SCHEDULE_TITLE_MAX = 200; // 任务标题
|
|
50
|
+
const SCHEDULE_PROMPT_MAX = 4000; // prompt(列表里只用于预览)
|
|
51
|
+
const SCHEDULE_EXPRESSION_MAX = 200; // cron 五段表达式
|
|
52
|
+
const SCHEDULE_TIMEZONE_MAX = 64; // IANA 时区名
|
|
53
|
+
const SCHEDULE_TIME_MAX = 40; // HH:mm:ss.SSS 与 RFC3339 时间戳
|
|
54
|
+
const SCHEDULE_DEFAULT_LIMIT = 20; // 历史记录默认条数
|
|
55
|
+
const SCHEDULE_MAX_LIMIT = 100; // 历史记录条数上限(宿主同一口径)
|
|
56
|
+
|
|
57
|
+
/** 宿主 ScheduleRecord 的封闭 kind 集。 */
|
|
58
|
+
const SCHEDULE_KINDS = new Set(['after', 'at', 'every', 'daily', 'weekly', 'cron']);
|
|
59
|
+
|
|
60
|
+
/** 宿主没有这个服务时对 App 的统一回答(不是「没有任务」)。 */
|
|
61
|
+
const UNAVAILABLE = Object.freeze({
|
|
62
|
+
code: 'schedule-unavailable',
|
|
63
|
+
message: '宿主没有定时任务服务',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** 必填文本:非字符串或全空白 → '';超长截断。 */
|
|
67
|
+
function requiredText(value, max) {
|
|
68
|
+
if (typeof value !== 'string') return '';
|
|
69
|
+
const text = value.trim();
|
|
70
|
+
if (!text) return '';
|
|
71
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 可选文本:非法/空白 → undefined;超长截断(不补省略号,前端不显示标记)。 */
|
|
75
|
+
function optionalText(value, max) {
|
|
76
|
+
if (typeof value !== 'string') return undefined;
|
|
77
|
+
const text = value.trim();
|
|
78
|
+
if (!text) return undefined;
|
|
79
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** prompt 是正文,别 trim:开头的换行对模型有意义。 */
|
|
83
|
+
function promptText(value) {
|
|
84
|
+
if (typeof value !== 'string' || value.length === 0) return '';
|
|
85
|
+
return value.length > SCHEDULE_PROMPT_MAX ? value.slice(0, SCHEDULE_PROMPT_MAX) : value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 有限正数,否则 undefined(everySeconds / afterSeconds 都只在这个域里)。 */
|
|
89
|
+
function optionalPositive(value) {
|
|
90
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** ISO 周几数组:1–7 的整数,去重升序;空数组或混入非法值 → undefined。 */
|
|
94
|
+
function optionalWeekdays(value) {
|
|
95
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
96
|
+
const days = new Set();
|
|
97
|
+
for (const day of value) {
|
|
98
|
+
if (!Number.isInteger(day) || day < 1 || day > 7) return undefined;
|
|
99
|
+
days.add(day);
|
|
100
|
+
}
|
|
101
|
+
return [...days].sort((a, b) => a - b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 非负整数,否则 undefined。 */
|
|
105
|
+
function optionalCount(value) {
|
|
106
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 一条运行记录:messageId 是游标,必须留着;时间字段坏了只丢字段。 */
|
|
110
|
+
function normalizeDeliveryRecord(item) {
|
|
111
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
112
|
+
const messageId = requiredText(item.messageId, SCHEDULE_ID_MAX);
|
|
113
|
+
if (!messageId) return null;
|
|
114
|
+
const scheduledAt = optionalText(item.scheduledAt, SCHEDULE_TIME_MAX);
|
|
115
|
+
const deliveredAt = optionalText(item.deliveredAt, SCHEDULE_TIME_MAX);
|
|
116
|
+
const prompt = optionalText(item.prompt, SCHEDULE_PROMPT_MAX);
|
|
117
|
+
return {
|
|
118
|
+
messageId,
|
|
119
|
+
...(scheduledAt === undefined ? {} : { scheduledAt }),
|
|
120
|
+
...(deliveredAt === undefined ? {} : { deliveredAt }),
|
|
121
|
+
...(prompt === undefined ? {} : { prompt }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 宿主回的最后一次投递:两个时间都在才留,缺一个就当没有。 */
|
|
126
|
+
function normalizeLastDelivery(value) {
|
|
127
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
128
|
+
const scheduledAt = optionalText(value.scheduledAt, SCHEDULE_TIME_MAX);
|
|
129
|
+
const deliveredAt = optionalText(value.deliveredAt, SCHEDULE_TIME_MAX);
|
|
130
|
+
if (scheduledAt === undefined || deliveredAt === undefined) return undefined;
|
|
131
|
+
return { scheduledAt, deliveredAt };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 把一条宿主任务压成手机认的 `ScheduleEntry`。
|
|
136
|
+
*
|
|
137
|
+
* `sessionId` / `state` 由调用方给:catalog 用行上的(`status` → `state`),list 用查询串
|
|
138
|
+
* 里的会话 id 且恒为 active —— list 只回裸记录,行上没有这两格。
|
|
139
|
+
*
|
|
140
|
+
* 必填坏了丢整条;`scheduledAt` 坏了只丢字段(App 因此不显示下次运行时间,但任务本身
|
|
141
|
+
* 还在);kind 专有字段只认自己那一种,混进来的别种字段一律不转发。
|
|
142
|
+
*/
|
|
143
|
+
function normalizeScheduleEntry(item, sessionId, state) {
|
|
144
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
145
|
+
const id = requiredText(item.id, SCHEDULE_ID_MAX);
|
|
146
|
+
if (!id) return null;
|
|
147
|
+
if (typeof item.kind !== 'string' || !SCHEDULE_KINDS.has(item.kind)) return null;
|
|
148
|
+
const title = requiredText(item.title, SCHEDULE_TITLE_MAX);
|
|
149
|
+
if (!title) return null;
|
|
150
|
+
// catalog 的 sessionId 在行上,list 的行上没有(由查询串那个顶上)。
|
|
151
|
+
const resolvedSessionId = sessionId || requiredText(item.sessionId, SCHEDULE_ID_MAX);
|
|
152
|
+
if (!resolvedSessionId) return null;
|
|
153
|
+
|
|
154
|
+
const entry = {
|
|
155
|
+
id,
|
|
156
|
+
sessionId: resolvedSessionId,
|
|
157
|
+
kind: item.kind,
|
|
158
|
+
title,
|
|
159
|
+
prompt: promptText(item.prompt),
|
|
160
|
+
state: state || stateOf(item.status),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const scheduledAt = optionalText(item.scheduledAt, SCHEDULE_TIME_MAX);
|
|
164
|
+
if (scheduledAt !== undefined) entry.scheduledAt = scheduledAt;
|
|
165
|
+
|
|
166
|
+
if (item.kind === 'after' || item.kind === 'every') {
|
|
167
|
+
const seconds = optionalPositive(item.kind === 'after' ? item.afterSeconds : item.everySeconds);
|
|
168
|
+
if (seconds !== undefined) {
|
|
169
|
+
if (item.kind === 'after') entry.afterSeconds = seconds;
|
|
170
|
+
else entry.everySeconds = seconds;
|
|
171
|
+
}
|
|
172
|
+
} else if (item.kind !== 'at') {
|
|
173
|
+
// `at` 没有专有字段,别把宿主残留的 time / expression 顺手转发出去。
|
|
174
|
+
const time = optionalText(item.time, SCHEDULE_TIME_MAX);
|
|
175
|
+
const timeZone = optionalText(item.timeZone, SCHEDULE_TIMEZONE_MAX);
|
|
176
|
+
if (time !== undefined) entry.time = time;
|
|
177
|
+
if (timeZone !== undefined) entry.timeZone = timeZone;
|
|
178
|
+
if (item.kind === 'weekly') {
|
|
179
|
+
const weekdays = optionalWeekdays(item.weekdays);
|
|
180
|
+
if (weekdays !== undefined) entry.weekdays = weekdays;
|
|
181
|
+
} else if (item.kind === 'cron') {
|
|
182
|
+
const expression = optionalText(item.expression, SCHEDULE_EXPRESSION_MAX);
|
|
183
|
+
if (expression !== undefined) entry.expression = expression;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const lastDelivery = normalizeLastDelivery(item.lastDelivery);
|
|
188
|
+
if (lastDelivery !== undefined) entry.lastDelivery = lastDelivery;
|
|
189
|
+
|
|
190
|
+
return entry;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 把宿主回的数组收敛成手机列表:非数组当空;坏条目丢掉;按 id 去重(保留先出现的);
|
|
195
|
+
* 截到条数上限。catalog 与 list 共用同一套规则,所以两边形状天然一致。
|
|
196
|
+
*/
|
|
197
|
+
function normalizeScheduleEntries(value, sessionId, state) {
|
|
198
|
+
const raw = Array.isArray(value)
|
|
199
|
+
? value
|
|
200
|
+
: (value && typeof value === 'object' && Array.isArray(value.records) ? value.records
|
|
201
|
+
: (value && typeof value === 'object' && Array.isArray(value.items) ? value.items : []));
|
|
202
|
+
const out = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const item of raw) {
|
|
205
|
+
const entry = normalizeScheduleEntry(item, sessionId, state);
|
|
206
|
+
if (!entry || seen.has(entry.id)) continue;
|
|
207
|
+
seen.add(entry.id);
|
|
208
|
+
out.push(entry);
|
|
209
|
+
if (out.length >= SCHEDULE_MAX_ENTRIES) break;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 宿主的 `status` → 手机认的 `state`。缺字段时按 active:宁可多显示一条,别整条消失。 */
|
|
215
|
+
function stateOf(status) {
|
|
216
|
+
return status === 'inactive' ? 'inactive' : 'active';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 错误文字:宿主 reject 的原文,取不到就退回 String(),别回一句空话。 */
|
|
220
|
+
function errorText(error) {
|
|
221
|
+
if (error === undefined || error === null) return '未知错误';
|
|
222
|
+
if (typeof error.message === 'string' && error.message) return error.message;
|
|
223
|
+
return String(error);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 宿主服务:同进程直查,拿不到就是老宿主(或极简 profile 没装)。
|
|
228
|
+
*
|
|
229
|
+
* 用 `ctx.get` 而不是 inject —— 与 directory-browse 的理由相同:本插件不依赖它,
|
|
230
|
+
* 缺了只该让这四条路由回 503,不该让整个插件装不起来。
|
|
231
|
+
*/
|
|
232
|
+
function scheduleService(ctx) {
|
|
233
|
+
try {
|
|
234
|
+
if (!ctx || typeof ctx.get !== 'function') return null;
|
|
235
|
+
return ctx.get('schedule') || null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 503:宿主没有这个能力(App 据此说「这台电脑没有这个功能」)。 */
|
|
242
|
+
function sendUnavailable(res) {
|
|
243
|
+
sendJson(res, 503, { ok: false, error: { ...UNAVAILABLE } });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 502:宿主调用失败。带已知错误码(`invalid_rule` 之类)就原样透传,App 能按码分支;
|
|
248
|
+
* 否则统一 `schedule_failed`。
|
|
249
|
+
*/
|
|
250
|
+
function sendFailure(res, error) {
|
|
251
|
+
const code = error && typeof error.code === 'string' && error.code.trim()
|
|
252
|
+
? error.code.trim()
|
|
253
|
+
: 'schedule_failed';
|
|
254
|
+
sendJson(res, 502, { ok: false, error: { code, message: errorText(error) } });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 从查询串取一个 trim 过的字符串参数。 */
|
|
258
|
+
function queryValue(url, key) {
|
|
259
|
+
return (url.searchParams.get(key) || '').trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** GET /api/mobile/v1/schedule/catalog —— 全部任务(active + inactive)。 */
|
|
263
|
+
async function handleScheduleCatalog(ctx, _req, res) {
|
|
264
|
+
const service = scheduleService(ctx);
|
|
265
|
+
if (!service) return sendUnavailable(res);
|
|
266
|
+
|
|
267
|
+
let value;
|
|
268
|
+
try {
|
|
269
|
+
value = await service.catalog();
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return sendFailure(res, error);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// `serverNow` 给手机算「还有多久 / 是不是逾期」:手机与电脑的钟不一定对得上,一律以
|
|
275
|
+
// 宿主时间为准,不要在 App 里拿本地 Date.now() 减 scheduledAt。
|
|
276
|
+
sendJson(res, 200, {
|
|
277
|
+
ok: true,
|
|
278
|
+
records: normalizeScheduleEntries(value, undefined, undefined),
|
|
279
|
+
serverNow: Date.now(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* GET /api/mobile/v1/schedule/list?sessionId=… —— 该会话的全部任务(active + inactive)。
|
|
285
|
+
*
|
|
286
|
+
* 走 `catalog()` 再按 sessionId 筛,而不是宿主的 `list({sessionId})`:后者只回还在跑的,
|
|
287
|
+
* 且行上没有 sessionId / status(见文件头)。`state` 因此由行上的 status 推出来,
|
|
288
|
+
* 已结束的任务才能如实显示成「已结束」。
|
|
289
|
+
*/
|
|
290
|
+
async function handleScheduleList(ctx, req, res) {
|
|
291
|
+
const url = new URL(req.url, 'http://x');
|
|
292
|
+
const sessionId = queryValue(url, 'sessionId');
|
|
293
|
+
if (!sessionId) {
|
|
294
|
+
return sendJson(res, 400, { ok: false, error: { code: 'invalid_selector', message: '需要 sessionId' } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const service = scheduleService(ctx);
|
|
298
|
+
if (!service) return sendUnavailable(res);
|
|
299
|
+
|
|
300
|
+
let value;
|
|
301
|
+
try {
|
|
302
|
+
value = await service.catalog();
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return sendFailure(res, error);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 先按目录整体收敛(保住 status → state 的推导),再筛这条会话的。
|
|
308
|
+
const all = normalizeScheduleEntries(value, undefined, undefined);
|
|
309
|
+
sendJson(res, 200, {
|
|
310
|
+
ok: true,
|
|
311
|
+
records: all.filter((entry) => entry.sessionId === sessionId),
|
|
312
|
+
serverNow: Date.now(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 把查询串里的 limit 收成 1–100 的整数;非法返回 0。 */
|
|
317
|
+
function parseLimit(raw) {
|
|
318
|
+
if (raw === null) return SCHEDULE_DEFAULT_LIMIT;
|
|
319
|
+
if (!/^\d+$/.test(raw.trim())) return 0;
|
|
320
|
+
const limit = Number(raw);
|
|
321
|
+
return Number.isInteger(limit) && limit >= 1 && limit <= SCHEDULE_MAX_LIMIT ? limit : 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 运行记录的 retention:两个非负数,坏了按 0(App 只是据此说「更早的读不到了」)。 */
|
|
325
|
+
function normalizeRetention(value) {
|
|
326
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
327
|
+
return {
|
|
328
|
+
days: optionalCount(source.days) ?? 0,
|
|
329
|
+
records: optionalCount(source.records) ?? 0,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** GET /api/mobile/v1/schedule/history?sessionId=…&id=…&limit=20&before=… */
|
|
334
|
+
async function handleScheduleHistory(ctx, req, res) {
|
|
335
|
+
const url = new URL(req.url, 'http://x');
|
|
336
|
+
const sessionId = queryValue(url, 'sessionId');
|
|
337
|
+
const id = queryValue(url, 'id');
|
|
338
|
+
if (!sessionId || !id) {
|
|
339
|
+
return sendJson(res, 400, { ok: false, error: { code: 'invalid_selector', message: '需要 sessionId 与 id' } });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
343
|
+
if (limit === 0) {
|
|
344
|
+
return sendJson(res, 400, {
|
|
345
|
+
ok: false,
|
|
346
|
+
error: {
|
|
347
|
+
code: 'invalid_limit',
|
|
348
|
+
message: `limit 必须是 1–${SCHEDULE_MAX_LIMIT} 的整数`,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const service = scheduleService(ctx);
|
|
354
|
+
if (!service) return sendUnavailable(res);
|
|
355
|
+
|
|
356
|
+
const payload = { sessionId, id, limit };
|
|
357
|
+
const before = queryValue(url, 'before');
|
|
358
|
+
if (before) payload.before = before;
|
|
359
|
+
|
|
360
|
+
let value;
|
|
361
|
+
try {
|
|
362
|
+
value = await service.history(payload);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return sendFailure(res, error);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const source = value && typeof value === 'object' ? value : {};
|
|
368
|
+
if (source.code === 'schedule_not_found') {
|
|
369
|
+
return sendJson(res, 404, { ok: false, error: { code: 'schedule_not_found', message: '没有这条定时任务' } });
|
|
370
|
+
}
|
|
371
|
+
if (source.code === 'delivery_cursor_not_found') {
|
|
372
|
+
return sendJson(res, 409, { ok: false, error: { code: 'delivery_cursor_not_found', message: '运行记录游标已失效' } });
|
|
373
|
+
}
|
|
374
|
+
if (source.code) {
|
|
375
|
+
return sendFailure(res, { code: 'schedule_failed', message: `宿主回了未知结果码:${source.code}` });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const records = [];
|
|
379
|
+
for (const item of Array.isArray(source.records) ? source.records : []) {
|
|
380
|
+
const record = normalizeDeliveryRecord(item);
|
|
381
|
+
if (!record) continue;
|
|
382
|
+
records.push(record);
|
|
383
|
+
if (records.length >= SCHEDULE_MAX_RECORDS) break;
|
|
384
|
+
}
|
|
385
|
+
const nextBefore = optionalText(source.nextBefore, SCHEDULE_ID_MAX);
|
|
386
|
+
|
|
387
|
+
sendJson(res, 200, {
|
|
388
|
+
ok: true,
|
|
389
|
+
id: requiredText(source.id, SCHEDULE_ID_MAX) || id,
|
|
390
|
+
records,
|
|
391
|
+
// 两个标记都原样带上:unavailable 是「更早的记录本来就不可用」,pruned 是「按保留
|
|
392
|
+
// 策略清掉了」,App 对前者说「读不到了」、对后者说「已过期清理」,不能合并。
|
|
393
|
+
earlierRecordsUnavailable: source.earlierRecordsUnavailable === true,
|
|
394
|
+
earlierRecordsPruned: source.earlierRecordsPruned === true,
|
|
395
|
+
retention: normalizeRetention(source.retention),
|
|
396
|
+
...(nextBefore === undefined ? {} : { nextBefore }),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** POST /api/mobile/v1/schedule/delete —— body { sessionId, id }。 */
|
|
401
|
+
async function handleScheduleDelete(ctx, req, res) {
|
|
402
|
+
const body = (await readJsonBody(req)) || {};
|
|
403
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
404
|
+
const id = typeof body.id === 'string' ? body.id.trim() : '';
|
|
405
|
+
if (!sessionId || !id) {
|
|
406
|
+
return sendJson(res, 400, { ok: false, error: { code: 'invalid_selector', message: '需要 sessionId 与 id' } });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const service = scheduleService(ctx);
|
|
410
|
+
if (!service) return sendUnavailable(res);
|
|
411
|
+
|
|
412
|
+
let value;
|
|
413
|
+
try {
|
|
414
|
+
value = await service.delete({ sessionId, id });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return sendFailure(res, error);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const source = value && typeof value === 'object' ? value : {};
|
|
420
|
+
// 任务已经不在了不算失败:手机端刷新后本来就看不到它,报错只会弹一个没意义的红条。
|
|
421
|
+
const notFound = source.deleted !== true && source.code === 'schedule_not_found';
|
|
422
|
+
sendJson(res, 200, {
|
|
423
|
+
ok: true,
|
|
424
|
+
id: requiredText(source.id, SCHEDULE_ID_MAX) || id,
|
|
425
|
+
deleted: source.deleted === true,
|
|
426
|
+
...(notFound ? { code: 'schedule_not_found' } : {}),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
module.exports = {
|
|
431
|
+
handleScheduleCatalog,
|
|
432
|
+
handleScheduleList,
|
|
433
|
+
handleScheduleHistory,
|
|
434
|
+
handleScheduleDelete,
|
|
435
|
+
normalizeScheduleEntry,
|
|
436
|
+
normalizeScheduleEntries,
|
|
437
|
+
normalizeDeliveryRecord,
|
|
438
|
+
normalizeRetention,
|
|
439
|
+
parseLimit,
|
|
440
|
+
};
|
|
@@ -70,6 +70,23 @@ function invalidateSessionModels(ctx, req, sessionId) {
|
|
|
70
70
|
state?.delete(`${deviceKey}\0${sessionId}`);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 这台设备上**所有**会话的模型目录一起作废。
|
|
75
|
+
*
|
|
76
|
+
* 模型目录是设备级的(同一台宿主的渠道清单不随会话变),缓存却按 `设备 + 会话` 存。
|
|
77
|
+
* 渠道增删之后只清一条的话,其余会话在 TTL 内继续回旧目录——症状是「刚加的渠道,
|
|
78
|
+
* 在这个会话里有、换一个会话又没了」。
|
|
79
|
+
*/
|
|
80
|
+
function dropSessionModelsForDevice(ctx, req) {
|
|
81
|
+
const deviceKey = canonicalDeviceKey(req);
|
|
82
|
+
const state = sessionModelsCache.get(ctx);
|
|
83
|
+
if (!state) return;
|
|
84
|
+
const prefix = `${deviceKey}\0`;
|
|
85
|
+
for (const key of [...state.keys()]) {
|
|
86
|
+
if (key.startsWith(prefix)) state.delete(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function sessionListResponseState(ctx, deviceKey = 'legacy') {
|
|
74
91
|
let byDevice = sessionListResponseCache.get(ctx);
|
|
75
92
|
if (!byDevice) {
|
|
@@ -303,6 +320,7 @@ module.exports = {
|
|
|
303
320
|
canonicalDeviceKey,
|
|
304
321
|
sessionModelsStateOf,
|
|
305
322
|
invalidateSessionModels,
|
|
323
|
+
dropSessionModelsForDevice,
|
|
306
324
|
sessionListResponseState,
|
|
307
325
|
invalidateSessionList,
|
|
308
326
|
rememberDeletedSessionIds,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 交付文件的字节(POST /api/mobile/v1/session/file)。
|
|
5
|
+
*
|
|
6
|
+
* 轮尾那张「交付文件」卡在桌面上点一下就用系统默认程序打开;手机上没有那条路 ——
|
|
7
|
+
* 文件在电脑上,宿主只给了路径,App 必须先取回字节。这条路由就是那条通道。
|
|
8
|
+
*
|
|
9
|
+
* 三条边界同时成立:
|
|
10
|
+
*
|
|
11
|
+
* 1. **只认本会话 present 声明过的路径**:`deliverables/presented` 是模型显式交出来的
|
|
12
|
+
* 那份清单,App 卡片上的每一格都来自它。别把它放宽成「会话工作区里的任意文件」——
|
|
13
|
+
* 移动 API 不暴露任意读,与它不暴露 shell / 任意写入是同一条边界。路径按**字符串
|
|
14
|
+
* 全等**比对声明,所以不存在穿越。
|
|
15
|
+
* 2. **相对路径按会话工作区解析**:声明里既可能是 `src/x.png` 也可能是绝对路径,
|
|
16
|
+
* 相对的那一种只能相对 cwd 解析(cwd 取自会话列表摘要,与 /session 同一个来源)。
|
|
17
|
+
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
18
|
+
* 导出同一个理由。
|
|
19
|
+
*
|
|
20
|
+
* 取不到时两种失败要分得开:不在声明里回 403(`not-presented`),声明里有、盘上没了
|
|
21
|
+
* 回 404(`missing`)—— 前者是「这一格不该出现」,后者是「文件被挪走了」,文案不同。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const { stat } = require('node:fs/promises');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { pipeline } = require('node:stream/promises');
|
|
28
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
29
|
+
const { unwrap } = require('./envelope.js');
|
|
30
|
+
const { callSessions } = require('./rpc.js');
|
|
31
|
+
const { presentedPathsOf } = require('./history-derive.js');
|
|
32
|
+
|
|
33
|
+
/** 单文件上限:手机内存与中转链路都不适合再大的东西(超出如实报 413)。 */
|
|
34
|
+
const SESSION_FILE_MAX_BYTES = 512 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 找声明时读的历史窗口。宿主 history 按**消息条数**取尾页;交付声明挂在消息上,
|
|
38
|
+
* 所以窗口要盖得住「用户还能滚到的那几屏」。取不到更早的声明就回 403,让 App 说清。
|
|
39
|
+
*/
|
|
40
|
+
const PRESENTED_WINDOW_MESSAGES = 600;
|
|
41
|
+
|
|
42
|
+
/** 扩展名 → 内容类型:App 用不到全部,但 `<video>` / `<img>` 这类要看它。 */
|
|
43
|
+
const MEDIA_TYPES = Object.freeze({
|
|
44
|
+
mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
45
|
+
mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
46
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
47
|
+
webp: 'image/webp', heic: 'image/heic', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
48
|
+
pdf: 'application/pdf',
|
|
49
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
50
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
51
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
52
|
+
zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
|
|
53
|
+
json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const TEXT_EXTENSIONS = new Set([
|
|
57
|
+
'txt', 'md', 'markdown', 'log', 'json', 'csv', 'tsv', 'html', 'htm', 'css',
|
|
58
|
+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
|
|
59
|
+
'swift', 'c', 'h', 'cpp', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'ini',
|
|
60
|
+
'conf', 'sql', 'xml', 'svg', 'patch', 'diff',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** 取扩展名(小写、不含点);没有扩展名回空串。 */
|
|
64
|
+
function extensionOf(filePath) {
|
|
65
|
+
const dot = filePath.lastIndexOf('.');
|
|
66
|
+
if (dot <= 0 || dot === filePath.length - 1) return '';
|
|
67
|
+
return filePath.slice(dot + 1).toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 回给响应的内容类型:认得出的按表,认不出的按扩展名落 text/* 或 octet-stream。 */
|
|
71
|
+
function mediaTypeOf(filePath) {
|
|
72
|
+
const ext = extensionOf(filePath);
|
|
73
|
+
if (MEDIA_TYPES[ext]) return MEDIA_TYPES[ext];
|
|
74
|
+
if (TEXT_EXTENSIONS.has(ext)) {
|
|
75
|
+
// json / csv / html 在表里已经定死了,走到这里的是其余纯文本。
|
|
76
|
+
return 'text/plain; charset=utf-8';
|
|
77
|
+
}
|
|
78
|
+
return 'application/octet-stream';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* content-disposition 的文件名:头里只能放 ASCII,中文名按 RFC 5987 的 `filename*`
|
|
83
|
+
* 另给一份。App 侧并不读它(卡片上本来就有文件名),留着是为了别人抓包时能认出是哪份。
|
|
84
|
+
*/
|
|
85
|
+
function dispositionOf(name) {
|
|
86
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
87
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 一条会话里 present 声明过的全部路径(历史窗口内)。 */
|
|
91
|
+
async function declaredPathsOf(ctx, sessionId) {
|
|
92
|
+
const { value, error } = unwrap(await callSessions(ctx, 'history', {
|
|
93
|
+
sessionId,
|
|
94
|
+
maxMessages: PRESENTED_WINDOW_MESSAGES,
|
|
95
|
+
}));
|
|
96
|
+
if (error) throw Object.assign(new Error(error.message || '读会话历史失败'), { code: error.code });
|
|
97
|
+
return presentedPathsOf((value && value.events) || []);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
101
|
+
async function cwdOf(ctx, sessionId) {
|
|
102
|
+
try {
|
|
103
|
+
const { value } = unwrap(await callSessions(ctx, 'list', {}));
|
|
104
|
+
const items = (value && value.items) || [];
|
|
105
|
+
const row = items.find((item) => item && item.sessionId === sessionId);
|
|
106
|
+
return row && typeof row.cwd === 'string' ? row.cwd : '';
|
|
107
|
+
} catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* POST { sessionId, path } → 该文件的原始字节。
|
|
114
|
+
*
|
|
115
|
+
* @param {object} ctx 插件上下文
|
|
116
|
+
* @param {object} req node:http IncomingMessage
|
|
117
|
+
* @param {object} res node:http ServerResponse
|
|
118
|
+
*/
|
|
119
|
+
async function handleSessionFile(ctx, req, res) {
|
|
120
|
+
const body = await readJsonBody(req);
|
|
121
|
+
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
122
|
+
const declared = body && typeof body.path === 'string' ? body.path : '';
|
|
123
|
+
if (!sessionId || !declared) {
|
|
124
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, path }' } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let declaredPaths;
|
|
128
|
+
try {
|
|
129
|
+
declaredPaths = await declaredPathsOf(ctx, sessionId);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return sendJson(res, 502, {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!declaredPaths.has(declared)) {
|
|
137
|
+
return sendJson(res, 403, {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: { code: 'not-presented', message: '这个文件不在本会话的交付声明里' },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let target;
|
|
144
|
+
if (path.isAbsolute(declared)) {
|
|
145
|
+
target = declared;
|
|
146
|
+
} else {
|
|
147
|
+
const cwd = await cwdOf(ctx, sessionId);
|
|
148
|
+
if (!cwd) {
|
|
149
|
+
return sendJson(res, 409, {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: { code: 'no-cwd', message: '这条会话没有工作区目录,没法解析相对路径' },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
target = path.join(cwd, declared);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let info;
|
|
158
|
+
try {
|
|
159
|
+
info = await stat(target);
|
|
160
|
+
} catch {
|
|
161
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '文件已经不在这台电脑上了' } });
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile()) {
|
|
164
|
+
return sendJson(res, 400, { ok: false, error: { code: 'not-a-file', message: '这条路径不是一个文件' } });
|
|
165
|
+
}
|
|
166
|
+
if (info.size > SESSION_FILE_MAX_BYTES) {
|
|
167
|
+
return sendJson(res, 413, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: { code: 'too-large', message: '这个文件太大,手机这边不支持整份取回' },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const name = path.basename(target);
|
|
174
|
+
res.writeHead(200, {
|
|
175
|
+
'content-type': mediaTypeOf(target),
|
|
176
|
+
'content-length': String(info.size),
|
|
177
|
+
'content-disposition': dispositionOf(name),
|
|
178
|
+
'cache-control': 'no-store',
|
|
179
|
+
});
|
|
180
|
+
if (info.size === 0) return res.end();
|
|
181
|
+
|
|
182
|
+
const stream = fs.createReadStream(target);
|
|
183
|
+
res.on('close', () => stream.destroy());
|
|
184
|
+
try {
|
|
185
|
+
await pipeline(stream, res);
|
|
186
|
+
} catch {
|
|
187
|
+
// 已经写出 200,改不了状态码,只能断开让对端按截断处理(与会话日志导出一致)。
|
|
188
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { handleSessionFile, SESSION_FILE_MAX_BYTES };
|