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/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 + cron), model {provider, model} to pin executions to a model. '
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) or { mode: "scheduled", cron: "m h dom mon dow" }.',
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
@@ -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
- /** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */
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; computes the first `nextRunAt` from
716
- * `now`.
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)) {
@@ -6,5 +6,5 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.7.1'
9
+ export const PLUGIN_VERSION = '0.7.4'
10
10