@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.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.i18n.yaml +2 -2
- package/README.md +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
package/src/core/subtask.ts
CHANGED
|
@@ -36,6 +36,31 @@ export function isExecutionOutcome(value: unknown): value is ExecutionOutcome {
|
|
|
36
36
|
return value === 'succeeded' || value === 'failed' || value === 'cancelled'
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Reusable lineage index over ONE task list: the id lookup and the direct
|
|
41
|
+
* children per parent. A caller that runs several lineage queries over the same
|
|
42
|
+
* ledger (the link-subtask candidate gate runs one per task) builds this once
|
|
43
|
+
* instead of rescanning the list per query.
|
|
44
|
+
*/
|
|
45
|
+
export interface LineageIndex {
|
|
46
|
+
readonly byId: ReadonlyMap<string, TaskRecord>
|
|
47
|
+
readonly childrenByParent: ReadonlyMap<string, readonly TaskRecord[]>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build the reusable index for a task list (ledger order preserved). */
|
|
51
|
+
export function buildLineageIndex(tasks: readonly TaskRecord[]): LineageIndex {
|
|
52
|
+
const byId = new Map<string, TaskRecord>()
|
|
53
|
+
const childrenByParent = new Map<string, TaskRecord[]>()
|
|
54
|
+
for (const task of tasks) {
|
|
55
|
+
byId.set(task.id, task)
|
|
56
|
+
if (task.parentId === undefined) continue
|
|
57
|
+
const siblings = childrenByParent.get(task.parentId)
|
|
58
|
+
if (siblings === undefined) childrenByParent.set(task.parentId, [task])
|
|
59
|
+
else siblings.push(task)
|
|
60
|
+
}
|
|
61
|
+
return { byId, childrenByParent }
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
|
|
40
65
|
return new Map(tasks.map(task => [task.id, task]))
|
|
41
66
|
}
|
|
@@ -46,8 +71,8 @@ function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
|
|
|
46
71
|
* under a larger limit survives that limit being lowered, and restore and
|
|
47
72
|
* inheritance must still see all of it.
|
|
48
73
|
*/
|
|
49
|
-
export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): TaskRecord[] {
|
|
50
|
-
const index = indexOf(tasks)
|
|
74
|
+
export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord, lineage?: LineageIndex): TaskRecord[] {
|
|
75
|
+
const index = lineage?.byId ?? indexOf(tasks)
|
|
51
76
|
const chain: TaskRecord[] = []
|
|
52
77
|
const seen = new Set<string>([task.id])
|
|
53
78
|
let current = task.parentId === undefined ? undefined : index.get(task.parentId)
|
|
@@ -61,13 +86,14 @@ export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): T
|
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
/** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
|
|
64
|
-
export function taskDepth(tasks: readonly TaskRecord[], id: string): number {
|
|
65
|
-
const task = indexOf(tasks).get(id)
|
|
66
|
-
return task === undefined ? 0 : ancestorChain(tasks, task).length
|
|
89
|
+
export function taskDepth(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): number {
|
|
90
|
+
const task = (lineage?.byId ?? indexOf(tasks)).get(id)
|
|
91
|
+
return task === undefined ? 0 : ancestorChain(tasks, task, lineage).length
|
|
67
92
|
}
|
|
68
93
|
|
|
69
94
|
/** Direct subtasks of a task, in ledger order. */
|
|
70
|
-
export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRecord[] {
|
|
95
|
+
export function directSubtasks(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): TaskRecord[] {
|
|
96
|
+
if (lineage !== undefined) return [...(lineage.childrenByParent.get(id) ?? [])]
|
|
71
97
|
return tasks.filter(task => task.parentId === id)
|
|
72
98
|
}
|
|
73
99
|
|
|
@@ -75,15 +101,18 @@ export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRe
|
|
|
75
101
|
* Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
|
|
76
102
|
* total depth. The visited set keeps a malformed ledger from looping.
|
|
77
103
|
*/
|
|
78
|
-
export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number): TaskRecord[] {
|
|
104
|
+
export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number, lineage?: LineageIndex): TaskRecord[] {
|
|
79
105
|
const visited = new Set<string>([id])
|
|
80
106
|
const found: TaskRecord[] = []
|
|
81
107
|
let frontier = [id]
|
|
82
108
|
for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
|
|
83
109
|
const next: string[] = []
|
|
84
110
|
for (const parentId of frontier) {
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
const children = lineage === undefined
|
|
112
|
+
? tasks.filter(task => task.parentId === parentId)
|
|
113
|
+
: lineage.childrenByParent.get(parentId) ?? []
|
|
114
|
+
for (const task of children) {
|
|
115
|
+
if (visited.has(task.id)) continue
|
|
87
116
|
visited.add(task.id)
|
|
88
117
|
found.push(task)
|
|
89
118
|
next.push(task.id)
|
|
@@ -99,13 +128,13 @@ export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSub
|
|
|
99
128
|
* The visited set makes the walk terminate on a cyclic hand-edited ledger
|
|
100
129
|
* instead of recursing until the stack overflows.
|
|
101
130
|
*/
|
|
102
|
-
export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>()): number {
|
|
131
|
+
export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>(), lineage?: LineageIndex): number {
|
|
103
132
|
if (visited.has(id)) return 0
|
|
104
133
|
const seen = new Set([...visited, id])
|
|
105
|
-
const children = directSubtasks(tasks, id)
|
|
134
|
+
const children = directSubtasks(tasks, id, lineage)
|
|
106
135
|
if (children.length === 0) return 0
|
|
107
136
|
let height = 0
|
|
108
|
-
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen))
|
|
137
|
+
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen, lineage))
|
|
109
138
|
return Math.min(height, SUBTASK_DEPTH_MAX)
|
|
110
139
|
}
|
|
111
140
|
|
|
@@ -137,16 +166,17 @@ export function checkParentLink(
|
|
|
137
166
|
childId: string,
|
|
138
167
|
parentId: string | null,
|
|
139
168
|
maxSubtaskDepth: number,
|
|
169
|
+
lineage?: LineageIndex,
|
|
140
170
|
): ParentLinkCheck {
|
|
141
|
-
const child = tasks.find(task => task.id === childId)
|
|
171
|
+
const child = lineage === undefined ? tasks.find(task => task.id === childId) : lineage.byId.get(childId)
|
|
142
172
|
if (child === undefined) return { ok: false, reason: 'unknown-task' }
|
|
143
173
|
if (parentId === null || parentId === '') return { ok: true }
|
|
144
174
|
if (parentId === childId) return { ok: false, reason: 'self-parent' }
|
|
145
|
-
const parent = tasks.find(task => task.id === parentId)
|
|
175
|
+
const parent = lineage === undefined ? tasks.find(task => task.id === parentId) : lineage.byId.get(parentId)
|
|
146
176
|
if (parent === undefined) return { ok: false, reason: 'unknown-parent' }
|
|
147
177
|
if (parent.archivedAt !== undefined) return { ok: false, reason: 'archived-parent' }
|
|
148
|
-
if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
|
|
149
|
-
const depth = taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId)
|
|
178
|
+
if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX, lineage).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
|
|
179
|
+
const depth = taskDepth(tasks, parentId, lineage) + 1 + subtreeHeight(tasks, childId, new Set<string>(), lineage)
|
|
150
180
|
if (depth > maxSubtaskDepth) return { ok: false, reason: 'depth-exceeded' }
|
|
151
181
|
return { ok: true }
|
|
152
182
|
}
|
|
@@ -241,18 +271,43 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
|
|
|
241
271
|
*/
|
|
242
272
|
/**
|
|
243
273
|
* The immutable teammate name for one team-mode member. Teammate names are
|
|
244
|
-
* lower-kebab-case and must stay unique for the lifetime of the Team
|
|
245
|
-
* run-group token
|
|
246
|
-
*
|
|
247
|
-
*
|
|
274
|
+
* lower-kebab-case and must stay unique for the lifetime of the Team: the
|
|
275
|
+
* run-group token separates two runs of the same tree, and the member
|
|
276
|
+
* discriminator separates two members of one run that reduce to the same slug.
|
|
277
|
+
*
|
|
278
|
+
* The discriminator is not cosmetic. A title with no ASCII word characters (a
|
|
279
|
+
* CJK title, for example) carries no slug at all, so every such title of one
|
|
280
|
+
* run group collides on the generic prefix; Agent Teams answers every member
|
|
281
|
+
* after the first with `teammate name "..." was already used in this Team`,
|
|
282
|
+
* and those subtasks never start. Two ASCII titles that share a slug
|
|
283
|
+
* (`AI 强度预测` next to `AI 配比优化`) collide the same way.
|
|
248
284
|
* @param title - the member task's title.
|
|
249
285
|
* @param token - the run group id (the task id when no group is recorded).
|
|
286
|
+
* @param memberId - the member task's id, which makes the name unique in the run.
|
|
250
287
|
* @returns the teammate name handed to the Agent Teams service.
|
|
251
288
|
*/
|
|
252
|
-
export function teammateName(title: string, token: string): string {
|
|
289
|
+
export function teammateName(title: string, token: string, memberId?: string): string {
|
|
253
290
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '')
|
|
254
291
|
const suffix = token.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase()
|
|
255
|
-
|
|
292
|
+
const member = memberId === undefined || memberId === '' ? undefined : stableTag(memberId)
|
|
293
|
+
return [slug === '' ? 'subtask' : slug, member, suffix === '' ? 'run' : suffix]
|
|
294
|
+
.filter((part): part is string => part !== undefined)
|
|
295
|
+
.join('-')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A stable 4-hex tag for one member id. The name is derived twice for the same
|
|
300
|
+
* member — once for the Lead's prompt and once for the spawn request — so the
|
|
301
|
+
* tag must be a pure function of the id, never of a clock or a counter. FNV-1a
|
|
302
|
+
* keeps it dependency-free in `core/`, which both halves compile.
|
|
303
|
+
*/
|
|
304
|
+
function stableTag(value: string): string {
|
|
305
|
+
let hash = 0x811c9dc5
|
|
306
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
307
|
+
hash ^= value.charCodeAt(index)
|
|
308
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
309
|
+
}
|
|
310
|
+
return hash.toString(16).padStart(8, '0').slice(0, 4)
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
export function resolveExecutionTargets(
|
package/src/core/tasks.ts
CHANGED
|
@@ -87,6 +87,13 @@ export interface ScheduleRule {
|
|
|
87
87
|
enabled: boolean
|
|
88
88
|
/** 5-field cron expression: `分 时 日 月 周`. */
|
|
89
89
|
cron: string
|
|
90
|
+
/**
|
|
91
|
+
* IANA zone the cron wall clock is read in (issue #1722). Absent means the
|
|
92
|
+
* Host process zone, which is what a rule written before zones were
|
|
93
|
+
* persisted keeps using; the Host stamps its own zone at ledger migration so
|
|
94
|
+
* a later `TZ` change cannot silently move an existing rule.
|
|
95
|
+
*/
|
|
96
|
+
timeZone?: string
|
|
90
97
|
/** Next due instant (ms epoch); maintained by the scheduler/controller. */
|
|
91
98
|
nextRunAt: number | undefined
|
|
92
99
|
/** Instant of the latest scheduled trigger (ms epoch). */
|
|
@@ -278,6 +285,16 @@ export interface TaskRecord {
|
|
|
278
285
|
* and a subtask's own permission pin cannot be honored in this mode.
|
|
279
286
|
*/
|
|
280
287
|
teamRun?: boolean
|
|
288
|
+
/**
|
|
289
|
+
* Start every execution of this task with dsh's built-in `/goal` command,
|
|
290
|
+
* armed with the composed execution prompt as its objective. The session then
|
|
291
|
+
* keeps working automatic continuation rounds until the agent marks the goal
|
|
292
|
+
* complete, and this board settles the execution on the goal's own end
|
|
293
|
+
* instead of at the first turn end. Absent means ON — the option is checked
|
|
294
|
+
* by default, so only an explicit `false` runs the task as a single plain
|
|
295
|
+
* turn. A session whose `/goal` command is refused still runs the prompt.
|
|
296
|
+
*/
|
|
297
|
+
goalRun?: boolean
|
|
281
298
|
/**
|
|
282
299
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
283
300
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -354,12 +371,18 @@ export interface NewTaskInput {
|
|
|
354
371
|
reuseSession?: boolean
|
|
355
372
|
/** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
|
|
356
373
|
teamRun?: boolean
|
|
374
|
+
/**
|
|
375
|
+
* Start the execution with dsh's built-in `/goal`. Absent/true keeps the
|
|
376
|
+
* default (goal run); an explicit false requests a single plain turn.
|
|
377
|
+
*/
|
|
378
|
+
goalRun?: boolean
|
|
357
379
|
/**
|
|
358
380
|
* Optional scheduled-run rule requested at creation time (the new-task
|
|
359
|
-
* dialog): an enable flag
|
|
360
|
-
*
|
|
381
|
+
* dialog): an enable flag, a 5-field cron expression, and the IANA zone its
|
|
382
|
+
* wall clock is read in (absent means the Host zone). The create use case
|
|
383
|
+
* arms it only when enabled and the expression is valid.
|
|
361
384
|
*/
|
|
362
|
-
schedule?: { enabled: boolean; cron: string }
|
|
385
|
+
schedule?: { enabled: boolean; cron: string; timeZone?: string }
|
|
363
386
|
/**
|
|
364
387
|
* Optional frozen context snapshot (goal/progress/next, sanitized by the
|
|
365
388
|
* protocol gate) turning the new task into a continuation card.
|
|
@@ -449,7 +472,11 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
449
472
|
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
450
473
|
model: normalizeTargetId(input.model),
|
|
451
474
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
452
|
-
|
|
475
|
+
teamRun: input.teamRun === true ? true : undefined,
|
|
476
|
+
// Default ON: only an explicit false is stored, so a card that never
|
|
477
|
+
// touched the option (and every card written before the field existed)
|
|
478
|
+
// keeps starting its runs with /goal.
|
|
479
|
+
goalRun: input.goalRun === false ? false : undefined,
|
|
453
480
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
454
481
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
455
482
|
...(tags === undefined ? {} : { tags }),
|
|
@@ -476,11 +503,18 @@ export function withSchedule(
|
|
|
476
503
|
const schedule: ScheduleRule = {
|
|
477
504
|
enabled: current?.enabled ?? false,
|
|
478
505
|
cron: current?.cron ?? '',
|
|
506
|
+
...(current?.timeZone === undefined ? {} : { timeZone: current.timeZone }),
|
|
479
507
|
nextRunAt: current?.nextRunAt,
|
|
480
508
|
lastTriggeredAt: current?.lastTriggeredAt,
|
|
481
509
|
}
|
|
482
510
|
if ('enabled' in patch) schedule.enabled = patch.enabled ?? false
|
|
483
511
|
if ('cron' in patch) schedule.cron = patch.cron ?? ''
|
|
512
|
+
// An explicit `undefined` drops the stored zone (back to the Host zone);
|
|
513
|
+
// an absent key keeps it.
|
|
514
|
+
if ('timeZone' in patch) {
|
|
515
|
+
if (patch.timeZone === undefined) delete schedule.timeZone
|
|
516
|
+
else schedule.timeZone = patch.timeZone
|
|
517
|
+
}
|
|
484
518
|
if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
|
|
485
519
|
if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
|
|
486
520
|
return { ...task, updatedAt: now, schedule }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* (no persistence or notify - the controller orchestrates those), so it is
|
|
5
5
|
* unit-testable without any runtime face.
|
|
6
6
|
*/
|
|
7
|
-
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
|
|
8
8
|
import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
|
|
9
9
|
import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
|
|
10
10
|
|
|
@@ -32,6 +32,7 @@ export interface CreateTaskResult {
|
|
|
32
32
|
* @param now - clock instant (ms epoch).
|
|
33
33
|
* @param id - minted task id.
|
|
34
34
|
* @param maxSubtaskDepth - deployment subtask depth limit.
|
|
35
|
+
* @param hostTimeZone - zone a rule that stores no zone of its own follows.
|
|
35
36
|
*/
|
|
36
37
|
export function applyCreateTask(
|
|
37
38
|
tasks: readonly TaskRecord[],
|
|
@@ -39,6 +40,7 @@ export function applyCreateTask(
|
|
|
39
40
|
now: number,
|
|
40
41
|
id: string,
|
|
41
42
|
maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
|
|
43
|
+
hostTimeZone: string = resolveHostTimeZone(),
|
|
42
44
|
): CreateTaskResult {
|
|
43
45
|
if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
|
|
44
46
|
const limit = normalizeSubtaskDepth(maxSubtaskDepth)
|
|
@@ -71,7 +73,17 @@ export function applyCreateTask(
|
|
|
71
73
|
const requested = input.schedule
|
|
72
74
|
if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
|
|
73
75
|
const cron = requested.cron.trim()
|
|
74
|
-
|
|
76
|
+
const requestedZone = requested.timeZone
|
|
77
|
+
// An unusable zone is refused rather than silently reinterpreted as the
|
|
78
|
+
// Host zone: the caller asked for a specific wall clock.
|
|
79
|
+
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
|
|
80
|
+
return { task: undefined, tasks, error: 'invalid schedule time zone' }
|
|
81
|
+
}
|
|
82
|
+
const zone = requestedZone ?? hostTimeZone
|
|
83
|
+
const nextRunAt = nextRunAtMs(cron, now, zone)
|
|
84
|
+
if (nextRunAt !== undefined) {
|
|
85
|
+
task = withSchedule(task, { enabled: true, cron, timeZone: requestedZone, nextRunAt }, now)
|
|
86
|
+
}
|
|
75
87
|
}
|
|
76
88
|
return { task, tasks: [...tasks, task] }
|
|
77
89
|
}
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* orchestrates those). Validation and next-run computation live here, sharing
|
|
5
5
|
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
6
6
|
*/
|
|
7
|
-
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs } from '../schedule.ts'
|
|
8
8
|
import { withSchedule, type TaskRecord } from '../tasks.ts'
|
|
9
9
|
|
|
10
10
|
/** Fields the schedule use case may change on a rule. */
|
|
11
11
|
export interface SetSchedulePatch {
|
|
12
12
|
enabled?: boolean
|
|
13
13
|
cron?: string
|
|
14
|
+
/** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
|
|
15
|
+
timeZone?: string | null
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Result of arming/disarming a rule. */
|
|
@@ -22,31 +24,40 @@ export interface SetScheduleResult {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Set an on-board task's schedule rule. A blank or invalid cron, or
|
|
26
|
-
* archived task, is rejected (state untouched); an enabled
|
|
27
|
-
* next run instant immediately
|
|
27
|
+
* Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
|
|
28
|
+
* archived task, or an unusable zone is rejected (state untouched); an enabled
|
|
29
|
+
* rule computes the next run instant immediately in the rule's own zone, a
|
|
30
|
+
* disabled one carries no next-run instant.
|
|
28
31
|
* @param tasks - current ledger.
|
|
29
32
|
* @param id - the task to schedule.
|
|
30
33
|
* @param patch - rule fields to change (absent fields keep their current value).
|
|
31
34
|
* @param now - clock instant (ms epoch).
|
|
35
|
+
* @param hostTimeZone - zone a rule with no stored zone follows.
|
|
32
36
|
*/
|
|
33
37
|
export function applySetSchedule(
|
|
34
38
|
tasks: readonly TaskRecord[],
|
|
35
39
|
id: string,
|
|
36
40
|
patch: SetSchedulePatch,
|
|
37
41
|
now: number,
|
|
42
|
+
hostTimeZone: string,
|
|
38
43
|
): SetScheduleResult {
|
|
39
44
|
const task = tasks.find(candidate => candidate.id === id)
|
|
40
45
|
if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
|
|
41
46
|
const current = task.schedule
|
|
42
47
|
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
43
48
|
if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
|
|
49
|
+
// An explicit null clears the stored zone; an absent key keeps it.
|
|
50
|
+
const requestedZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
|
|
51
|
+
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) return { tasks, applied: false }
|
|
44
52
|
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
45
|
-
const
|
|
53
|
+
const effectiveZone = requestedZone ?? hostTimeZone
|
|
54
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now, effectiveZone) : undefined
|
|
46
55
|
if (enabled && nextRunAt === undefined) return { tasks, applied: false }
|
|
47
56
|
return {
|
|
48
57
|
tasks: tasks.map(candidate =>
|
|
49
|
-
candidate.id === id
|
|
58
|
+
candidate.id === id
|
|
59
|
+
? withSchedule(candidate, { enabled, cron, timeZone: requestedZone ?? undefined, nextRunAt }, now)
|
|
60
|
+
: candidate),
|
|
50
61
|
applied: true,
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
|
|
|
16
16
|
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
17
17
|
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
18
18
|
*/
|
|
19
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun'>> & {
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
|
|
20
20
|
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
21
|
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
22
|
handover?: TaskHandoverInput | null
|
|
@@ -108,6 +108,9 @@ export function applyUpdateTask(
|
|
|
108
108
|
if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
|
|
109
109
|
// Team execution is a boolean opt-in with the same clear-by-false rule.
|
|
110
110
|
if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
|
|
111
|
+
// The /goal opt-in is default-ON: true (or an explicit null) returns the
|
|
112
|
+
// card to the default and stores nothing, only false pins a plain turn.
|
|
113
|
+
if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
|
|
111
114
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
112
115
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
113
116
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/host/agent-tools.ts
CHANGED
|
@@ -94,6 +94,8 @@ function scheduleView(schedule: ScheduleRule): Record<string, unknown> {
|
|
|
94
94
|
return {
|
|
95
95
|
enabled: schedule.enabled,
|
|
96
96
|
cron: schedule.cron,
|
|
97
|
+
// Absent means the rule follows the Host zone, which the board view reports.
|
|
98
|
+
...(schedule.timeZone === undefined ? {} : { timeZone: schedule.timeZone }),
|
|
97
99
|
...(schedule.nextRunAt === undefined ? {} : { nextRunAt: schedule.nextRunAt }),
|
|
98
100
|
...(schedule.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }),
|
|
99
101
|
}
|
|
@@ -150,6 +152,8 @@ function taskSummary(
|
|
|
150
152
|
...(task.model === undefined ? {} : { model: task.model }),
|
|
151
153
|
...(task.reuseSession === true ? { reuseSession: true } : {}),
|
|
152
154
|
...(task.teamRun === true ? { teamRun: true } : {}),
|
|
155
|
+
// Default-ON switch: only the opt-out is a deviation worth reporting.
|
|
156
|
+
...(task.goalRun === false ? { goalRun: false } : {}),
|
|
153
157
|
...(task.schedule === undefined ? {} : { schedule: scheduleView(task.schedule) }),
|
|
154
158
|
...(task.freeze === undefined ? {} : { continuationCard: true }),
|
|
155
159
|
...(task.permissionConfirmedAt === undefined ? {} : { permissionConfirmedAt: task.permissionConfirmedAt }),
|
|
@@ -334,18 +338,19 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
334
338
|
return defineTool({
|
|
335
339
|
name: 'task_board_manage',
|
|
336
340
|
description: [
|
|
337
|
-
'Move, archive, restore, or
|
|
341
|
+
'Move, archive, restore, delete, or settle one task board card.',
|
|
338
342
|
'move-todo and move-backlog are the manual column moves; a running card cannot be moved.',
|
|
343
|
+
'settle force-closes the open execution of a card the board can no longer observe (a stuck running card) and records it cancelled with the caller as the reason, so the card returns to the todo column and can be run again.',
|
|
339
344
|
'archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.',
|
|
340
345
|
'It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.',
|
|
341
|
-
'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task.',
|
|
346
|
+
'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle.',
|
|
342
347
|
].join(' '),
|
|
343
348
|
parameters: {
|
|
344
349
|
taskId: { type: 'string', required: true, description: 'The task to act on.' },
|
|
345
350
|
action: {
|
|
346
351
|
type: 'string',
|
|
347
352
|
required: true,
|
|
348
|
-
enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete'],
|
|
353
|
+
enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete', 'settle'],
|
|
349
354
|
description: 'The lifecycle operation to perform.',
|
|
350
355
|
},
|
|
351
356
|
},
|
|
@@ -356,6 +361,7 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
356
361
|
'move-todo': { kind: 'move', taskId: args.taskId, status: 'todo' },
|
|
357
362
|
'move-backlog': { kind: 'move', taskId: args.taskId, status: 'backlog' },
|
|
358
363
|
archive: { kind: 'archive', taskId: args.taskId },
|
|
364
|
+
settle: { kind: 'settle', taskId: args.taskId },
|
|
359
365
|
restore: { kind: 'restore', taskId: args.taskId },
|
|
360
366
|
delete: { kind: 'delete', taskId: args.taskId },
|
|
361
367
|
}
|
|
@@ -381,7 +387,8 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
381
387
|
return defineTool({
|
|
382
388
|
name: 'task_board_schedule',
|
|
383
389
|
description: [
|
|
384
|
-
'Arm, change, or disarm a task scheduled runs (5-field cron
|
|
390
|
+
'Arm, change, or disarm a task scheduled runs (5-field cron; day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND).',
|
|
391
|
+
'The cron wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.',
|
|
385
392
|
'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
|
|
386
393
|
'A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.',
|
|
387
394
|
'Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.',
|
|
@@ -391,20 +398,27 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
391
398
|
taskId: { type: 'string', required: true, description: 'The task whose schedule changes.' },
|
|
392
399
|
enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) the schedule.' },
|
|
393
400
|
cron: { type: 'string', description: '5-field cron expression: minute hour day-of-month month day-of-week.' },
|
|
401
|
+
timeZone: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'IANA zone the cron wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone.',
|
|
404
|
+
},
|
|
394
405
|
},
|
|
395
406
|
output: { schema: { type: 'json' }, render: renderJson },
|
|
396
407
|
async execute(args, exec) {
|
|
397
|
-
if (args.enabled === undefined && args.cron === undefined) {
|
|
398
|
-
return refused('nothing-to-change', 'pass enabled and/or cron')
|
|
408
|
+
if (args.enabled === undefined && args.cron === undefined && args.timeZone === undefined) {
|
|
409
|
+
return refused('nothing-to-change', 'pass enabled and/or cron and/or timeZone')
|
|
399
410
|
}
|
|
400
411
|
// A missing card must read as a missing card: the ledger reports an
|
|
401
412
|
// unknown id through the same refusal as a malformed cron.
|
|
402
413
|
if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
|
|
403
414
|
return refused('task-not-found', 'no task with id ' + args.taskId)
|
|
404
415
|
}
|
|
405
|
-
const patch: { enabled?: boolean; cron?: string } = {
|
|
416
|
+
const patch: { enabled?: boolean; cron?: string; timeZone?: string | null } = {
|
|
406
417
|
...(args.enabled === undefined ? {} : { enabled: args.enabled }),
|
|
407
418
|
...(args.cron === undefined ? {} : { cron: args.cron }),
|
|
419
|
+
// An empty string is the documented way to clear the stored zone, so
|
|
420
|
+
// the model never has to emit an explicit null through JSON args.
|
|
421
|
+
...(args.timeZone === undefined ? {} : { timeZone: args.timeZone === '' ? null : args.timeZone }),
|
|
408
422
|
}
|
|
409
423
|
try {
|
|
410
424
|
const snapshot = host.apply(crypto.randomUUID(), { kind: 'set-schedule', taskId: args.taskId, patch }, callingSessionId(exec))
|
|
@@ -514,6 +528,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
514
528
|
model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
|
|
515
529
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
|
|
516
530
|
teamRun: { type: 'boolean', description: 'Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service.' },
|
|
531
|
+
goalRun: { type: 'boolean', description: 'Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Default true (omit to keep it); pass false for a single plain turn.' },
|
|
517
532
|
tags: {
|
|
518
533
|
type: 'array',
|
|
519
534
|
description: 'Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.',
|
|
@@ -555,6 +570,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
555
570
|
...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
|
|
556
571
|
...(args.reuseSession === true ? { reuseSession: true } : {}),
|
|
557
572
|
...(args.teamRun === true ? { teamRun: true } : {}),
|
|
573
|
+
...(args.goalRun === false ? { goalRun: false } : {}),
|
|
558
574
|
...(tags.length === 0 ? {} : { tags }),
|
|
559
575
|
...(args.schedule === undefined ? {} : { schedule: { enabled: args.schedule.enabled, cron: args.schedule.cron } }),
|
|
560
576
|
}
|
|
@@ -587,10 +603,25 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
587
603
|
prompt: { type: 'string', description: 'New execution prompt.' },
|
|
588
604
|
workspaceId: { type: 'string', description: 'New workspace id; an empty string clears it.' },
|
|
589
605
|
mode: { type: 'string', description: 'New agent preset id; an empty string clears it.' },
|
|
590
|
-
|
|
606
|
+
// `enum` with a clearing value would advertise the empty string as a
|
|
607
|
+
// legal member of the enum. Some OpenAI-compatible gateways forward the
|
|
608
|
+
// tool schema to Gemini, which rejects an empty enum member outright
|
|
609
|
+
// ("cannot be empty") and fails the whole request - not just this tool -
|
|
610
|
+
// even for a plain greeting, because the schema ships with every call
|
|
611
|
+
// (issue #1748). Splitting the clear value into its own `const` branch
|
|
612
|
+
// keeps both the real enum intact and the wire representation valid for
|
|
613
|
+
// those gateways.
|
|
614
|
+
permission: {
|
|
615
|
+
oneOf: [
|
|
616
|
+
{ type: 'string', enum: [...TASK_PERMISSIONS] },
|
|
617
|
+
{ type: 'string', const: '' },
|
|
618
|
+
],
|
|
619
|
+
description: 'New permission preset; an empty string clears it back to the session default.',
|
|
620
|
+
},
|
|
591
621
|
model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
|
|
592
622
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
|
|
593
623
|
teamRun: { type: 'boolean', description: 'Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask).' },
|
|
624
|
+
goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead.' },
|
|
594
625
|
tags: {
|
|
595
626
|
type: 'array',
|
|
596
627
|
description: 'Replacement label set; an empty array clears all labels.',
|
|
@@ -619,6 +650,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
619
650
|
}
|
|
620
651
|
if (has(args, 'reuseSession')) patch.reuseSession = args.reuseSession === true
|
|
621
652
|
if (has(args, 'teamRun')) patch.teamRun = args.teamRun === true
|
|
653
|
+
if (has(args, 'goalRun')) patch.goalRun = args.goalRun === false ? false : true
|
|
622
654
|
if (has(args, 'tags')) {
|
|
623
655
|
const tags = args.tags ?? []
|
|
624
656
|
patch.tags = tags.length === 0
|