dsh-taskboard 0.7.1 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/lib/client.js +14 -9
- package/lib/host/execution.js +124 -48
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +1 -0
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduled-session.js +50 -0
- package/lib/host/scheduled-session.js.map +1 -0
- package/lib/host/scheduler.js +46 -11
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +28 -1
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/tools.js +21 -6
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +12 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +98 -6
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +88 -89
- package/src/client/board/TaskFormModal.tsx +59 -16
- package/src/client/controller.ts +6 -2
- package/src/client/i18n/en.ts +10 -0
- package/src/client/i18n/zh.ts +10 -0
- package/src/client/styles.ts +5 -0
- package/src/host/execution.ts +138 -45
- package/src/host/routes.ts +4 -3
- package/src/host/scheduled-session.ts +68 -0
- package/src/host/scheduler.ts +65 -16
- package/src/host/session-sync.ts +34 -0
- package/src/host/tools.ts +21 -6
- package/src/index.ts +9 -0
- package/src/shared/api.ts +3 -3
- package/src/shared/protocol.ts +108 -6
- package/src/shared/version.ts +1 -1
package/src/host/tools.ts
CHANGED
|
@@ -27,11 +27,13 @@ import { defineTool } from './sdk.ts'
|
|
|
27
27
|
import {
|
|
28
28
|
MAX_CHECKLIST_ITEMS,
|
|
29
29
|
asIsolation,
|
|
30
|
+
asPermission,
|
|
30
31
|
asStatus,
|
|
31
32
|
asUrgency,
|
|
32
33
|
canTransition,
|
|
33
34
|
checklistFromTexts,
|
|
34
35
|
defaultIsolationOf,
|
|
36
|
+
defaultPermissionOf,
|
|
35
37
|
effectivePrompt,
|
|
36
38
|
isClaim,
|
|
37
39
|
isClaimedBy,
|
|
@@ -87,7 +89,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
87
89
|
const lines: string[] = [
|
|
88
90
|
`任务 ${t.id} 「${t.title}」`,
|
|
89
91
|
`状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
|
|
90
|
-
`执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
|
|
92
|
+
`执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` 定期 cron=${t.execution.cron}` : ''}${t.execution.runAt !== undefined ? ` 定时(一次) runAt=${new Date(t.execution.runAt).toISOString()}` : ''}`,
|
|
91
93
|
`隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}${t.branches !== undefined ? `(多仓库镜像 ${Object.keys(t.branches).length + (t.branch !== undefined ? 1 : 0)} 个仓库)` : ''}`,
|
|
92
94
|
]
|
|
93
95
|
const holder = isClaimedBy(t)
|
|
@@ -377,7 +379,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
377
379
|
description:
|
|
378
380
|
'Create a task on the board. Required: title, workspaceId (project), urgency (urgent/normal/relaxed). '
|
|
379
381
|
+ 'Optional: description, prompt (sent to a fresh session on execution), status (default todo), '
|
|
380
|
-
+ 'execution mode (claim|scheduled +
|
|
382
|
+
+ 'execution mode (claim | scheduled+cron 定期重复 | scheduled+runAt 定时一次), model {provider, model} to pin executions to a model. '
|
|
381
383
|
+ 'Do not track trivial requests as tasks.',
|
|
382
384
|
parameters: {
|
|
383
385
|
title: { type: 'string', required: true, description: 'Short imperative line (1..200 chars).' },
|
|
@@ -389,10 +391,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
389
391
|
execution: {
|
|
390
392
|
type: 'object',
|
|
391
393
|
additionalProperties: false,
|
|
392
|
-
description: 'Execution config: { mode: "claim" } (default)
|
|
394
|
+
description: 'Execution config: { mode: "claim" } (default) | { mode: "scheduled", cron } periodic (定期, repeats) | { mode: "scheduled", runAt } one-shot (定时, fires once).',
|
|
393
395
|
properties: {
|
|
394
396
|
mode: { type: 'string', description: 'claim | scheduled.' },
|
|
395
|
-
cron: { type: 'string', description: 'Five-field cron expression (scheduled only).' },
|
|
397
|
+
cron: { type: 'string', description: 'Five-field cron expression (scheduled periodic only).' },
|
|
398
|
+
runAt: { type: 'string', description: 'One-shot trigger time: epoch ms or ISO string (scheduled one-shot only; must be in the future).' },
|
|
396
399
|
},
|
|
397
400
|
},
|
|
398
401
|
model: {
|
|
@@ -409,6 +412,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
409
412
|
type: 'string',
|
|
410
413
|
description: 'Code isolation for executions: "worktree" (each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction). Omitted → the board default (看板设置 → 默认执行隔离; factory default "none").',
|
|
411
414
|
},
|
|
415
|
+
permission: {
|
|
416
|
+
type: 'string',
|
|
417
|
+
description: 'Execution permission: "read-only", "workspace-write", or "danger-full-access". Omitted → the board default (看板设置 → 默认权限; factory default "workspace-write").',
|
|
418
|
+
},
|
|
412
419
|
presetId: {
|
|
413
420
|
type: 'string',
|
|
414
421
|
description: 'Agent preset the execution session is composed from (its tool set / persona); default = the deployment default preset. Optional.',
|
|
@@ -434,9 +441,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
434
441
|
status?: string
|
|
435
442
|
description?: string
|
|
436
443
|
prompt?: string
|
|
437
|
-
execution?: { mode?: string; cron?: string }
|
|
444
|
+
execution?: { mode?: string; cron?: string; runAt?: string | number }
|
|
438
445
|
model?: { provider?: string; model?: string }
|
|
439
446
|
isolation?: string
|
|
447
|
+
permission?: string
|
|
440
448
|
presetId?: string
|
|
441
449
|
checklist?: string[]
|
|
442
450
|
}, exec: unknown) {
|
|
@@ -457,6 +465,9 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
457
465
|
// (看板设置) at creation, so later setting changes never rewrite
|
|
458
466
|
// existing tasks.
|
|
459
467
|
const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
|
|
468
|
+
// Match the GUI create route: freeze the current board default onto
|
|
469
|
+
// the task so later settings changes do not silently alter a schedule.
|
|
470
|
+
const permission = args.permission === undefined ? defaultPermissionOf(store.snapshot().settings) : asPermission(args.permission)
|
|
460
471
|
const presetId = args.presetId?.trim() || undefined
|
|
461
472
|
// T9: match the GUI create route — trim and drop blank lines instead
|
|
462
473
|
// of failing the whole call over one empty string.
|
|
@@ -475,6 +486,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
475
486
|
execution,
|
|
476
487
|
model,
|
|
477
488
|
isolation,
|
|
489
|
+
permission,
|
|
478
490
|
...(presetId !== undefined ? { presetId } : {}),
|
|
479
491
|
...(checklist !== undefined ? { checklist } : {}),
|
|
480
492
|
version: 1,
|
|
@@ -498,7 +510,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
498
510
|
disposers.push(register(defineTool({
|
|
499
511
|
name: 'taskboard_update',
|
|
500
512
|
description:
|
|
501
|
-
'Update a task\'s title/description/prompt/urgency/blocked. Requires ifVersion (read first). '
|
|
513
|
+
'Update a task\'s title/description/prompt/urgency/blocked/permission. Requires ifVersion (read first). '
|
|
502
514
|
+ 'The model and execution config are read-only through this tool (they belong to the task owner/user).',
|
|
503
515
|
parameters: {
|
|
504
516
|
id: { type: 'string', required: true, description: 'Task id.' },
|
|
@@ -508,6 +520,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
508
520
|
prompt: { type: 'string', description: 'New execution prompt.' },
|
|
509
521
|
urgency: { type: 'string', description: 'urgent | normal | relaxed.' },
|
|
510
522
|
blocked: { type: 'boolean', description: 'Blocked marker (work cannot continue right now).' },
|
|
523
|
+
permission: { type: 'string', description: 'Execution permission: read-only | workspace-write | danger-full-access.' },
|
|
511
524
|
},
|
|
512
525
|
output: {
|
|
513
526
|
schema: JSON_OUT,
|
|
@@ -525,6 +538,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
525
538
|
prompt?: string
|
|
526
539
|
urgency?: string
|
|
527
540
|
blocked?: boolean
|
|
541
|
+
permission?: string
|
|
528
542
|
}, exec: unknown) {
|
|
529
543
|
try {
|
|
530
544
|
const { actor } = caller(exec as ToolRunContext)
|
|
@@ -542,6 +556,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
542
556
|
if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
|
|
543
557
|
if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
|
|
544
558
|
if (args.blocked !== undefined) next.blocked = args.blocked
|
|
559
|
+
if (args.permission !== undefined) next.permission = asPermission(args.permission)
|
|
545
560
|
next.version = task.version + 1
|
|
546
561
|
next.updatedAt = deps.now()
|
|
547
562
|
next.updatedBy = actor
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
22
22
|
import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
|
|
23
23
|
import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
|
|
24
|
+
import { scheduledSessionResumer, type ScheduledSessionDeps } from './host/scheduled-session.ts'
|
|
24
25
|
import { createGitFace } from './host/git.ts'
|
|
25
26
|
import { createRepoScanner } from './host/repos.ts'
|
|
26
27
|
import { registerTaskboardRoutes } from './host/routes.ts'
|
|
@@ -183,6 +184,14 @@ export function apply(ctx: Context): void {
|
|
|
183
184
|
store,
|
|
184
185
|
agents: {
|
|
185
186
|
create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,
|
|
187
|
+
resumeScheduled: scheduledSessionResumer({
|
|
188
|
+
agents: {
|
|
189
|
+
get: id => agentCtx.agents.get(id as never) as unknown as ReturnType<ScheduledSessionDeps['agents']['get']>,
|
|
190
|
+
resume: options => agentCtx.agents.resume(options as never) as Promise<never>,
|
|
191
|
+
},
|
|
192
|
+
persistence: () => agentCtx.get('sessionPersistence') as ReturnType<ScheduledSessionDeps['persistence']>,
|
|
193
|
+
isArchived: id => wsCtx.workspaceRegistry.archivedSessionIds.includes(id as never),
|
|
194
|
+
}),
|
|
186
195
|
},
|
|
187
196
|
workspaces: {
|
|
188
197
|
get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),
|
package/src/shared/api.ts
CHANGED
|
@@ -54,7 +54,7 @@ export type CreateTaskBody = {
|
|
|
54
54
|
urgency: string
|
|
55
55
|
description?: string
|
|
56
56
|
prompt?: string
|
|
57
|
-
execution?: { mode?: string; cron?: string }
|
|
57
|
+
execution?: { mode?: string; cron?: string; runAt?: string | number }
|
|
58
58
|
model?: TaskModel
|
|
59
59
|
/** Code isolation for executions ('worktree' | 'none'); omitted = default. */
|
|
60
60
|
isolation?: string
|
|
@@ -76,7 +76,7 @@ export type UpdateTaskBody = {
|
|
|
76
76
|
blocked?: boolean
|
|
77
77
|
/** Rebind the task to another project (GUI owner surface only). */
|
|
78
78
|
workspaceId?: string
|
|
79
|
-
execution?: { mode?: string; cron?: string }
|
|
79
|
+
execution?: { mode?: string; cron?: string; runAt?: string | number }
|
|
80
80
|
model?: TaskModel | null
|
|
81
81
|
/** Change isolation; locked once the task has execution history. */
|
|
82
82
|
isolation?: string
|
|
@@ -170,7 +170,7 @@ export type TaskTemplateSpec = {
|
|
|
170
170
|
description?: string
|
|
171
171
|
prompt?: string
|
|
172
172
|
urgency?: string
|
|
173
|
-
execution?: { mode?: string; cron?: string }
|
|
173
|
+
execution?: { mode?: string; cron?: string; runAt?: string | number }
|
|
174
174
|
model?: TaskModel
|
|
175
175
|
isolation?: string
|
|
176
176
|
presetId?: string
|
package/src/shared/protocol.ts
CHANGED
|
@@ -52,7 +52,7 @@ const TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
|
|
|
52
52
|
todo: ['in_progress', 'backlog', 'canceled'],
|
|
53
53
|
in_progress: ['in_review', 'todo', 'canceled'],
|
|
54
54
|
in_review: ['in_progress', 'todo', 'done', 'canceled'],
|
|
55
|
-
done: ['archived'],
|
|
55
|
+
done: ['archived', 'todo'],
|
|
56
56
|
canceled: ['archived', 'todo'],
|
|
57
57
|
archived: [],
|
|
58
58
|
}
|
|
@@ -220,8 +220,18 @@ export type ExecutionMode = 'claim' | 'scheduled'
|
|
|
220
220
|
*/
|
|
221
221
|
export interface ExecutionConfig {
|
|
222
222
|
mode: ExecutionMode
|
|
223
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Five-field cron expression (minute hour day month weekday). Present on
|
|
225
|
+
* PERIODIC scheduled tasks (定期执行): the scheduler refires the task each
|
|
226
|
+
* time it comes due while the card sits in todo.
|
|
227
|
+
*/
|
|
224
228
|
cron?: string
|
|
229
|
+
/**
|
|
230
|
+
* One-shot trigger time (epoch ms). Present on ONE-SHOT scheduled tasks
|
|
231
|
+
* (定时执行): the scheduler fires the task once when due and consumes the
|
|
232
|
+
* field. Mutually exclusive with {@link cron}.
|
|
233
|
+
*/
|
|
234
|
+
runAt?: number
|
|
225
235
|
/** Next due time (epoch ms); maintained by the host scheduler. */
|
|
226
236
|
nextRunAt?: number
|
|
227
237
|
/** Last time the scheduler triggered this task (epoch ms). */
|
|
@@ -461,6 +471,8 @@ export type ExecutionRecord = {
|
|
|
461
471
|
id: string
|
|
462
472
|
/** The session this execution ran in; set once the session is really started. */
|
|
463
473
|
sessionId?: string
|
|
474
|
+
/** Effective configuration of a scheduled session; used only for compatible reuse. */
|
|
475
|
+
sessionReuseKey?: string
|
|
464
476
|
/** Trigger: manual button or the host scheduler. */
|
|
465
477
|
trigger: 'manual' | 'scheduled'
|
|
466
478
|
startedAt?: number
|
|
@@ -571,6 +583,13 @@ export type TaskRecord = {
|
|
|
571
583
|
executions: ExecutionRecord[]
|
|
572
584
|
/** How many older execution records were pruned by the retention cap. */
|
|
573
585
|
executionsPruned?: number
|
|
586
|
+
/**
|
|
587
|
+
* Id of the task this card continues (0.7.x periodic execution): when a
|
|
588
|
+
* PERIODIC scheduled task succeeds, the finished card moves to in_review
|
|
589
|
+
* and a fresh todo card carrying the cron is minted to keep the cycle
|
|
590
|
+
* going. Absent on originally created tasks.
|
|
591
|
+
*/
|
|
592
|
+
spawnedFrom?: string
|
|
574
593
|
/** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */
|
|
575
594
|
trashedAt?: number
|
|
576
595
|
}
|
|
@@ -591,6 +610,60 @@ export function pruneExecutions(task: TaskRecord): void {
|
|
|
591
610
|
task.executionsPruned = (task.executionsPruned ?? 0) + dropped
|
|
592
611
|
}
|
|
593
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Mint the successor card of a PERIODIC scheduled task that just succeeded
|
|
615
|
+
* (0.7.x): the finished card goes to in_review for acceptance while this
|
|
616
|
+
* fresh todo card carries the cron onward, keeping the cycle alive. The
|
|
617
|
+
* next run is recomputed from `now` (no compensating catch-up burst).
|
|
618
|
+
* Pure: the caller pushes the returned record into the ledger.
|
|
619
|
+
* @param source - the finished periodic task (still carrying its cron).
|
|
620
|
+
* @param prevExecutionId - id of the execution that just succeeded.
|
|
621
|
+
* @param now - current epoch ms.
|
|
622
|
+
* @returns the successor task record.
|
|
623
|
+
*/
|
|
624
|
+
export function spawnNextCycle(source: TaskRecord, prevExecutionId: string | undefined, now: number): TaskRecord {
|
|
625
|
+
const cron = source.execution.cron
|
|
626
|
+
if (cron === undefined) throw new Error('spawnNextCycle: source task has no cron')
|
|
627
|
+
const match = parseCron(cron)
|
|
628
|
+
const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
|
|
629
|
+
if (next === undefined) throw new Error('spawnNextCycle: cron has no upcoming match within 4 years')
|
|
630
|
+
return {
|
|
631
|
+
id: newTaskId(),
|
|
632
|
+
title: source.title,
|
|
633
|
+
description: source.description,
|
|
634
|
+
prompt: source.prompt,
|
|
635
|
+
workspaceId: source.workspaceId,
|
|
636
|
+
urgency: source.urgency,
|
|
637
|
+
status: 'todo',
|
|
638
|
+
blocked: false,
|
|
639
|
+
execution: { mode: 'scheduled', cron, nextRunAt: next },
|
|
640
|
+
...(source.model !== undefined ? { model: structuredClone(source.model) } : {}),
|
|
641
|
+
...(source.isolation !== undefined ? { isolation: source.isolation } : {}),
|
|
642
|
+
...(source.presetId !== undefined ? { presetId: source.presetId } : {}),
|
|
643
|
+
...(source.permission !== undefined ? { permission: source.permission } : {}),
|
|
644
|
+
...(source.checklist !== undefined
|
|
645
|
+
? { checklist: source.checklist.map(item => ({ ...item, checked: false, checkedBy: undefined, checkedAt: undefined, note: undefined })) }
|
|
646
|
+
: {}),
|
|
647
|
+
...(source.branch !== undefined ? { branch: source.branch } : {}),
|
|
648
|
+
...(source.branches !== undefined ? { branches: { ...source.branches } } : {}),
|
|
649
|
+
spawnedFrom: source.id,
|
|
650
|
+
version: 1,
|
|
651
|
+
createdAt: now,
|
|
652
|
+
updatedAt: now,
|
|
653
|
+
createdBy: { kind: 'system' },
|
|
654
|
+
updatedBy: { kind: 'system' },
|
|
655
|
+
comments: [{
|
|
656
|
+
id: newCommentId(),
|
|
657
|
+
body: normalizeBody(`[系统] 定期任务上一轮执行完毕(执行 ${prevExecutionId ?? '未知'}),本卡承接定时继续下一轮;上一轮成果见 ${source.id} 的待验收。`),
|
|
658
|
+
systemKey: 'sys.spawnedFrom',
|
|
659
|
+
systemParams: { sourceId: source.id, executionId: prevExecutionId ?? '' },
|
|
660
|
+
version: 1,
|
|
661
|
+
createdAt: now,
|
|
662
|
+
}],
|
|
663
|
+
executions: [],
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
594
667
|
/** The whole durable ledger. */
|
|
595
668
|
export type TaskLedger = {
|
|
596
669
|
schemaVersion: number
|
|
@@ -710,24 +783,52 @@ export function asStatus(raw: string): TaskStatus {
|
|
|
710
783
|
return raw as TaskStatus
|
|
711
784
|
}
|
|
712
785
|
|
|
786
|
+
/**
|
|
787
|
+
* Parse a raw runAt input: epoch ms number or ISO date string → epoch ms.
|
|
788
|
+
* @param raw - untyped runAt value.
|
|
789
|
+
* @returns the epoch ms, or undefined when absent.
|
|
790
|
+
*/
|
|
791
|
+
function normalizeRunAt(raw: unknown): number | undefined {
|
|
792
|
+
if (raw === undefined || raw === null) return undefined
|
|
793
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.trunc(raw)
|
|
794
|
+
if (typeof raw === 'string') {
|
|
795
|
+
const t = Date.parse(raw)
|
|
796
|
+
if (Number.isNaN(t)) throw new Error('execution.runAt is not a valid time (epoch ms or ISO string)')
|
|
797
|
+
return t
|
|
798
|
+
}
|
|
799
|
+
throw new Error('execution.runAt must be an epoch ms number or an ISO date string')
|
|
800
|
+
}
|
|
801
|
+
|
|
713
802
|
/**
|
|
714
803
|
* Validate an execution config request from raw tool/route input.
|
|
715
|
-
* `scheduled` requires a valid cron
|
|
716
|
-
*
|
|
804
|
+
* `scheduled` requires either a valid cron (periodic, 定期执行) or a runAt
|
|
805
|
+
* instant (one-shot, 定时执行); the two are mutually exclusive. A cron's
|
|
806
|
+
* first `nextRunAt` is computed from `now`.
|
|
717
807
|
* @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).
|
|
718
808
|
* @param now - current epoch ms.
|
|
809
|
+
* @param opts - `allowPastRunAt` lets the import path keep a historical
|
|
810
|
+
* one-shot instant instead of rejecting it.
|
|
719
811
|
* @returns the normalized config.
|
|
720
812
|
*/
|
|
721
813
|
export function normalizeExecution(
|
|
722
|
-
raw: { mode?: string; cron?: string },
|
|
814
|
+
raw: { mode?: string; cron?: string; runAt?: unknown },
|
|
723
815
|
now: number,
|
|
816
|
+
opts?: { allowPastRunAt?: boolean },
|
|
724
817
|
): ExecutionConfig {
|
|
725
818
|
const mode = raw.mode ?? 'claim'
|
|
726
819
|
if (mode !== 'claim' && mode !== 'scheduled') {
|
|
727
820
|
throw new Error("execution.mode must be 'claim' or 'scheduled'")
|
|
728
821
|
}
|
|
729
822
|
if (mode === 'claim') return { mode }
|
|
823
|
+
const runAt = normalizeRunAt(raw.runAt)
|
|
730
824
|
const cron = (raw.cron ?? '').trim()
|
|
825
|
+
if (cron.length > 0 && runAt !== undefined) {
|
|
826
|
+
throw new Error('execution: cron and runAt are mutually exclusive (periodic vs one-shot)')
|
|
827
|
+
}
|
|
828
|
+
if (runAt !== undefined) {
|
|
829
|
+
if (!opts?.allowPastRunAt && runAt <= now) throw new Error('execution.runAt must be in the future')
|
|
830
|
+
return { mode, runAt, nextRunAt: runAt }
|
|
831
|
+
}
|
|
731
832
|
const match = parseCron(cron)
|
|
732
833
|
if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')
|
|
733
834
|
const next = nextCronTime(match, now)
|
|
@@ -1053,8 +1154,9 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
|
|
|
1053
1154
|
if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')
|
|
1054
1155
|
try {
|
|
1055
1156
|
const execution = normalizeExecution(
|
|
1056
|
-
typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
|
|
1157
|
+
typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string; runAt?: unknown } : {},
|
|
1057
1158
|
now,
|
|
1159
|
+
{ allowPastRunAt: true },
|
|
1058
1160
|
)
|
|
1059
1161
|
const comments: CommentRecord[] = []
|
|
1060
1162
|
if (Array.isArray(e.comments)) {
|
package/src/shared/version.ts
CHANGED