Skip to content

Commit e3ac1c5

Browse files
committed
feat: friendly duration format for schedule (30s/5min/1h/2d/PT30M)
1 parent 30ddb57 commit e3ac1c5

5 files changed

Lines changed: 163 additions & 10 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-supertask",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "AI Agent 任务调度系统 — OpenCode 插件 + CLI + Gateway 常驻进程",
55
"type": "module",
66
"main": "dist/plugin/supertask.js",

plugin/supertask.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { type Plugin, type Hooks, tool } from "@opencode-ai/plugin";
1010
import { TaskService } from "@core/services/task.service";
1111
import { TaskTemplateService } from "@core/services/task-template.service";
1212
import { getDb, sqlite } from "@core/db";
13+
import { parseDuration } from "@core/duration";
1314
import { ensureGateway } from "../src/daemon/pm2";
1415

1516
let _initialized = false;
@@ -460,8 +461,8 @@ export const SuperTaskPlugin: Plugin = async () => {
460461
.object({
461462
type: tool.schema.enum(["cron", "delayed", "recurring"]).describe("调度类型"),
462463
cron_expr: tool.schema.string().optional().describe("cron 表达式(cron 类型必填,如 '0 9 * * 1-5')"),
463-
run_at: tool.schema.number().optional().describe("执行时间戳 ms(delayed 类型必填)"),
464-
interval_ms: tool.schema.number().optional().describe("间隔毫秒(recurring 类型必填)"),
464+
delay: tool.schema.string().optional().describe("延迟时间(delayed 类型必填),友好格式如 '30s' '5min' '1h' '2d',也支持 ISO 8601 duration 如 'PT30M'"),
465+
interval: tool.schema.string().optional().describe("循环间隔(recurring 类型必填),友好格式如 '1h' '30min' '5s',也支持 ISO 8601 duration 如 'PT1H'"),
465466
})
466467
.describe("调度配置"),
467468
max_instances: tool.schema.number().optional().describe("最大并发实例数,默认 1"),
@@ -474,6 +475,26 @@ export const SuperTaskPlugin: Plugin = async () => {
474475
return JSON.stringify({ error: "schedule is required" });
475476
}
476477
const scheduleType = args.schedule.type as import("@core/db/schema").ScheduleType;
478+
479+
let cronExpr = args.schedule.cron_expr;
480+
let intervalMs: number | null = null;
481+
let runAt: number | null = null;
482+
483+
if (scheduleType === "delayed" && args.schedule.delay) {
484+
const delayMs = parseDuration(args.schedule.delay);
485+
if (delayMs === null) {
486+
return JSON.stringify({ error: `Invalid delay format: "${args.schedule.delay}". Use formats like "30s", "5min", "1h", "2d"` });
487+
}
488+
runAt = Date.now() + delayMs;
489+
}
490+
491+
if (scheduleType === "recurring" && args.schedule.interval) {
492+
intervalMs = parseDuration(args.schedule.interval);
493+
if (intervalMs === null) {
494+
return JSON.stringify({ error: `Invalid interval format: "${args.schedule.interval}". Use formats like "30s", "5min", "1h", "2d"` });
495+
}
496+
}
497+
477498
const tmpl = await TaskTemplateService.create({
478499
name: args.name,
479500
agent: args.agent,
@@ -483,9 +504,9 @@ export const SuperTaskPlugin: Plugin = async () => {
483504
importance: args.importance ?? 3,
484505
urgency: args.urgency ?? 3,
485506
scheduleType,
486-
cronExpr: args.schedule.cron_expr,
487-
intervalMs: args.schedule.interval_ms,
488-
runAt: args.schedule.run_at,
507+
cronExpr,
508+
intervalMs,
509+
runAt,
489510
maxInstances: args.max_instances,
490511
maxRetries: args.max_retries,
491512
retryBackoffMs: args.retry_backoff_ms,

src/cli/index.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander';
22
import { TaskService } from '@core/services/task.service';
33
import { TaskTemplateService } from '@core/services/task-template.service';
44
import { closeDb } from '@core/db';
5+
import { parseDuration } from '@core/duration';
56
import type { TaskStatus, ScheduleType } from '@core/db/schema';
67

78
async function withDb<T>(fn: () => Promise<T>): Promise<T> {
@@ -221,8 +222,8 @@ program
221222
.requiredOption('-p, --prompt <prompt>', '提示词')
222223
.requiredOption('-t, --type <type>', '调度类型:cron/delayed/recurring')
223224
.option('--cron <expr>', 'cron 表达式(cron 类型必填)')
224-
.option('--interval <ms>', '间隔毫秒(recurring 类型必填)')
225-
.option('--run-at <ms>', '执行时间戳 ms(delayed 类型必填)')
225+
.option('--delay <duration>', '延迟时间(delayed 类型必填),如 30s / 5min / 1h / 2d')
226+
.option('--interval <duration>', '循环间隔(recurring 类型必填),如 1h / 30min / 5s')
226227
.option('-m, --model <model>', '模型')
227228
.option('-c, --category <category>', '分类', 'general')
228229
.option('-i, --importance <number>', '重要程度 1-5', '3')
@@ -231,6 +232,25 @@ program
231232
.option('--max-retries <number>', '最大重试次数', '3')
232233
.option('--retry-backoff <ms>', '退避基础间隔 ms', '30000')
233234
.action(async (options) => withDb(async () => {
235+
let intervalMs: number | null = null;
236+
let runAt: number | null = null;
237+
238+
if (options.interval) {
239+
intervalMs = parseDuration(options.interval);
240+
if (intervalMs === null) {
241+
console.error(JSON.stringify({ error: `Invalid interval: "${options.interval}". Use 30s / 5min / 1h / 2d` }));
242+
process.exit(1);
243+
}
244+
}
245+
if (options.delay) {
246+
const delayMs = parseDuration(options.delay);
247+
if (delayMs === null) {
248+
console.error(JSON.stringify({ error: `Invalid delay: "${options.delay}". Use 30s / 5min / 1h / 2d` }));
249+
process.exit(1);
250+
}
251+
runAt = Date.now() + delayMs;
252+
}
253+
234254
const tmpl = await TaskTemplateService.create({
235255
name: options.name,
236256
agent: options.agent,
@@ -241,8 +261,8 @@ program
241261
urgency: parseInt(options.urgency),
242262
scheduleType: options.type as ScheduleType,
243263
cronExpr: options.cron,
244-
intervalMs: options.interval ? parseInt(options.interval) : null,
245-
runAt: options.runAt ? parseInt(options.runAt) : null,
264+
intervalMs,
265+
runAt,
246266
maxInstances: parseInt(options.maxInstances),
247267
maxRetries: parseInt(options.maxRetries),
248268
retryBackoffMs: parseInt(options.retryBackoff),

src/core/duration.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|sec|seconds?|min|minutes?|m|h|hours?|d|days?|w|weeks?)$/i;
2+
const ISO8601_REGEX = /^P(?:([.\d]+)D)?(?:T(?:([.\d]+)H)?(?:([.\d]+)M)?(?:([.\d]+)S)?)?$/i;
3+
4+
export function parseDuration(input: string): number | null {
5+
const trimmed = input.trim();
6+
7+
const simple = DURATION_REGEX.exec(trimmed);
8+
if (simple) {
9+
const value = parseFloat(simple[1]);
10+
const unit = simple[2].toLowerCase();
11+
if (unit === "ms") return value;
12+
if (unit === "s" || unit === "sec" || unit === "second" || unit === "seconds") return value * 1000;
13+
if (unit === "min" || unit === "minute" || unit === "minutes" || unit === "m") return value * 60_000;
14+
if (unit === "h" || unit === "hour" || unit === "hours") return value * 3_600_000;
15+
if (unit === "d" || unit === "day" || unit === "days") return value * 86_400_000;
16+
if (unit === "w" || unit === "week" || unit === "weeks") return value * 604_800_000;
17+
}
18+
19+
const iso = ISO8601_REGEX.exec(trimmed);
20+
if (iso) {
21+
const days = parseFloat(iso[1] ?? "0");
22+
const hours = parseFloat(iso[2] ?? "0");
23+
const minutes = parseFloat(iso[3] ?? "0");
24+
const seconds = parseFloat(iso[4] ?? "0");
25+
return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
26+
}
27+
28+
const asNumber = Number(trimmed);
29+
if (!isNaN(asNumber) && asNumber > 0) return asNumber;
30+
31+
return null;
32+
}

tests/duration.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { parseDuration } from "../src/core/duration";
3+
4+
describe("parseDuration", () => {
5+
test("秒", () => {
6+
expect(parseDuration("30s")).toBe(30_000);
7+
expect(parseDuration("1s")).toBe(1000);
8+
expect(parseDuration("5sec")).toBe(5000);
9+
expect(parseDuration("10seconds")).toBe(10_000);
10+
expect(parseDuration("2 second")).toBe(2000);
11+
});
12+
13+
test("分钟", () => {
14+
expect(parseDuration("1min")).toBe(60_000);
15+
expect(parseDuration("5min")).toBe(300_000);
16+
expect(parseDuration("30minutes")).toBe(1_800_000);
17+
expect(parseDuration("1 minute")).toBe(60_000);
18+
expect(parseDuration("1m")).toBe(60_000);
19+
});
20+
21+
test("小时", () => {
22+
expect(parseDuration("1h")).toBe(3_600_000);
23+
expect(parseDuration("2h")).toBe(7_200_000);
24+
expect(parseDuration("1hours")).toBe(3_600_000);
25+
expect(parseDuration("1 hour")).toBe(3_600_000);
26+
});
27+
28+
test("天", () => {
29+
expect(parseDuration("1d")).toBe(86_400_000);
30+
expect(parseDuration("2d")).toBe(172_800_000);
31+
expect(parseDuration("1days")).toBe(86_400_000);
32+
expect(parseDuration("3 day")).toBe(259_200_000);
33+
});
34+
35+
test("周", () => {
36+
expect(parseDuration("1w")).toBe(604_800_000);
37+
expect(parseDuration("2weeks")).toBe(1_209_600_000);
38+
});
39+
40+
test("毫秒", () => {
41+
expect(parseDuration("500ms")).toBe(500);
42+
expect(parseDuration("1000ms")).toBe(1000);
43+
});
44+
45+
test("小数", () => {
46+
expect(parseDuration("1.5h")).toBe(5_400_000);
47+
expect(parseDuration("0.5d")).toBe(43_200_000);
48+
});
49+
50+
test("ISO 8601 duration", () => {
51+
expect(parseDuration("PT30M")).toBe(1_800_000);
52+
expect(parseDuration("PT1H")).toBe(3_600_000);
53+
expect(parseDuration("PT1H30M")).toBe(5_400_000);
54+
expect(parseDuration("P1DT12H")).toBe(129_600_000);
55+
expect(parseDuration("PT45S")).toBe(45_000);
56+
});
57+
58+
test("纯数字(毫秒)", () => {
59+
expect(parseDuration("60000")).toBe(60_000);
60+
expect(parseDuration("1000")).toBe(1000);
61+
});
62+
63+
test("大小写不敏感", () => {
64+
expect(parseDuration("1H")).toBe(3_600_000);
65+
expect(parseDuration("5Min")).toBe(300_000);
66+
expect(parseDuration("2D")).toBe(172_800_000);
67+
});
68+
69+
test("带空格", () => {
70+
expect(parseDuration(" 1h ")).toBe(3_600_000);
71+
expect(parseDuration("5 min")).toBe(300_000);
72+
});
73+
74+
test("无效输入返回 null", () => {
75+
expect(parseDuration("abc")).toBeNull();
76+
expect(parseDuration("")).toBeNull();
77+
expect(parseDuration("0")).toBeNull();
78+
expect(parseDuration("-1h")).toBeNull();
79+
});
80+
});

0 commit comments

Comments
 (0)