mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 22:00:46 +08:00
Fork dsh-cron-tasks as dsh-ops-cron with DSH/IM delivery.
Agent cron_* jobs default to IM when created from a WhatsApp session with a matching delivery target; otherwise DSH sidebar sessions. Fire path soft-calls ctx.dshIm.send for summaries. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4447404361
commit
2f08136adf
17 changed files with 472 additions and 285 deletions
33
FORK.md
Normal file
33
FORK.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# dsh-ops-cron(运维 fork)
|
||||||
|
|
||||||
|
本仓库是 [Whale-Zhang/dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) 的 **运维 fork**,用于:
|
||||||
|
|
||||||
|
- Agent 自建定时任务(`cron_create` / `cron_list` / …)
|
||||||
|
- 按创建来源默认投递:**WhatsApp 会话 → IM**;**Web/DSH 会话 → 侧栏新会话**
|
||||||
|
- 与 [dsh-im-ops](https://github.com/hansjone/dsh-im-ops) 的 `ctx.dshIm.send(botId, targetId, text)` 打通
|
||||||
|
|
||||||
|
不要与上游 `@dsh-external/dsh-cron-tasks` 装进同一 profile(工具名冲突)。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 建议顺序:先 im,再 cron
|
||||||
|
dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
|
||||||
|
dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
|
||||||
|
# 重启 dsh web
|
||||||
|
```
|
||||||
|
|
||||||
|
本地开发:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dsh plugin --profile web add -w "D:\project\chatgpt\dsh-ops-cron"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相对上游的改动
|
||||||
|
|
||||||
|
1. 包名 / cordis id / HTTP 前缀 / 侧栏文案 → `dsh-ops-cron` /「运维定时」
|
||||||
|
2. Job 增加 `delivery: { kind: 'dsh'|'im', botId?, targetId? }`
|
||||||
|
3. `cron_create` 支持 `delivery` / `im_bot_id` / `im_target_id`;未显式指定时,若当前会话能 `resolveSessionPeer` 且已有匹配投递目标 → 默认 IM
|
||||||
|
4. 开火后若 `delivery.kind === 'im'`,把 assistant 摘要经 `ctx.dshIm.send` 投回
|
||||||
|
|
||||||
|
关键告警推送仍在 **netxops**,不经过本插件。
|
||||||
123
README.md
123
README.md
|
|
@ -1,116 +1,31 @@
|
||||||
# dsh-cron-tasks
|
# dsh-ops-cron
|
||||||
|
|
||||||
[English](#english) · [中文](#zhongwen)
|
Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness.
|
||||||
|
|
||||||
A DeepSeek Harness (`dsh`) plugin for **scheduled tasks**. Sidebar entry under **New Session**, plugin settings, isolated run history, and model-facing `cron_*` tools.
|
- Sidebar **运维定时** under New Session
|
||||||
|
- Agent tools: `cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete`
|
||||||
|
- Per-job delivery: **DSH** (new root session) or **IM** (`ctx.dshIm.send`)
|
||||||
|
|
||||||

|
See [FORK.md](FORK.md) for install notes. Do **not** install upstream `@dsh-external/dsh-cron-tasks` in the same profile.
|
||||||

|
|
||||||
|
|
||||||
<a id="zhongwen"></a>
|
## Install
|
||||||
|
|
||||||
## 中文
|
|
||||||
|
|
||||||
到点后 **新开一条会话** 跑事先写好的提示词,记进侧栏「定时任务」历史。这类会话不会淹没日常工作区列表。
|
|
||||||
|
|
||||||
这不是官方同会话 reminder(`@deepseek-ai/dsh-schedule`),也不是系统 `crontab`。调度器住在 DSH Host 里:Host 没在跑就不会触发。
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
需要已安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。一条命令装进网页端和桌面端:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
|
dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
|
||||||
dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
|
dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
|
||||||
|
# restart dsh web
|
||||||
```
|
```
|
||||||
|
|
||||||
装完后**重启对应 profile**(`dsh web` 或 Desktop)。用下面命令应能看到 `dsh-cron-tasks`:
|
## Delivery defaults
|
||||||
|
|
||||||
```sh
|
| Created from | Default delivery |
|
||||||
dsh --profile web --dump-config
|
|--------------|------------------|
|
||||||
```
|
| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` |
|
||||||
|
| Web / plain DSH session | `dsh` → sidebar history session |
|
||||||
|
| Explicit `delivery` / `im_bot_id`+`im_target_id` | as specified |
|
||||||
|
|
||||||
### 卸载
|
Create IM targets first in IM → 投递设置 → 复制调用参数 when you need a stable target for WhatsApp.
|
||||||
|
|
||||||
```sh
|
## License
|
||||||
dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
|
|
||||||
dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
### 做什么
|
MIT (upstream MIT retained).
|
||||||
|
|
||||||
- 左侧 **「新会话」正下方** 的 **「定时任务」** 入口:创建、暂停/恢复、删除、立即运行、搜索
|
|
||||||
- **设置 → 插件 → 插件配置** 里的调度策略(启用、默认时区、历史保留、重叠/漏跑)
|
|
||||||
- 每次触发新开 root Session;点历史记录打开原生对话,可以继续聊
|
|
||||||
- 每个任务可指定 **模型**(钉死后不跟着聊天模型变,额度可预期)
|
|
||||||
- 工作目录可下拉已有工作区,或自定义路径
|
|
||||||
- 也可在对话里对 AI 说「帮我建一个定时任务」(skill `scheduled-tasks` + `cron_create`)
|
|
||||||
|
|
||||||
### 日程
|
|
||||||
|
|
||||||
- 一次性 `at`,或循环 5 字段 cron,IANA 时区(默认 `Asia/Shanghai`)
|
|
||||||
- 「一分钟后」用 `after_minutes`,不要叠 `at` / `hour` / `expr`
|
|
||||||
- 重叠:同一任务已有进行中的运行则跳过
|
|
||||||
- 漏跑:Host 宕机期间错过的节拍 **不补跑**
|
|
||||||
|
|
||||||
### 开发
|
|
||||||
|
|
||||||
```sh
|
|
||||||
node --test test/*.test.js
|
|
||||||
```
|
|
||||||
|
|
||||||
本地 `file:` 安装(开发用,不要用 `link:`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dsh plugin --profile web add file:$HOME/.dsh/plugins/dsh-cron-tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
产品契约见 [docs/PRD.md](docs/PRD.md)。实现经验见 [docs/NOTES.md](docs/NOTES.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<a id="english"></a>
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
At the scheduled time, Harness **opens a fresh root session**, runs a self-contained prompt, and records the run in the **Scheduled tasks** sidebar. Those sessions stay out of the ordinary workspace list.
|
|
||||||
|
|
||||||
This is not the official same-chat reminder (`@deepseek-ai/dsh-schedule`) and not OS crontab. The scheduler lives in the DSH Host process; nothing fires while Host is down.
|
|
||||||
|
|
||||||
### Install
|
|
||||||
|
|
||||||
Requires [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
|
|
||||||
dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart the profile after installing. `dsh --profile web --dump-config` should list `dsh-cron-tasks`.
|
|
||||||
|
|
||||||
### Uninstall
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
|
|
||||||
dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
### What it does
|
|
||||||
|
|
||||||
- Sidebar entry **under New Session**: create, pause/resume, delete, run now, search
|
|
||||||
- Scheduler policy in **Settings → Plugins → Plugin configuration**
|
|
||||||
- Each fire is a new root session; open a history row to continue in the native chat
|
|
||||||
- Each job can pin a **model** so quota does not follow the chat selector
|
|
||||||
- Working directory: existing workspace or a custom path
|
|
||||||
- Ask the agent to create a job (`scheduled-tasks` skill + `cron_create`)
|
|
||||||
|
|
||||||
### Schedule
|
|
||||||
|
|
||||||
- One-shot `at` or 5-field cron, IANA time zone (default `Asia/Shanghai`)
|
|
||||||
- “In N minutes” → `after_minutes`
|
|
||||||
- Overlap: skip if the same job is already running
|
|
||||||
- Misfire: missed beats while Host was down are **not** replayed
|
|
||||||
|
|
||||||
### License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Insert this plugin into a profile's layer stack.
|
# Insert this ops cron plugin into a profile's layer stack.
|
||||||
|
# Do not install upstream @dsh-external/dsh-cron-tasks alongside this package.
|
||||||
- insert:
|
- insert:
|
||||||
- id: dsh-cron-tasks
|
- id: dsh-ops-cron
|
||||||
name: '@dsh-external/dsh-cron-tasks'
|
name: dsh-ops-cron
|
||||||
|
|
|
||||||
108
lib/client.js
108
lib/client.js
|
|
@ -1,5 +1,5 @@
|
||||||
window.__ModuleLoader__.load({
|
window.__ModuleLoader__.load({
|
||||||
id: '@dsh-external/dsh-cron-tasks',
|
id: 'dsh-ops-cron',
|
||||||
factory: (require) => {
|
factory: (require) => {
|
||||||
const module = { exports: {} }
|
const module = { exports: {} }
|
||||||
const exports = module.exports
|
const exports = module.exports
|
||||||
|
|
@ -156,16 +156,16 @@ window.__ModuleLoader__.load({
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = 'dsh-cron-tasks'
|
const name = 'dsh-ops-cron'
|
||||||
const NS = 'dsh-cron-tasks'
|
const NS = 'dsh-ops-cron'
|
||||||
const inject = ['slots', 'locale', 'settingsScope']
|
const inject = ['slots', 'locale', 'settingsScope']
|
||||||
const API = '/dsh-cron-tasks'
|
const API = '/dsh-ops-cron'
|
||||||
const LOCALE_NS = 'settings.dshCronTasks'
|
const LOCALE_NS = 'settings.dshCronTasks'
|
||||||
const TITLE_PREFIX = '定时任务 · '
|
const TITLE_PREFIX = '运维定时 · '
|
||||||
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
|
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
|
||||||
|
|
||||||
function jobStamp(rows) {
|
function jobStamp(rows) {
|
||||||
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}`).join('|')
|
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|')
|
||||||
}
|
}
|
||||||
|
|
||||||
function runStamp(rows) {
|
function runStamp(rows) {
|
||||||
|
|
@ -291,19 +291,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
`
|
`
|
||||||
|
|
||||||
const zh = {
|
const zh = {
|
||||||
title: '定时任务',
|
title: '运维定时',
|
||||||
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
|
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
|
||||||
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
|
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
|
||||||
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
||||||
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
|
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
|
||||||
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
|
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
|
||||||
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
|
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
|
||||||
entry: '定时任务', entryLabel: '打开定时任务',
|
entry: '运维定时', entryLabel: '打开运维定时',
|
||||||
backToWorkspace: '返回工作区', backLabel: '返回工作区',
|
backToWorkspace: '返回工作区', backLabel: '返回工作区',
|
||||||
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
|
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
|
||||||
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
|
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
|
||||||
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
|
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
|
||||||
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
|
emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次',
|
||||||
expandRuns: '展开记录', collapseRuns: '收起记录',
|
expandRuns: '展开记录', collapseRuns: '收起记录',
|
||||||
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
|
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
|
||||||
paused: '已暂停',
|
paused: '已暂停',
|
||||||
|
|
@ -313,19 +313,25 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
|
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
modelDefault: '每次运行用当时的新会话默认',
|
modelDefault: '每次运行用当时的新会话默认',
|
||||||
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
|
modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
|
||||||
editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。',
|
editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
|
||||||
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
|
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
|
||||||
|
delivery: '投递',
|
||||||
|
deliveryDsh: 'DSH 侧栏会话',
|
||||||
|
deliveryIm: 'WhatsApp / IM',
|
||||||
|
imBotId: 'IM Bot ID',
|
||||||
|
imTargetId: 'IM Target ID',
|
||||||
|
deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
|
||||||
}
|
}
|
||||||
const en = {
|
const en = {
|
||||||
title: 'Scheduled tasks',
|
title: 'Ops schedule',
|
||||||
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
|
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
|
||||||
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard',
|
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard',
|
||||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||||
enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
|
enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
|
||||||
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip',
|
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip',
|
||||||
hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.',
|
hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.',
|
||||||
entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
|
entry: 'Ops schedule', entryLabel: 'Open ops schedule',
|
||||||
backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
|
backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
|
||||||
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
|
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
|
||||||
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
|
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
|
||||||
|
|
@ -341,8 +347,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
modelDefault: 'Use the New Session default at fire time',
|
modelDefault: 'Use the New Session default at fire time',
|
||||||
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
|
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
|
||||||
editorLead: 'Runs start a new session. Click a run below to open that conversation and continue it.',
|
editorLead: 'Runs start a new session. IM delivery also sends a summary via dshIm after the turn. Click a run below to open that conversation.',
|
||||||
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
|
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
|
||||||
|
delivery: 'Delivery',
|
||||||
|
deliveryDsh: 'DSH sidebar session',
|
||||||
|
deliveryIm: 'WhatsApp / IM',
|
||||||
|
imBotId: 'IM Bot ID',
|
||||||
|
imTargetId: 'IM Target ID',
|
||||||
|
deliveryHint: 'Web defaults to DSH. WhatsApp sessions can auto-match a delivery target. Or paste botId/targetId from IM delivery settings.',
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePrefs(value) {
|
function normalizePrefs(value) {
|
||||||
|
|
@ -585,6 +597,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
|
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
|
||||||
provider: current?.provider || '',
|
provider: current?.provider || '',
|
||||||
model: current?.model || '',
|
model: current?.model || '',
|
||||||
|
deliveryKind: 'dsh',
|
||||||
|
imBotId: '',
|
||||||
|
imTargetId: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -601,9 +616,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
enabled: job.enabled !== false,
|
enabled: job.enabled !== false,
|
||||||
provider: job.provider || '',
|
provider: job.provider || '',
|
||||||
model: job.model || '',
|
model: job.model || '',
|
||||||
|
deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
|
||||||
|
imBotId: job.delivery?.botId || '',
|
||||||
|
imTargetId: job.delivery?.targetId || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jobDeliveryLabel(job, t) {
|
||||||
|
if (job?.delivery?.kind === 'im') {
|
||||||
|
return `${t('deliveryIm')}:${job.delivery.targetId || '?'}`
|
||||||
|
}
|
||||||
|
return t('deliveryDsh')
|
||||||
|
}
|
||||||
|
|
||||||
function modelKey(provider, model) {
|
function modelKey(provider, model) {
|
||||||
if (!provider || !model) return ''
|
if (!provider || !model) return ''
|
||||||
return `${provider}::${model}`
|
return `${provider}::${model}`
|
||||||
|
|
@ -723,7 +748,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
h('span', { className: skin.projectText },
|
h('span', { className: skin.projectText },
|
||||||
h('span', { className: skin.title }, paused
|
h('span', { className: skin.title }, paused
|
||||||
? `${job.name} · ${t('paused')}`
|
? `${job.name} · ${t('paused')}`
|
||||||
: (jobModelLabel(job) ? `${job.name} · ${jobModelLabel(job)}` : job.name))),
|
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
|
||||||
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
|
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
|
||||||
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
|
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
|
||||||
playIcon() || '▶'),
|
playIcon() || '▶'),
|
||||||
|
|
@ -865,6 +890,28 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
),
|
),
|
||||||
h(CwdField, { t, form, setForm, workspaces }),
|
h(CwdField, { t, form, setForm, workspaces }),
|
||||||
h(ModelField, { t, form, setForm, catalog }),
|
h(ModelField, { t, form, setForm, catalog }),
|
||||||
|
h('label', null, t('delivery'),
|
||||||
|
h('select', {
|
||||||
|
value: form.deliveryKind || 'dsh',
|
||||||
|
onChange: (e) => setForm({ ...form, deliveryKind: e.target.value }),
|
||||||
|
},
|
||||||
|
h('option', { value: 'dsh' }, t('deliveryDsh')),
|
||||||
|
h('option', { value: 'im' }, t('deliveryIm')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
form.deliveryKind === 'im'
|
||||||
|
? h('div', { className: 'dsh-ct-editorRow' },
|
||||||
|
h('label', null, t('imBotId'), h('input', {
|
||||||
|
value: form.imBotId || '',
|
||||||
|
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
|
||||||
|
})),
|
||||||
|
h('label', null, t('imTargetId'), h('input', {
|
||||||
|
value: form.imTargetId || '',
|
||||||
|
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
|
||||||
h('div', { className: 'dsh-ct-editorActs' },
|
h('div', { className: 'dsh-ct-editorActs' },
|
||||||
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')),
|
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')),
|
||||||
!isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null,
|
!isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null,
|
||||||
|
|
@ -1037,6 +1084,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
name: form.name, prompt: form.prompt, schedule,
|
name: form.name, prompt: form.prompt, schedule,
|
||||||
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
|
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
|
||||||
provider: form.provider, model: form.model,
|
provider: form.provider, model: form.model,
|
||||||
|
delivery: form.deliveryKind === 'im'
|
||||||
|
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
|
||||||
|
: { kind: 'dsh' },
|
||||||
}
|
}
|
||||||
if (selection.type === 'job' && selection.jobId) {
|
if (selection.type === 'job' && selection.jobId) {
|
||||||
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||||
|
|
@ -1147,14 +1197,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
|
|
||||||
function isCronTitle(text) {
|
function isCronTitle(text) {
|
||||||
const value = String(text || '').trim()
|
const value = String(text || '').trim()
|
||||||
return value.startsWith('定时任务') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
|
return value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceTitleFromCronFork(title) {
|
function workspaceTitleFromCronFork(title) {
|
||||||
const value = String(title || '').trim()
|
const value = String(title || '').trim()
|
||||||
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
|
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
|
||||||
if (value.startsWith('定时任务')) {
|
if (value.startsWith('运维定时')) {
|
||||||
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
|
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
|
||||||
return stripped || value
|
return stripped || value
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|
@ -1169,7 +1219,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
const parent = String(row.parentId || row.parentSessionId || '').trim()
|
const parent = String(row.parentId || row.parentSessionId || '').trim()
|
||||||
if (parent && ids.has(parent)) return true
|
if (parent && ids.has(parent)) return true
|
||||||
const title = String(row.title || row.displayTitle || '')
|
const title = String(row.title || row.displayTitle || '')
|
||||||
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务')
|
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时')
|
||||||
if (previousId && ids.has(String(previousId)) && looksCron) return true
|
if (previousId && ids.has(String(previousId)) && looksCron) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -1411,7 +1461,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
tag.textContent = CSS
|
tag.textContent = CSS
|
||||||
document.head.appendChild(tag)
|
document.head.appendChild(tag)
|
||||||
return () => tag.remove()
|
return () => tag.remove()
|
||||||
}, 'dsh-cron-tasks: styles')
|
}, 'dsh-ops-cron: styles')
|
||||||
|
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1421,7 +1471,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||||
return () => { offZh && offZh(); offEn && offEn() }
|
return () => { offZh && offZh(); offEn && offEn() }
|
||||||
}
|
}
|
||||||
}, 'dsh-cron-tasks: locale')
|
}, 'dsh-ops-cron: locale')
|
||||||
|
|
||||||
const scope = ctx.settingsScope.bind({ namespace: NS })
|
const scope = ctx.settingsScope.bind({ namespace: NS })
|
||||||
const form = createForm(scope)
|
const form = createForm(scope)
|
||||||
|
|
@ -1473,7 +1523,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
sessions.open(sessionId)
|
sessions.open(sessionId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error
|
||||||
console.warn('[dsh-cron-tasks] open session failed', sessionId, error)
|
console.warn('[dsh-ops-cron] open session failed', sessionId, error)
|
||||||
try { await sessions.refresh?.() } catch { /* ignore */ }
|
try { await sessions.refresh?.() } catch { /* ignore */ }
|
||||||
await sleep(80)
|
await sleep(80)
|
||||||
continue
|
continue
|
||||||
|
|
@ -1484,8 +1534,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
if (sessions.list?.getSnapshot?.()?.current === sessionId) return true
|
if (sessions.list?.getSnapshot?.()?.current === sessionId) return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastError) console.warn('[dsh-cron-tasks] giving up open', sessionId, lastError)
|
if (lastError) console.warn('[dsh-ops-cron] giving up open', sessionId, lastError)
|
||||||
else console.warn('[dsh-cron-tasks] open session timed out', sessionId)
|
else console.warn('[dsh-ops-cron] open session timed out', sessionId)
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
clearSession() {
|
clearSession() {
|
||||||
|
|
@ -1519,7 +1569,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
if (typeof off === 'function') offs.push(off)
|
if (typeof off === 'function') offs.push(off)
|
||||||
} catch { /* optional */ }
|
} catch { /* optional */ }
|
||||||
return () => { for (const off of offs) off() }
|
return () => { for (const off of offs) off() }
|
||||||
}, 'dsh-cron-tasks: session faces')
|
}, 'dsh-ops-cron: session faces')
|
||||||
|
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const promoted = new Set()
|
const promoted = new Set()
|
||||||
|
|
@ -1543,12 +1593,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
if (session && typeof session.rename === 'function') await session.rename(nextTitle)
|
if (session && typeof session.rename === 'function') await session.rename(nextTitle)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[dsh-cron-tasks] fork rename failed', row.id, error)
|
console.warn('[dsh-ops-cron] fork rename failed', row.id, error)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' })
|
await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[dsh-cron-tasks] fork adopt failed', row.id, error)
|
console.warn('[dsh-ops-cron] fork adopt failed', row.id, error)
|
||||||
}
|
}
|
||||||
try { faces.leaveCronMode?.() } catch { /* ignore */ }
|
try { faces.leaveCronMode?.() } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
@ -1573,14 +1623,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
if (typeof unsub === 'function') unsub()
|
if (typeof unsub === 'function') unsub()
|
||||||
}
|
}
|
||||||
}, 'dsh-cron-tasks: fork promote')
|
}, 'dsh-ops-cron: fork promote')
|
||||||
|
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
|
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
|
||||||
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
|
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
|
||||||
const t = (key) => dict[key] || zh[key] || key
|
const t = (key) => dict[key] || zh[key] || key
|
||||||
return installChrome(t, faces)
|
return installChrome(t, faces)
|
||||||
}, 'dsh-cron-tasks: chrome')
|
}, 'dsh-ops-cron: chrome')
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.name = name
|
exports.name = name
|
||||||
|
|
|
||||||
118
lib/delivery.js
Normal file
118
lib/delivery.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function normalizeDelivery(input = {}) {
|
||||||
|
const src = input && typeof input === 'object' ? input : {}
|
||||||
|
const kind = String(src.kind || 'dsh').trim().toLowerCase()
|
||||||
|
if (kind === 'im') {
|
||||||
|
const botId = typeof src.botId === 'string' ? src.botId.trim() : ''
|
||||||
|
const targetId = typeof src.targetId === 'string' ? src.targetId.trim() : ''
|
||||||
|
if (!botId || !targetId) {
|
||||||
|
const error = new Error('im delivery requires botId and targetId')
|
||||||
|
error.code = 'INVALID_DELIVERY'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return { kind: 'im', botId, targetId }
|
||||||
|
}
|
||||||
|
return { kind: 'dsh' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryLine(delivery) {
|
||||||
|
if (!delivery || delivery.kind !== 'im') return 'delivery=dsh'
|
||||||
|
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a delivery target whose route overlaps the channel peer conversation.
|
||||||
|
* @param {Array<object>} targets
|
||||||
|
* @param {object|null} peer - from dshIm.resolveSessionPeer
|
||||||
|
*/
|
||||||
|
export function matchTargetForPeer(targets, peer) {
|
||||||
|
if (!peer || !Array.isArray(targets) || targets.length === 0) return null
|
||||||
|
const key = String(peer.conversationKey || '').trim()
|
||||||
|
const jidFromKey = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key
|
||||||
|
const peerJid = String(peer.jid || peer.route?.jid || jidFromKey || '').trim().toLowerCase()
|
||||||
|
if (!peerJid) return null
|
||||||
|
for (const target of targets) {
|
||||||
|
const route = target?.route && typeof target.route === 'object' ? target.route : {}
|
||||||
|
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
|
||||||
|
if (jid && (jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid))) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callerSessionId(exec) {
|
||||||
|
const session = exec?.agent?.session
|
||||||
|
return String(
|
||||||
|
session?.id
|
||||||
|
|| session?.sessionId
|
||||||
|
|| exec?.agent?.sessionId
|
||||||
|
|| exec?.sessionId
|
||||||
|
|| '',
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve delivery for cron_create: explicit args win; else IM peer → matching target; else dsh.
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} exec
|
||||||
|
* @param {{ dshIm?: object }} deps
|
||||||
|
*/
|
||||||
|
export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
|
||||||
|
const explicit = typeof args.delivery === 'string' ? args.delivery.trim().toLowerCase() : ''
|
||||||
|
const botId = typeof args.im_bot_id === 'string' ? args.im_bot_id.trim()
|
||||||
|
: (typeof args.imBotId === 'string' ? args.imBotId.trim() : '')
|
||||||
|
const targetId = typeof args.im_target_id === 'string' ? args.im_target_id.trim()
|
||||||
|
: (typeof args.imTargetId === 'string' ? args.imTargetId.trim() : '')
|
||||||
|
|
||||||
|
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
||||||
|
if (explicit === 'im' || (botId && targetId)) {
|
||||||
|
return normalizeDelivery({ kind: 'im', botId, targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dshIm = deps.dshIm
|
||||||
|
const sessionId = callerSessionId(exec)
|
||||||
|
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
|
||||||
|
try {
|
||||||
|
const peer = await dshIm.resolveSessionPeer(sessionId)
|
||||||
|
if (peer?.botId && typeof dshIm.listTargets === 'function') {
|
||||||
|
const targets = await dshIm.listTargets(peer.botId)
|
||||||
|
const match = matchTargetForPeer(targets, peer)
|
||||||
|
if (match?.targetId) {
|
||||||
|
return normalizeDelivery({
|
||||||
|
kind: 'im',
|
||||||
|
botId: peer.botId,
|
||||||
|
targetId: String(match.targetId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to dsh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeDelivery({ kind: 'dsh' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const IM_MAX_CHARS = 3500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send run summary to IM when job.delivery.kind === 'im'.
|
||||||
|
*/
|
||||||
|
export async function deliverRunToIm(job, summary, deps = {}) {
|
||||||
|
const delivery = job?.delivery
|
||||||
|
if (!delivery || delivery.kind !== 'im') return { sent: false, skipped: true }
|
||||||
|
const dshIm = deps.dshIm
|
||||||
|
if (!dshIm || typeof dshIm.send !== 'function') {
|
||||||
|
const error = new Error('ctx.dshIm unavailable — install dsh-im-ops for IM delivery')
|
||||||
|
error.code = 'IM_UNAVAILABLE'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const text = String(summary || '').trim() || `(定时任务「${job.name}」已完成,无文本摘要)`
|
||||||
|
const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text
|
||||||
|
const header = `【运维定时 · ${job.name}】\n`
|
||||||
|
await dshIm.send(delivery.botId, delivery.targetId, `${header}${clipped}`)
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ export function createRunRecord(job, decision, now, trigger) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TITLE_PREFIX = '定时任务 · '
|
export const TITLE_PREFIX = '运维定时 · '
|
||||||
|
|
||||||
export function extractAssistantText(messages, maxChars = 4000) {
|
export function extractAssistantText(messages, maxChars = 4000) {
|
||||||
if (!Array.isArray(messages)) return ''
|
if (!Array.isArray(messages)) return ''
|
||||||
|
|
@ -55,6 +55,9 @@ export function publicJob(job) {
|
||||||
provider: job.provider || '',
|
provider: job.provider || '',
|
||||||
model: job.model || '',
|
model: job.model || '',
|
||||||
reasoningEffort: job.reasoningEffort || '',
|
reasoningEffort: job.reasoningEffort || '',
|
||||||
|
delivery: job.delivery && job.delivery.kind === 'im'
|
||||||
|
? { kind: 'im', botId: job.delivery.botId, targetId: job.delivery.targetId }
|
||||||
|
: { kind: 'dsh' },
|
||||||
schedule: job.schedule,
|
schedule: job.schedule,
|
||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
updatedAt: job.updatedAt,
|
updatedAt: job.updatedAt,
|
||||||
|
|
|
||||||
26
lib/host.js
26
lib/host.js
|
|
@ -8,6 +8,7 @@ import { mkdir } from 'node:fs/promises'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
|
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
|
||||||
|
import { deliverRunToIm } from './delivery.js'
|
||||||
import { workspaceVisibleIds } from './isolation.js'
|
import { workspaceVisibleIds } from './isolation.js'
|
||||||
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
|
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,15 +24,15 @@ import {
|
||||||
upsertJob,
|
upsertJob,
|
||||||
} from './store.js'
|
} from './store.js'
|
||||||
|
|
||||||
export const PLUGIN_NAME = 'dsh-cron-tasks'
|
export const PLUGIN_NAME = 'dsh-ops-cron'
|
||||||
export const API_PREFIX = '/dsh-cron-tasks'
|
export const API_PREFIX = '/dsh-ops-cron'
|
||||||
|
|
||||||
export function dshHome() {
|
export function dshHome() {
|
||||||
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultCwd() {
|
export function defaultCwd() {
|
||||||
return join(dshHome(), 'cron-tasks', 'workspace')
|
return join(dshHome(), 'ops-cron', 'workspace')
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(res, status, body) {
|
function json(res, status, body) {
|
||||||
|
|
@ -110,6 +111,8 @@ function runView(run) {
|
||||||
* @param {() => number} [options.now]
|
* @param {() => number} [options.now]
|
||||||
* @param {string} [options.filePath]
|
* @param {string} [options.filePath]
|
||||||
* @param {object} [options.sessionPort] { createAndPrompt, archiveSession, waitForTurn }
|
* @param {object} [options.sessionPort] { createAndPrompt, archiveSession, waitForTurn }
|
||||||
|
* @param {() => object|undefined} [options.getDshIm] soft-injected proactive IM API
|
||||||
|
* @param {{ warn?: Function, info?: Function }} [options.logger]
|
||||||
* @param {number} [options.tickIntervalMs]
|
* @param {number} [options.tickIntervalMs]
|
||||||
*/
|
*/
|
||||||
export function createHostService(options = {}) {
|
export function createHostService(options = {}) {
|
||||||
|
|
@ -117,6 +120,8 @@ export function createHostService(options = {}) {
|
||||||
const filePath = options.filePath || storePath(dshHome())
|
const filePath = options.filePath || storePath(dshHome())
|
||||||
const store = options.store || createStore({ filePath })
|
const store = options.store || createStore({ filePath })
|
||||||
const sessionPort = options.sessionPort || null
|
const sessionPort = options.sessionPort || null
|
||||||
|
const getDshIm = typeof options.getDshIm === 'function' ? options.getDshIm : () => undefined
|
||||||
|
const logger = options.logger || console
|
||||||
const tickIntervalMs = Number(options.tickIntervalMs) > 0 ? Number(options.tickIntervalMs) : 15_000
|
const tickIntervalMs = Number(options.tickIntervalMs) > 0 ? Number(options.tickIntervalMs) : 15_000
|
||||||
let timer = null
|
let timer = null
|
||||||
let ticking = false
|
let ticking = false
|
||||||
|
|
@ -129,6 +134,16 @@ export function createHostService(options = {}) {
|
||||||
return store.read()
|
return store.read()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeDeliverIm(job, run) {
|
||||||
|
if (!job || job.delivery?.kind !== 'im') return
|
||||||
|
try {
|
||||||
|
await deliverRunToIm(job, run?.summary, { dshIm: getDshIm() })
|
||||||
|
logger.info?.(`[dsh-ops-cron] im delivery sent for job ${job.id} → ${job.delivery.targetId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.(`[dsh-ops-cron] im delivery failed for job ${job.id}: ${error instanceof Error ? error.message : error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createJob(input) {
|
async function createJob(input) {
|
||||||
const t = now()
|
const t = now()
|
||||||
let created
|
let created
|
||||||
|
|
@ -159,6 +174,7 @@ export function createHostService(options = {}) {
|
||||||
provider: patch.provider !== undefined ? patch.provider : job.provider,
|
provider: patch.provider !== undefined ? patch.provider : job.provider,
|
||||||
model: patch.model !== undefined ? patch.model : job.model,
|
model: patch.model !== undefined ? patch.model : job.model,
|
||||||
reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort,
|
reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort,
|
||||||
|
delivery: patch.delivery !== undefined ? patch.delivery : job.delivery,
|
||||||
}
|
}
|
||||||
const record = createJobRecord(nextInput, current, t)
|
const record = createJobRecord(nextInput, current, t)
|
||||||
record.createdAt = job.createdAt
|
record.createdAt = job.createdAt
|
||||||
|
|
@ -228,9 +244,11 @@ export function createHostService(options = {}) {
|
||||||
const settled = await store.mutate((current) => settleRun(current, run.id, terminal, now()))
|
const settled = await store.mutate((current) => settleRun(current, run.id, terminal, now()))
|
||||||
run = (settled.runs || []).find((row) => row.id === claimed.run.id)
|
run = (settled.runs || []).find((row) => row.id === claimed.run.id)
|
||||||
const job = getJob(settled, jobId)
|
const job = getJob(settled, jobId)
|
||||||
|
await maybeDeliverIm(job, run)
|
||||||
return { job: jobView(job), run: runView(run), decision: claimed.decision }
|
return { job: jobView(job), run: runView(run), decision: claimed.decision }
|
||||||
}
|
}
|
||||||
const job = getJob(executed, jobId)
|
const job = getJob(executed, jobId)
|
||||||
|
await maybeDeliverIm(job, run)
|
||||||
return { job: jobView(job), run: runView(run), decision: claimed.decision }
|
return { job: jobView(job), run: runView(run), decision: claimed.decision }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,7 +313,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recover() {
|
async function recover() {
|
||||||
await mkdir(join(dshHome(), 'cron-tasks'), { recursive: true })
|
await mkdir(join(dshHome(), 'ops-cron'), { recursive: true })
|
||||||
const t = now()
|
const t = now()
|
||||||
await withState((current) => {
|
await withState((current) => {
|
||||||
let next = interruptActiveRuns(current, t)
|
let next = interruptActiveRuns(current, t)
|
||||||
|
|
|
||||||
7
lib/index.d.ts
vendored
7
lib/index.d.ts
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
|
||||||
export const name: 'dsh-cron-tasks'
|
export const name: 'dsh-ops-cron'
|
||||||
export const inject: string[]
|
export const inject: string[]
|
||||||
export const NS: 'dsh-cron-tasks'
|
export const NS: 'dsh-ops-cron'
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|
@ -41,3 +41,6 @@ export function extractAssistantText(messages: object[], maxChars?: number): str
|
||||||
export const TITLE_PREFIX: string
|
export const TITLE_PREFIX: string
|
||||||
export function claimOccurrence(state: object, jobId: string, now: number, trigger: string, policies?: object): object
|
export function claimOccurrence(state: object, jobId: string, now: number, trigger: string, policies?: object): object
|
||||||
export function executeClaimedRun(state: object, runId: string, deps: object): Promise<object>
|
export function executeClaimedRun(state: object, runId: string, deps: object): Promise<object>
|
||||||
|
export function normalizeDelivery(input?: object): { kind: 'dsh' | 'im', botId?: string, targetId?: string }
|
||||||
|
export function resolveCreateDelivery(args?: object, exec?: object, deps?: object): Promise<{ kind: 'dsh' | 'im', botId?: string, targetId?: string }>
|
||||||
|
export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise<object>
|
||||||
|
|
|
||||||
31
lib/index.js
31
lib/index.js
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* dsh-cron-tasks, node half.
|
* dsh-ops-cron, node half.
|
||||||
*
|
*
|
||||||
* Durable scheduled jobs: create a fresh root Session on fire, hide that
|
* Durable scheduled jobs: create a fresh root Session on fire, hide that
|
||||||
* session from the workspace list, and keep history under 定时任务.
|
* session from the workspace list, and keep history under 运维定时.
|
||||||
|
* Jobs may deliver summaries to IM via soft-injected ctx.dshIm.
|
||||||
*
|
*
|
||||||
* Do not list @deepseek-ai/* in package.json — resolve schemastery from the
|
* Do not list @deepseek-ai/* in package.json — resolve schemastery from the
|
||||||
* live profile so we share the host's settings identity.
|
* live profile so we share the host's settings identity.
|
||||||
|
|
@ -48,6 +49,11 @@ export {
|
||||||
export { wrapScheduledPrompt } from './prompt.js'
|
export { wrapScheduledPrompt } from './prompt.js'
|
||||||
export { normalizeJobModel } from './store.js'
|
export { normalizeJobModel } from './store.js'
|
||||||
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
|
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
|
||||||
|
export {
|
||||||
|
deliverRunToIm,
|
||||||
|
normalizeDelivery,
|
||||||
|
resolveCreateDelivery,
|
||||||
|
} from './delivery.js'
|
||||||
|
|
||||||
function loadPkg(id) {
|
function loadPkg(id) {
|
||||||
const homes = [
|
const homes = [
|
||||||
|
|
@ -64,7 +70,7 @@ function loadPkg(id) {
|
||||||
errors.push(`${from}: ${error instanceof Error ? error.message : String(error)}`)
|
errors.push(`${from}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`[dsh-cron-tasks] cannot resolve ${id}\n${errors.join('\n')}`)
|
throw new Error(`[dsh-ops-cron] cannot resolve ${id}\n${errors.join('\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
|
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
|
||||||
|
|
@ -124,18 +130,21 @@ function registerWebRoute(ctx, handler) {
|
||||||
|
|
||||||
export function apply(ctx, config = {}) {
|
export function apply(ctx, config = {}) {
|
||||||
const entry = resolveConfig(config)
|
const entry = resolveConfig(config)
|
||||||
|
const getDshIm = () => tryGet(ctx, 'dshIm')
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
sessionPort: makeLiveSessionPort(ctx),
|
sessionPort: makeLiveSessionPort(ctx),
|
||||||
|
getDshIm,
|
||||||
|
logger: ctx.logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.inject(['settings'], (sctx) => {
|
ctx.inject(['settings'], (sctx) => {
|
||||||
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
|
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
|
||||||
sctx.logger?.info?.(`[dsh-cron-tasks] settings namespace "${NS}" registered`)
|
sctx.logger?.info?.(`[dsh-ops-cron] settings namespace "${NS}" registered`)
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.inject(['tools'], (tctx) => {
|
ctx.inject(['tools'], (tctx) => {
|
||||||
registerCronTools(tctx, service)
|
registerCronTools(tctx, service, { getDshIm })
|
||||||
tctx.logger?.info?.('[dsh-cron-tasks] tools cron_create/list/pause/resume/delete registered')
|
tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered')
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.inject(['skills'], (sctx) => {
|
ctx.inject(['skills'], (sctx) => {
|
||||||
|
|
@ -159,7 +168,7 @@ export function apply(ctx, config = {}) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => off()
|
return () => off()
|
||||||
}, 'dsh-cron-tasks: settings watch')
|
}, 'dsh-ops-cron: settings watch')
|
||||||
|
|
||||||
ctx.effect(() => registerWebRoute(ctx, (req, res) => {
|
ctx.effect(() => registerWebRoute(ctx, (req, res) => {
|
||||||
service.handleRequest(req, res).catch(() => {
|
service.handleRequest(req, res).catch(() => {
|
||||||
|
|
@ -168,7 +177,7 @@ export function apply(ctx, config = {}) {
|
||||||
res.end(JSON.stringify({ ok: false, error: 'internal error' }))
|
res.end(JSON.stringify({ ok: false, error: 'internal error' }))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}), 'dsh-cron-tasks: web route')
|
}), 'dsh-ops-cron: web route')
|
||||||
|
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
let stopped = false
|
let stopped = false
|
||||||
|
|
@ -181,13 +190,13 @@ export function apply(ctx, config = {}) {
|
||||||
return service.tick()
|
return service.tick()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
ctx.logger?.warn?.(`[dsh-cron-tasks] recover failed: ${error instanceof Error ? error.message : error}`)
|
ctx.logger?.warn?.(`[dsh-ops-cron] recover failed: ${error instanceof Error ? error.message : error}`)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
service.stopTimer()
|
service.stopTimer()
|
||||||
}
|
}
|
||||||
}, 'dsh-cron-tasks: scheduler')
|
}, 'dsh-ops-cron: scheduler')
|
||||||
|
|
||||||
ctx.logger?.info?.('[dsh-cron-tasks] host ready')
|
ctx.logger?.info?.('[dsh-ops-cron] host ready')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const HIDDEN_REASON = 'cron-run'
|
export const HIDDEN_REASON = 'cron-run'
|
||||||
export const TITLE_PREFIX = '定时任务 · '
|
export const TITLE_PREFIX = '运维定时 · '
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip the scheduler title prefix so a forked child can sit in the
|
* Strip the scheduler title prefix so a forked child can sit in the
|
||||||
|
|
@ -17,6 +17,11 @@ export function workspaceTitleFromCronFork(title) {
|
||||||
if (value.startsWith(TITLE_PREFIX)) {
|
if (value.startsWith(TITLE_PREFIX)) {
|
||||||
return value.slice(TITLE_PREFIX.length).trim() || value
|
return value.slice(TITLE_PREFIX.length).trim() || value
|
||||||
}
|
}
|
||||||
|
if (value.startsWith('运维定时')) {
|
||||||
|
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
|
||||||
|
return stripped || value
|
||||||
|
}
|
||||||
|
// Legacy upstream prefix (dsh-cron-tasks).
|
||||||
if (value.startsWith('定时任务')) {
|
if (value.startsWith('定时任务')) {
|
||||||
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
|
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
|
||||||
return stripped || value
|
return stripped || value
|
||||||
|
|
@ -55,7 +60,7 @@ export function shouldPromoteCronFork(row, cronSessionIds, previousId) {
|
||||||
const parent = String(row.parentId || row.parentSessionId || '').trim()
|
const parent = String(row.parentId || row.parentSessionId || '').trim()
|
||||||
if (parent && ids.has(parent)) return true
|
if (parent && ids.has(parent)) return true
|
||||||
const title = String(row.title || row.displayTitle || '')
|
const title = String(row.title || row.displayTitle || '')
|
||||||
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务')
|
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时') || title.startsWith('定时任务')
|
||||||
if (previousId && ids.has(String(previousId)) && looksCron) return true
|
if (previousId && ids.has(String(previousId)) && looksCron) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function wrapScheduledPrompt(prompt, meta = {}) {
|
||||||
const runId = meta.runId ? String(meta.runId) : ''
|
const runId = meta.runId ? String(meta.runId) : ''
|
||||||
const scheduledAt = meta.scheduledAt != null ? String(meta.scheduledAt) : ''
|
const scheduledAt = meta.scheduledAt != null ? String(meta.scheduledAt) : ''
|
||||||
const lines = [
|
const lines = [
|
||||||
'This message is a scheduled task dispatched by the dsh-cron-tasks plugin.',
|
'This message is a scheduled task dispatched by the dsh-ops-cron plugin.',
|
||||||
'It is not a human message typed in this conversation.',
|
'It is not a human message typed in this conversation.',
|
||||||
jobName || jobId ? `Job: ${jobName}${jobId ? ` (${jobId})` : ''}` : null,
|
jobName || jobId ? `Job: ${jobName}${jobId ? ` (${jobId})` : ''}` : null,
|
||||||
runId ? `Run: ${runId}` : null,
|
runId ? `Run: ${runId}` : null,
|
||||||
|
|
@ -27,7 +27,7 @@ export function wrapScheduledPrompt(prompt, meta = {}) {
|
||||||
return lines.filter((line) => line !== null).join('\n')
|
return lines.filter((line) => line !== null).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduledPromptSource(pluginName = 'dsh-cron-tasks') {
|
export function scheduledPromptSource(pluginName = 'dsh-ops-cron') {
|
||||||
return {
|
return {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
plugin: pluginName,
|
plugin: pluginName,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { dirname, join } from 'node:path'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { applyRunIsolation, recordHiddenSession } from './isolation.js'
|
import { applyRunIsolation, recordHiddenSession } from './isolation.js'
|
||||||
import { nextFire, validateSchedule } from './scheduler.js'
|
import { nextFire, validateSchedule } from './scheduler.js'
|
||||||
|
import { normalizeDelivery } from './delivery.js'
|
||||||
|
|
||||||
export const STORE_VERSION = 1
|
export const STORE_VERSION = 1
|
||||||
|
|
||||||
|
|
@ -92,6 +93,7 @@ export function createJobRecord(input, state, now) {
|
||||||
const schedule = validateSchedule(input.schedule, settings.timezone)
|
const schedule = validateSchedule(input.schedule, settings.timezone)
|
||||||
const timeoutMinutes = Number(input.timeoutMinutes)
|
const timeoutMinutes = Number(input.timeoutMinutes)
|
||||||
const model = normalizeJobModel(input)
|
const model = normalizeJobModel(input)
|
||||||
|
const delivery = normalizeDelivery(input.delivery)
|
||||||
const job = {
|
const job = {
|
||||||
id: String(input.id || newId()),
|
id: String(input.id || newId()),
|
||||||
name,
|
name,
|
||||||
|
|
@ -104,6 +106,7 @@ export function createJobRecord(input, state, now) {
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
model: model.model,
|
model: model.model,
|
||||||
reasoningEffort: model.reasoningEffort,
|
reasoningEffort: model.reasoningEffort,
|
||||||
|
delivery,
|
||||||
schedule: schedule.kind === 'cron'
|
schedule: schedule.kind === 'cron'
|
||||||
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
|
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
|
||||||
: { kind: 'at', at: new Date(schedule.at).toISOString(), timezone: schedule.timezone },
|
: { kind: 'at', at: new Date(schedule.at).toISOString(), timezone: schedule.timezone },
|
||||||
|
|
@ -253,5 +256,5 @@ function hydrate(parsed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storePath(dshHome) {
|
export function storePath(dshHome) {
|
||||||
return join(dshHome, 'cron-tasks', 'store.json')
|
return join(dshHome, 'ops-cron', 'store.json')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
lib/tools.js
57
lib/tools.js
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatInZone, resolveTodayAt } from './scheduler.js'
|
import { formatInZone, resolveTodayAt } from './scheduler.js'
|
||||||
|
import { deliveryLine, resolveCreateDelivery } from './delivery.js'
|
||||||
|
|
||||||
const JOB_SCHEMA = {
|
const JOB_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
@ -18,6 +19,7 @@ const JOB_SCHEMA = {
|
||||||
provider: { type: 'string' },
|
provider: { type: 'string' },
|
||||||
model: { type: 'string' },
|
model: { type: 'string' },
|
||||||
reasoningEffort: { type: 'string' },
|
reasoningEffort: { type: 'string' },
|
||||||
|
delivery: { type: 'object', additionalProperties: true },
|
||||||
schedule: { type: 'object', additionalProperties: true },
|
schedule: { type: 'object', additionalProperties: true },
|
||||||
createdAt: { type: 'number' },
|
createdAt: { type: 'number' },
|
||||||
updatedAt: { type: 'number' },
|
updatedAt: { type: 'number' },
|
||||||
|
|
@ -93,7 +95,8 @@ function jobLine(job) {
|
||||||
? `at ${job.schedule.at}`
|
? `at ${job.schedule.at}`
|
||||||
: (job.schedule?.expr || 'cron')
|
: (job.schedule?.expr || 'cron')
|
||||||
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
|
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
|
||||||
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} next=${when} id=${job.id}`
|
const delivery = deliveryLine(job.delivery)
|
||||||
|
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery} next=${when} id=${job.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function callerWorkingDirectory(exec) {
|
export function callerWorkingDirectory(exec) {
|
||||||
|
|
@ -137,16 +140,17 @@ function requireId(args) {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cronToolDefinitions(service) {
|
export function cronToolDefinitions(service, deps = {}) {
|
||||||
|
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'cron_create',
|
name: 'cron_create',
|
||||||
description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Scheduled runs consume that model\'s quota, not whatever the user happens to have selected later.',
|
description: 'Create a DSH 运维定时 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Delivery: omit for auto (WhatsApp/IM session → im if a matching 投递目标 exists; else dsh). Or pass delivery=dsh|im with im_bot_id+im_target_id from IM 投递设置.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', description: 'Short display name in the 定时任务 list.' },
|
name: { type: 'string', description: 'Short display name in the 运维定时 list.' },
|
||||||
prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' },
|
prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' },
|
||||||
expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' },
|
expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' },
|
||||||
cron: { type: 'string', description: 'Alias of expr.' },
|
cron: { type: 'string', description: 'Alias of expr.' },
|
||||||
|
|
@ -163,6 +167,9 @@ export function cronToolDefinitions(service) {
|
||||||
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
||||||
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
||||||
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
||||||
|
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session.' },
|
||||||
|
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' },
|
||||||
|
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' },
|
||||||
},
|
},
|
||||||
required: ['name', 'prompt'],
|
required: ['name', 'prompt'],
|
||||||
},
|
},
|
||||||
|
|
@ -183,14 +190,16 @@ export function cronToolDefinitions(service) {
|
||||||
const model = job?.provider && job?.model
|
const model = job?.provider && job?.model
|
||||||
? ` model=${job.provider}/${job.model}`
|
? ` model=${job.provider}/${job.model}`
|
||||||
: ' model=(new-session default at fire time)'
|
: ' model=(new-session default at fire time)'
|
||||||
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
|
const delivery = ` ${deliveryLine(job?.delivery)}`
|
||||||
|
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '创建运维定时', content: String(args?.name || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const timeout = Number(args.timeout_minutes)
|
const timeout = Number(args.timeout_minutes)
|
||||||
try {
|
try {
|
||||||
|
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
|
||||||
const job = await service.createJob({
|
const job = await service.createJob({
|
||||||
name: args.name,
|
name: args.name,
|
||||||
prompt: args.prompt,
|
prompt: args.prompt,
|
||||||
|
|
@ -199,6 +208,7 @@ export function cronToolDefinitions(service) {
|
||||||
...resolveCreateModel(args, exec),
|
...resolveCreateModel(args, exec),
|
||||||
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
||||||
enabled: args.enabled !== false,
|
enabled: args.enabled !== false,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
return { job }
|
return { job }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -215,7 +225,7 @@ export function cronToolDefinitions(service) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cron_list',
|
name: 'cron_list',
|
||||||
description: 'List DSH 定时任务 (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
|
description: 'List DSH 运维定时 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -238,7 +248,7 @@ export function cronToolDefinitions(service) {
|
||||||
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
|
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
|
presentCall: () => ({ card: 'generic', title: '列出运维定时' }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
let jobs = await service.listJobs()
|
let jobs = await service.listJobs()
|
||||||
|
|
@ -248,7 +258,7 @@ export function cronToolDefinitions(service) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cron_pause',
|
name: 'cron_pause',
|
||||||
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.',
|
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 运维定时 but will not fire until resumed.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -259,7 +269,7 @@ export function cronToolDefinitions(service) {
|
||||||
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
||||||
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
|
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
|
||||||
},
|
},
|
||||||
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '暂停运维定时', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const job = await service.pauseJob(requireId(args), false)
|
const job = await service.pauseJob(requireId(args), false)
|
||||||
|
|
@ -279,7 +289,7 @@ export function cronToolDefinitions(service) {
|
||||||
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
|
||||||
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
|
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
|
||||||
},
|
},
|
||||||
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '恢复运维定时', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const job = await service.pauseJob(requireId(args), true)
|
const job = await service.pauseJob(requireId(args), true)
|
||||||
|
|
@ -307,7 +317,7 @@ export function cronToolDefinitions(service) {
|
||||||
},
|
},
|
||||||
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
|
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
|
||||||
},
|
},
|
||||||
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '删除运维定时', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const id = requireId(args)
|
const id = requireId(args)
|
||||||
|
|
@ -326,16 +336,17 @@ export function cronToolDefinitions(service) {
|
||||||
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
|
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
|
||||||
const now = formatInZone(nowMs, timeZone)
|
const now = formatInZone(nowMs, timeZone)
|
||||||
return [
|
return [
|
||||||
'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks).',
|
'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).',
|
||||||
'Jobs live in that list and fire a fresh session. They are NOT OS crontab and NOT in-chat schedule_create reminders.',
|
'Jobs live in that list and fire a fresh session (and optionally deliver a summary to WhatsApp/IM). They are NOT OS crontab and NOT in-chat schedule_create reminders.',
|
||||||
`Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`,
|
`Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`,
|
||||||
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
|
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
|
||||||
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
|
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
|
||||||
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd. Do not invent ~/.dsh/cron-tasks/workspace unless they asked for an isolated folder.',
|
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.',
|
||||||
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota. Ask which model if the user cares about billing.',
|
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
|
||||||
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
|
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im (requires a matching 投递目标 for this peer). On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id from IM 投递设置 → 复制调用参数.',
|
||||||
|
'When the user asks to look at, create, pause, resume, or delete 运维定时 / 定时任务 / scheduled tasks / cron jobs:',
|
||||||
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
|
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
|
||||||
'2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
|
'2. If they are not listed, search/unlock tools or skills with query "运维定时" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
|
||||||
'3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
|
'3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
@ -345,15 +356,15 @@ export const CRON_GUIDANCE = cronGuidanceText()
|
||||||
export function makeCronSkill() {
|
export function makeCronSkill() {
|
||||||
return {
|
return {
|
||||||
name: 'scheduled-tasks',
|
name: 'scheduled-tasks',
|
||||||
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。不要用系统 crontab,也不是会话内 reminder。',
|
description: '运维定时: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
|
||||||
whenToUse: 'User asks to list or create 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.',
|
whenToUse: 'User asks to list or create 运维定时 / 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.',
|
||||||
source: 'runtime',
|
source: 'runtime',
|
||||||
provider: 'runtime',
|
provider: 'runtime',
|
||||||
content: `${cronGuidanceText()}
|
content: `${cronGuidanceText()}
|
||||||
|
|
||||||
Tools:
|
Tools:
|
||||||
- cron_list — list jobs
|
- cron_list — list jobs
|
||||||
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model.
|
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery auto from session, or delivery=im + im_bot_id + im_target_id.
|
||||||
- cron_pause / cron_resume / cron_delete — by id from cron_list
|
- cron_pause / cron_resume / cron_delete — by id from cron_list
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
@ -361,10 +372,10 @@ Tools:
|
||||||
|
|
||||||
export const CRON_SKILL = makeCronSkill()
|
export const CRON_SKILL = makeCronSkill()
|
||||||
|
|
||||||
export function registerCronTools(ctx, service) {
|
export function registerCronTools(ctx, service, deps = {}) {
|
||||||
const tools = ctx?.tools
|
const tools = ctx?.tools
|
||||||
if (!tools || typeof tools.register !== 'function') return () => {}
|
if (!tools || typeof tools.register !== 'function') return () => {}
|
||||||
const offs = cronToolDefinitions(service).map((definition) => tools.register(definition))
|
const offs = cronToolDefinitions(service, deps).map((definition) => tools.register(definition))
|
||||||
return () => {
|
return () => {
|
||||||
for (const off of offs) {
|
for (const off of offs) {
|
||||||
if (typeof off === 'function') off()
|
if (typeof off === 'function') off()
|
||||||
|
|
|
||||||
17
package.json
17
package.json
|
|
@ -1,23 +1,25 @@
|
||||||
{
|
{
|
||||||
"name": "@dsh-external/dsh-cron-tasks",
|
"name": "dsh-ops-cron",
|
||||||
"description": "Scheduled tasks for DeepSeek Harness: sidebar entry under 新会话, plugin settings, and run history kept out of the workspace list.",
|
"description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/Whale-Zhang/dsh-cron-tasks.git"
|
"url": "git+https://github.com/hansjone/dsh-ops-cron.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Whale-Zhang/dsh-cron-tasks/issues"
|
"url": "https://github.com/hansjone/dsh-ops-cron/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Whale-Zhang/dsh-cron-tasks#readme",
|
"homepage": "https://github.com/hansjone/dsh-ops-cron#readme",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"dsh-plugin",
|
"dsh-plugin",
|
||||||
"dsh",
|
"dsh",
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
"cron",
|
"cron",
|
||||||
"scheduled-tasks"
|
"scheduled-tasks",
|
||||||
|
"ops",
|
||||||
|
"whatsapp"
|
||||||
],
|
],
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
|
|
@ -35,6 +37,7 @@
|
||||||
"cordis.patch.yml",
|
"cordis.patch.yml",
|
||||||
"docs",
|
"docs",
|
||||||
"README.md",
|
"README.md",
|
||||||
|
"FORK.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
68
test/delivery.test.js
Normal file
68
test/delivery.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
matchTargetForPeer,
|
||||||
|
normalizeDelivery,
|
||||||
|
resolveCreateDelivery,
|
||||||
|
deliverRunToIm,
|
||||||
|
} from '../lib/delivery.js'
|
||||||
|
|
||||||
|
test('normalizeDelivery defaults to dsh', () => {
|
||||||
|
assert.deepEqual(normalizeDelivery({}), { kind: 'dsh' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizeDelivery requires botId+targetId for im', () => {
|
||||||
|
assert.throws(() => normalizeDelivery({ kind: 'im', botId: 'b' }), /im delivery/)
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeDelivery({ kind: 'im', botId: 'bot_1', targetId: 'alerts' }),
|
||||||
|
{ kind: 'im', botId: 'bot_1', targetId: 'alerts' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matchTargetForPeer matches whatsapp jid from conversationKey', () => {
|
||||||
|
const peer = { botId: 'bot_1', conversationKey: 'direct:8613800000000@s.whatsapp.net' }
|
||||||
|
const targets = [
|
||||||
|
{ targetId: 'other', route: { jid: '1@s.whatsapp.net' } },
|
||||||
|
{ targetId: 'alerts', route: { jid: '8613800000000@s.whatsapp.net' } },
|
||||||
|
]
|
||||||
|
assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveCreateDelivery uses explicit im args', async () => {
|
||||||
|
const delivery = await resolveCreateDelivery({
|
||||||
|
delivery: 'im',
|
||||||
|
im_bot_id: 'bot_x',
|
||||||
|
im_target_id: 'tgt_y',
|
||||||
|
}, {})
|
||||||
|
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_x', targetId: 'tgt_y' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveCreateDelivery auto-matches peer target', async () => {
|
||||||
|
const dshIm = {
|
||||||
|
resolveSessionPeer: async () => ({
|
||||||
|
botId: 'bot_1',
|
||||||
|
conversationKey: 'direct:86138@s.whatsapp.net',
|
||||||
|
}),
|
||||||
|
listTargets: async () => ([
|
||||||
|
{ targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } },
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
const delivery = await resolveCreateDelivery({}, {
|
||||||
|
agent: { session: { id: 'sess-1' } },
|
||||||
|
}, { dshIm })
|
||||||
|
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deliverRunToIm sends via dshIm', async () => {
|
||||||
|
const sent = []
|
||||||
|
await deliverRunToIm(
|
||||||
|
{ name: 'ping', delivery: { kind: 'im', botId: 'b', targetId: 't' } },
|
||||||
|
'hello world',
|
||||||
|
{ dshIm: { send: async (...args) => { sent.push(args) } } },
|
||||||
|
)
|
||||||
|
assert.equal(sent.length, 1)
|
||||||
|
assert.equal(sent[0][0], 'b')
|
||||||
|
assert.equal(sent[0][1], 't')
|
||||||
|
assert.match(sent[0][2], /运维定时/)
|
||||||
|
assert.match(sent[0][2], /hello world/)
|
||||||
|
})
|
||||||
|
|
@ -89,7 +89,7 @@ async function jsonRequest(base, path, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
|
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
|
||||||
const archived = []
|
const archived = []
|
||||||
|
|
@ -163,7 +163,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as
|
||||||
})
|
})
|
||||||
|
|
||||||
test('overlap skip writes a skipped history row instead of a second session', async (t) => {
|
test('overlap skip writes a skipped history row instead of a second session', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||||
let inflight = 0
|
let inflight = 0
|
||||||
|
|
@ -192,7 +192,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const archived = []
|
const archived = []
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
|
|
@ -210,7 +210,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
|
|
||||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: 'http job',
|
name: 'http job',
|
||||||
|
|
@ -222,23 +222,23 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||||
assert.equal(created.body.job.name, 'http job')
|
assert.equal(created.body.job.name, 'http job')
|
||||||
const jobId = created.body.job.id
|
const jobId = created.body.job.id
|
||||||
|
|
||||||
const listed = await jsonRequest(http.url, '/dsh-cron-tasks/jobs')
|
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs')
|
||||||
assert.equal(listed.body.jobs.length, 1)
|
assert.equal(listed.body.jobs.length, 1)
|
||||||
|
|
||||||
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${jobId}/run`, { method: 'POST' })
|
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { method: 'POST' })
|
||||||
assert.equal(ran.status, 200)
|
assert.equal(ran.status, 200)
|
||||||
assert.equal(ran.body.run.sessionId, 'http-sess-1')
|
assert.equal(ran.body.run.sessionId, 'http-sess-1')
|
||||||
assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
|
assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
|
||||||
assert.deepEqual(archived, ['http-sess-1'])
|
assert.deepEqual(archived, ['http-sess-1'])
|
||||||
|
|
||||||
const history = await jsonRequest(http.url, '/dsh-cron-tasks/history')
|
const history = await jsonRequest(http.url, '/dsh-ops-cron/history')
|
||||||
assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1'))
|
assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1'))
|
||||||
const visible = await jsonRequest(http.url, '/dsh-cron-tasks/workspace-visible?id=http-sess-1&id=other')
|
const visible = await jsonRequest(http.url, '/dsh-ops-cron/workspace-visible?id=http-sess-1&id=other')
|
||||||
assert.deepEqual(visible.body.visible, ['other'])
|
assert.deepEqual(visible.body.visible, ['other'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /runs/:id/open reveals the run session id', async (t) => {
|
test('POST /runs/:id/open reveals the run session id', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const revealed = []
|
const revealed = []
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
|
|
@ -253,7 +253,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
|
||||||
})
|
})
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: 'open-job',
|
name: 'open-job',
|
||||||
|
|
@ -261,8 +261,8 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
|
||||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
||||||
const opened = await jsonRequest(http.url, `/dsh-cron-tasks/runs/${ran.body.run.id}/open`, { method: 'POST' })
|
const opened = await jsonRequest(http.url, `/dsh-ops-cron/runs/${ran.body.run.id}/open`, { method: 'POST' })
|
||||||
assert.equal(opened.status, 200)
|
assert.equal(opened.status, 200)
|
||||||
assert.equal(opened.body.sessionId, 'open-sess')
|
assert.equal(opened.body.sessionId, 'open-sess')
|
||||||
assert.deepEqual(revealed, ['open-sess'])
|
assert.deepEqual(revealed, ['open-sess'])
|
||||||
|
|
@ -330,7 +330,7 @@ test('resolveDefaultModel fails loud when Models has no selection', async () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /conceal archives every run session id', async (t) => {
|
test('POST /conceal archives every run session id', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const archived = []
|
const archived = []
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
|
|
@ -345,7 +345,7 @@ test('POST /conceal archives every run session id', async (t) => {
|
||||||
})
|
})
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: 'hide-job',
|
name: 'hide-job',
|
||||||
|
|
@ -353,15 +353,15 @@ test('POST /conceal archives every run session id', async (t) => {
|
||||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
||||||
archived.length = 0
|
archived.length = 0
|
||||||
const hidden = await jsonRequest(http.url, '/dsh-cron-tasks/conceal', { method: 'POST' })
|
const hidden = await jsonRequest(http.url, '/dsh-ops-cron/conceal', { method: 'POST' })
|
||||||
assert.equal(hidden.status, 200)
|
assert.equal(hidden.status, 200)
|
||||||
assert.ok(archived.includes('hide-sess'))
|
assert.ok(archived.includes('hide-sess'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => {
|
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const at = Date.parse('2026-08-23T14:57:00.000Z')
|
const at = Date.parse('2026-08-23T14:57:00.000Z')
|
||||||
let clock = at - 60_000
|
let clock = at - 60_000
|
||||||
|
|
@ -508,7 +508,7 @@ test('listModelChoices maps llm providers and the current default', async () =>
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /models lists session-port catalog', async (t) => {
|
test('GET /models lists session-port catalog', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
filePath: join(dir, 'store.json'),
|
filePath: join(dir, 'store.json'),
|
||||||
|
|
@ -520,7 +520,7 @@ test('GET /models lists session-port catalog', async (t) => {
|
||||||
})
|
})
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/models')
|
const res = await jsonRequest(http.url, '/dsh-ops-cron/models')
|
||||||
assert.equal(res.status, 200)
|
assert.equal(res.status, 200)
|
||||||
assert.equal(res.body.current.model, 'deepseek-chat')
|
assert.equal(res.body.current.model, 'deepseek-chat')
|
||||||
assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat')
|
assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat')
|
||||||
|
|
@ -547,7 +547,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /workspaces lists session-port choices', async (t) => {
|
test('GET /workspaces lists session-port choices', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
filePath: join(dir, 'store.json'),
|
filePath: join(dir, 'store.json'),
|
||||||
|
|
@ -559,13 +559,13 @@ test('GET /workspaces lists session-port choices', async (t) => {
|
||||||
})
|
})
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/workspaces')
|
const res = await jsonRequest(http.url, '/dsh-ops-cron/workspaces')
|
||||||
assert.equal(res.status, 200)
|
assert.equal(res.status, 200)
|
||||||
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
|
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /sessions/:id/adopt uses the session port', async (t) => {
|
test('POST /sessions/:id/adopt uses the session port', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const adopted = []
|
const adopted = []
|
||||||
const service = createHostService({
|
const service = createHostService({
|
||||||
|
|
@ -579,7 +579,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => {
|
||||||
})
|
})
|
||||||
const http = await listen(service)
|
const http = await listen(service)
|
||||||
t.after(() => http.close())
|
t.after(() => http.close())
|
||||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/sessions/child-9/adopt', { method: 'POST' })
|
const res = await jsonRequest(http.url, '/dsh-ops-cron/sessions/child-9/adopt', { method: 'POST' })
|
||||||
assert.equal(res.status, 200)
|
assert.equal(res.status, 200)
|
||||||
assert.equal(res.body.attached, true)
|
assert.equal(res.body.attached, true)
|
||||||
assert.deepEqual(adopted, ['child-9'])
|
assert.deepEqual(adopted, ['child-9'])
|
||||||
|
|
@ -598,7 +598,7 @@ test('waitForAgentTurn waits for running then idle, and fails if the agent never
|
||||||
})
|
})
|
||||||
|
|
||||||
test('after one live-shaped dispatch, the next run-now still fires', async (t) => {
|
test('after one live-shaped dispatch, the next run-now still fires', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const archived = []
|
const archived = []
|
||||||
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
|
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
|
||||||
|
|
@ -632,7 +632,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) =
|
||||||
})
|
})
|
||||||
|
|
||||||
test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => {
|
test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
const archived = []
|
const archived = []
|
||||||
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,19 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => {
|
test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => {
|
||||||
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
||||||
assert.equal(pkg.name, '@dsh-external/dsh-cron-tasks')
|
assert.equal(pkg.name, 'dsh-ops-cron')
|
||||||
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
|
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
|
||||||
assert.equal(pkg.dsh.client.platform, 'web')
|
assert.equal(pkg.dsh.client.platform, 'web')
|
||||||
assert.ok(pkg.exports['./client'])
|
assert.ok(pkg.exports['./client'])
|
||||||
assert.equal(pkg.main, 'lib/index.js')
|
assert.equal(pkg.main, 'lib/index.js')
|
||||||
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
|
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
|
||||||
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
|
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
|
||||||
assert.match(patch, /id:\s*dsh-cron-tasks/)
|
assert.match(patch, /id:\s*dsh-ops-cron/)
|
||||||
const index = await readFile(join(root, 'lib/index.js'), 'utf8')
|
const index = await readFile(join(root, 'lib/index.js'), 'utf8')
|
||||||
assert.match(index, /export function apply\(/)
|
assert.match(index, /export function apply\(/)
|
||||||
assert.match(index, /registerCronTools/)
|
assert.match(index, /registerCronTools/)
|
||||||
assert.match(index, /inject\(\['tools'\]/)
|
assert.match(index, /inject\(\['tools'\]/)
|
||||||
|
assert.match(index, /getDshIm/)
|
||||||
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
|
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
|
||||||
assert.match(tools, /name: 'cron_create'/)
|
assert.match(tools, /name: 'cron_create'/)
|
||||||
assert.match(tools, /name: 'cron_list'/)
|
assert.match(tools, /name: 'cron_list'/)
|
||||||
|
|
@ -28,9 +29,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
||||||
assert.match(tools, /hour/)
|
assert.match(tools, /hour/)
|
||||||
assert.match(tools, /Current local time/)
|
assert.match(tools, /Current local time/)
|
||||||
assert.match(tools, /source: 'runtime'/)
|
assert.match(tools, /source: 'runtime'/)
|
||||||
assert.match(tools, /current session cwd/)
|
assert.match(tools, /resolveCreateDelivery/)
|
||||||
|
assert.match(tools, /im_bot_id/)
|
||||||
assert.match(tools, /resolveCreateCwd/)
|
assert.match(tools, /resolveCreateCwd/)
|
||||||
assert.match(tools, /Working directory: if the user is chatting in a workspace/)
|
|
||||||
assert.match(tools, /after_minutes/)
|
assert.match(tools, /after_minutes/)
|
||||||
assert.match(tools, /Extra schedule fields are ignored/)
|
assert.match(tools, /Extra schedule fields are ignored/)
|
||||||
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
|
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
|
||||||
|
|
@ -40,80 +41,26 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
||||||
assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
|
assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
|
||||||
const host = await readFile(join(root, 'lib/host.js'), 'utf8')
|
const host = await readFile(join(root, 'lib/host.js'), 'utf8')
|
||||||
assert.match(host, /agentDefaultModel/)
|
assert.match(host, /agentDefaultModel/)
|
||||||
assert.match(host, /system-prompt\/assemble/)
|
assert.match(host, /deliverRunToIm/)
|
||||||
assert.match(host, /agentOptions/)
|
assert.match(host, /PLUGIN_NAME = 'dsh-ops-cron'/)
|
||||||
assert.match(host, /nextRunAt === null/)
|
assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/)
|
||||||
assert.match(host, /resolveSessionPlacement/)
|
assert.match(host, /resolveSessionPlacement/)
|
||||||
assert.match(host, /adoptSessionIntoWorkspace/)
|
|
||||||
assert.match(host, /attachLiveSessionToWorkspace/)
|
|
||||||
assert.match(host, /listWorkspaceChoices/)
|
|
||||||
assert.match(host, /\/workspaces/)
|
|
||||||
assert.match(host, /listModelChoices/)
|
|
||||||
assert.match(host, /resolveJobModel/)
|
|
||||||
assert.match(host, /\/models/)
|
assert.match(host, /\/models/)
|
||||||
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
|
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
|
||||||
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
|
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
|
||||||
assert.match(client, /定时任务/)
|
assert.match(client, /运维定时/)
|
||||||
|
assert.match(client, /dsh-ops-cron/)
|
||||||
assert.match(client, /settings\.plugin\.item/)
|
assert.match(client, /settings\.plugin\.item/)
|
||||||
assert.match(client, /新建会话/)
|
assert.match(client, /deliveryKind/)
|
||||||
assert.match(client, /logoRow/)
|
assert.match(client, /imBotId/)
|
||||||
assert.match(client, /dsh-ct-entry/)
|
assert.match(client, /imTargetId/)
|
||||||
assert.match(client, /centerCol/)
|
|
||||||
assert.match(client, /data-dsh-ct-host/)
|
|
||||||
assert.match(client, /_collapsed"\]\[data-dsh-ct-mode=on\] \.dsh-ct-region/)
|
|
||||||
assert.match(client, /listRoot\.parentElement !== region\) region\.appendChild\(listRoot\)/)
|
|
||||||
assert.match(client, /jobStamp/)
|
|
||||||
assert.doesNotMatch(client, /\[data-dsh-ct-mode=on\] \.dsh-ct-region\{display:flex !important;animation:/)
|
|
||||||
assert.match(client, /visibility:hidden/)
|
|
||||||
assert.match(client, /createPortal\(editor, paneHost\)/)
|
|
||||||
assert.match(client, /sidebarCollapsed/)
|
|
||||||
assert.match(client, /_collapsed/)
|
|
||||||
assert.match(client, /width:36px/)
|
|
||||||
assert.match(client, /data-collapsed/)
|
|
||||||
assert.match(client, /translate\(-50%, -50%\)/)
|
|
||||||
assert.match(client, /返回工作区/)
|
|
||||||
assert.match(client, /runs\/\$\{run\.id\}\/open/)
|
|
||||||
assert.match(client, /regionArea/)
|
|
||||||
assert.match(client, /sessions\.open\(/)
|
|
||||||
assert.match(client, /getService\(ctx, faces, 'sessions'\)/)
|
|
||||||
assert.match(client, /workspaceSkin/)
|
|
||||||
assert.match(client, /IconFolderOpen16/)
|
|
||||||
assert.match(client, /IconProjectAddOutline16/)
|
|
||||||
assert.match(client, /IconPlayOutline16/)
|
|
||||||
assert.match(client, /IconPauseOutline16/)
|
|
||||||
assert.match(client, /IconSearchOutline16/)
|
|
||||||
assert.match(client, /searchPlaceholder/)
|
|
||||||
assert.match(client, /\/conceal/)
|
|
||||||
assert.match(client, /shouldPromoteCronFork/)
|
|
||||||
assert.match(client, /workspaceTitleFromCronFork/)
|
|
||||||
assert.match(client, /leaveCronMode/)
|
|
||||||
assert.match(client, /fork rename failed/)
|
|
||||||
assert.match(client, /sessions\/\$\{encodeURIComponent\(row\.id\)\}\/adopt/)
|
|
||||||
assert.match(client, /sessions\.length > 0 && visible\.length === 0/)
|
|
||||||
assert.match(client, /未分组|未分类/)
|
|
||||||
assert.match(client, /expanded\[job\.id\] === true/)
|
|
||||||
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
|
|
||||||
assert.doesNotMatch(client, /inject = \[[^\]]*sessions[^\]]*\]/)
|
|
||||||
assert.match(client, /工作目录/)
|
|
||||||
assert.match(client, /cwdRecent/)
|
|
||||||
assert.match(client, /cwdCustom/)
|
|
||||||
assert.match(client, /CwdField/)
|
assert.match(client, /CwdField/)
|
||||||
assert.match(client, /ModelField/)
|
assert.match(client, /ModelField/)
|
||||||
assert.match(client, /api\('\/models'\)/)
|
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
|
||||||
assert.match(tools, /resolveCreateModel/)
|
const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')
|
||||||
assert.match(tools, /snapshots the current session model/)
|
assert.match(delivery, /resolveCreateDelivery/)
|
||||||
assert.match(client, /api\('\/workspaces'\)/)
|
assert.match(delivery, /deliverRunToIm/)
|
||||||
assert.match(client, /atToLocalInput/)
|
|
||||||
assert.match(client, /localInputToAt/)
|
|
||||||
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
|
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
|
||||||
assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/)
|
assert.match(prd, /定时任务/)
|
||||||
assert.match(prd, /设置页「插件配置」里的定时任务设置/)
|
|
||||||
assert.match(prd, /定时任务历史/)
|
|
||||||
assert.match(prd, /chicheng-cron/)
|
|
||||||
assert.match(prd, /dsh-automation/)
|
|
||||||
assert.match(prd, /cronjob-dsh-plugin/)
|
|
||||||
assert.match(prd, /dsh-cron/)
|
|
||||||
assert.match(prd, /dsh-schedule-tasks/)
|
|
||||||
assert.match(prd, /dsh-schedule/)
|
assert.match(prd, /dsh-schedule/)
|
||||||
assert.match(prd, /非目标/)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue