@linxin666/dsh-client-ui-task-board 0.1.17 → 0.1.18
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +377 -115
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/locales.d.ts +7 -0
- package/lib/types/client/schedule-presets.d.ts +10 -0
- package/lib/types/core/controller.d.ts +27 -0
- package/lib/types/core/execution.d.ts +16 -0
- package/lib/types/core/scheduler.d.ts +7 -0
- package/lib/types/core/tasks.d.ts +16 -0
- package/lib/types/core/use-cases/task-archive.d.ts +22 -0
- package/lib/types/core/use-cases/task-create.d.ts +1 -6
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/board/NewTaskModal.tsx +68 -0
- package/src/client/board/TaskBoard.tsx +48 -19
- package/src/client/board/TaskDetail.tsx +27 -8
- package/src/client/index.ts +3 -0
- package/src/client/locales.ts +14 -0
- package/src/client/schedule-presets.ts +13 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/core/controller.ts +54 -0
- package/src/core/execution.ts +41 -2
- package/src/core/scheduler.ts +15 -0
- package/src/core/store.ts +1 -0
- package/src/core/tasks.ts +15 -0
- package/src/core/use-cases/task-archive.ts +48 -0
- package/src/core/use-cases/task-create.ts +12 -3
package/src/client/locales.ts
CHANGED
|
@@ -14,6 +14,10 @@ export const zh = {
|
|
|
14
14
|
'board.search': '筛选任务…',
|
|
15
15
|
'board.empty': '这个状态还没有任务',
|
|
16
16
|
'board.filterAll': '全部',
|
|
17
|
+
'board.archive': '归档',
|
|
18
|
+
'board.archiveView': '归档 ({count})',
|
|
19
|
+
'board.backToBoard': '返回看板',
|
|
20
|
+
'archive.empty': '没有已归档的任务',
|
|
17
21
|
'board.status': '状态',
|
|
18
22
|
'board.status.backlog': '待规划',
|
|
19
23
|
'board.status.todo': '待办',
|
|
@@ -41,6 +45,9 @@ export const zh = {
|
|
|
41
45
|
'detail.run': '执行',
|
|
42
46
|
'detail.rerun': '重新执行',
|
|
43
47
|
'detail.delete': '删除',
|
|
48
|
+
'detail.archive': '归档',
|
|
49
|
+
'detail.restore': '恢复',
|
|
50
|
+
'detail.archivedAt': '已归档 · {time}',
|
|
44
51
|
'detail.viewSession': '查看会话',
|
|
45
52
|
'detail.noSession': '暂无会话',
|
|
46
53
|
'detail.executionStarted': '已启动',
|
|
@@ -120,6 +127,10 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
120
127
|
'board.search': 'Filter tasks…',
|
|
121
128
|
'board.empty': 'No tasks in this column',
|
|
122
129
|
'board.filterAll': 'All',
|
|
130
|
+
'board.archive': 'Archive',
|
|
131
|
+
'board.archiveView': 'Archived ({count})',
|
|
132
|
+
'board.backToBoard': 'Back to board',
|
|
133
|
+
'archive.empty': 'No archived tasks',
|
|
123
134
|
'board.status': 'Status',
|
|
124
135
|
'board.status.backlog': 'Backlog',
|
|
125
136
|
'board.status.todo': 'To Do',
|
|
@@ -147,6 +158,9 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
147
158
|
'detail.run': 'Run',
|
|
148
159
|
'detail.rerun': 'Run Again',
|
|
149
160
|
'detail.delete': 'Delete',
|
|
161
|
+
'detail.archive': 'Archive',
|
|
162
|
+
'detail.restore': 'Restore',
|
|
163
|
+
'detail.archivedAt': 'Archived · {time}',
|
|
150
164
|
'detail.viewSession': 'View Session',
|
|
151
165
|
'detail.noSession': 'No session',
|
|
152
166
|
'detail.executionStarted': 'Started',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scheduled-run presets for the schedule editors (the new-task dialog
|
|
3
|
+
* and the task detail panel): cron expression → locale label.
|
|
4
|
+
*/
|
|
5
|
+
import type { TaskBoardKey } from './locales.ts'
|
|
6
|
+
|
|
7
|
+
/** Common scheduled-run presets (cron → locale label). */
|
|
8
|
+
export const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
9
|
+
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
10
|
+
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
11
|
+
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
12
|
+
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
13
|
+
]
|
package/src/core/controller.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
settleExecution, startExecution, withStatus,
|
|
20
20
|
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
21
21
|
} from './tasks.ts'
|
|
22
|
+
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
22
23
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
24
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
25
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
@@ -74,6 +75,8 @@ export interface ExecutionOptionsSnapshot {
|
|
|
74
75
|
export interface ControllerSnapshot {
|
|
75
76
|
tasks: readonly TaskRecord[]
|
|
76
77
|
boardOpen: boolean
|
|
78
|
+
/** True when the board shows the archive view instead of the columns. */
|
|
79
|
+
archiveView: boolean
|
|
77
80
|
selectedTaskId: string | undefined
|
|
78
81
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
79
82
|
executionOptions: ExecutionOptionsSnapshot
|
|
@@ -109,6 +112,7 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
|
109
112
|
export class BoardController {
|
|
110
113
|
private tasks: TaskRecord[] = []
|
|
111
114
|
private boardOpen = false
|
|
115
|
+
private archiveView = false
|
|
112
116
|
private selectedTaskId: string | undefined
|
|
113
117
|
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
|
|
114
118
|
private listeners = new Set<() => void>()
|
|
@@ -157,6 +161,7 @@ export class BoardController {
|
|
|
157
161
|
return {
|
|
158
162
|
tasks: this.tasks,
|
|
159
163
|
boardOpen: this.boardOpen,
|
|
164
|
+
archiveView: this.archiveView,
|
|
160
165
|
selectedTaskId: this.selectedTaskId,
|
|
161
166
|
executionOptions: this.executionOptions,
|
|
162
167
|
}
|
|
@@ -190,6 +195,20 @@ export class BoardController {
|
|
|
190
195
|
else this.openBoard()
|
|
191
196
|
}
|
|
192
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Switch between the kanban columns and the archive view. Leaving the
|
|
200
|
+
* archive view with an archived task still selected closes the selection —
|
|
201
|
+
* the detail overlay must not linger over a task that is off-board.
|
|
202
|
+
*/
|
|
203
|
+
toggleArchiveView(): void {
|
|
204
|
+
this.archiveView = !this.archiveView
|
|
205
|
+
if (!this.archiveView && this.selectedTaskId !== undefined) {
|
|
206
|
+
const selected = this.tasks.find(task => task.id === this.selectedTaskId)
|
|
207
|
+
if (selected?.archivedAt !== undefined) this.selectedTaskId = undefined
|
|
208
|
+
}
|
|
209
|
+
this.notify()
|
|
210
|
+
}
|
|
211
|
+
|
|
193
212
|
openTask(id: string): void {
|
|
194
213
|
if (this.tasks.some(task => task.id === id)) {
|
|
195
214
|
this.selectedTaskId = id
|
|
@@ -239,6 +258,29 @@ export class BoardController {
|
|
|
239
258
|
this.persistAndNotify()
|
|
240
259
|
}
|
|
241
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Archive a settled task (done/failed). Running or on-board-unsettled
|
|
263
|
+
* tasks are refused so the runner keeps exclusive ownership of their
|
|
264
|
+
* lifecycle.
|
|
265
|
+
* @returns true when applied.
|
|
266
|
+
*/
|
|
267
|
+
archiveTask(id: string): boolean {
|
|
268
|
+
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
|
|
269
|
+
if (!archived) return false
|
|
270
|
+
this.tasks = [...tasks]
|
|
271
|
+
this.persistAndNotify()
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Restore an archived task back onto the board (same status column). */
|
|
276
|
+
restoreTask(id: string): boolean {
|
|
277
|
+
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
|
|
278
|
+
if (!archived) return false
|
|
279
|
+
this.tasks = [...tasks]
|
|
280
|
+
this.persistAndNotify()
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
// --- scheduling ---------------------------------------------------------------
|
|
243
285
|
|
|
244
286
|
/**
|
|
@@ -269,6 +311,18 @@ export class BoardController {
|
|
|
269
311
|
this.persistAndNotify()
|
|
270
312
|
}
|
|
271
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Reload the ledger from the persisted store without notifying subscribers.
|
|
316
|
+
* The scheduler calls this before every tick so a task deleted in another
|
|
317
|
+
* tab (or a stale in-memory copy) can never be fired from this tab: the
|
|
318
|
+
* fire decision and the subsequent roll-forward both run on the freshest
|
|
319
|
+
* persisted truth. Deliberately silent — same-origin external changes still
|
|
320
|
+
* reach subscribers through the storage-event subscription.
|
|
321
|
+
*/
|
|
322
|
+
reloadFromStore(): void {
|
|
323
|
+
this.tasks = this.deps.store.load()
|
|
324
|
+
}
|
|
325
|
+
|
|
272
326
|
/**
|
|
273
327
|
* Jump to an execution's session transcript. Selecting the session changes
|
|
274
328
|
* `current`, which closes the board (the conversation view takes over).
|
package/src/core/execution.ts
CHANGED
|
@@ -46,6 +46,9 @@ export interface PresetsExecutionFace {
|
|
|
46
46
|
select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Settled result of one agent-preset switch, shared by concurrent waiters. */
|
|
50
|
+
type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
|
|
51
|
+
|
|
49
52
|
/** The narrow workspaces face the service needs. */
|
|
50
53
|
export interface WorkspacesExecutionFace {
|
|
51
54
|
list: {
|
|
@@ -137,6 +140,16 @@ function isErrorTurnEnd(data: unknown): boolean {
|
|
|
137
140
|
* every failure path is reported as a settled event.
|
|
138
141
|
*/
|
|
139
142
|
export class ExecutionService {
|
|
143
|
+
/**
|
|
144
|
+
* In-flight preset switches keyed by `sessionId\u0000mode`. The session
|
|
145
|
+
* list mirror can lag behind a just-applied switch, so concurrent runs
|
|
146
|
+
* against one blank session would otherwise each issue the same select
|
|
147
|
+
* RPC (an amplified storm); sharing one in-flight call keeps the wire to
|
|
148
|
+
* a single request. The settled entry is forgotten so a later run can
|
|
149
|
+
* switch again.
|
|
150
|
+
*/
|
|
151
|
+
private readonly presetSwitches = new Map<string, Promise<PresetSelectResult>>()
|
|
152
|
+
|
|
140
153
|
/** @param env - the runtime faces (real or fake). */
|
|
141
154
|
constructor(private readonly env: ExecutionEnvironment) {}
|
|
142
155
|
|
|
@@ -199,13 +212,18 @@ export class ExecutionService {
|
|
|
199
212
|
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
200
213
|
return false
|
|
201
214
|
}
|
|
215
|
+
// Fast path: the list mirror already reports the pinned preset. The
|
|
216
|
+
// mirror can lag a just-applied switch, though, so this guard alone is
|
|
217
|
+
// not enough under concurrent runs — `switchPreset` below dedupes the
|
|
218
|
+
// wire call for the lag window.
|
|
202
219
|
if (summary?.agentPreset === mode) return true
|
|
203
|
-
|
|
220
|
+
const presets = this.env.presets
|
|
221
|
+
if (presets === undefined) {
|
|
204
222
|
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
205
223
|
return false
|
|
206
224
|
}
|
|
207
225
|
try {
|
|
208
|
-
const result = await this.
|
|
226
|
+
const result = await this.switchPreset(presets, sessionId, mode)
|
|
209
227
|
if (!result.ok) {
|
|
210
228
|
// A list race can leave the summary without the preset label even
|
|
211
229
|
// though the blank session already runs it; the wire answers that
|
|
@@ -227,6 +245,27 @@ export class ExecutionService {
|
|
|
227
245
|
return true
|
|
228
246
|
}
|
|
229
247
|
|
|
248
|
+
/**
|
|
249
|
+
* One in-flight `select` per (session, preset): concurrent runs against
|
|
250
|
+
* the same blank session share the same wire call instead of each issuing
|
|
251
|
+
* a duplicate RPC. The entry is removed once the call settles, so a later
|
|
252
|
+
* run (after a shared failure, say) issues a fresh switch.
|
|
253
|
+
*/
|
|
254
|
+
private switchPreset(
|
|
255
|
+
presets: PresetsExecutionFace,
|
|
256
|
+
sessionId: string,
|
|
257
|
+
mode: string,
|
|
258
|
+
): Promise<PresetSelectResult> {
|
|
259
|
+
const key = `${sessionId}\u0000${mode}`
|
|
260
|
+
const inflight = this.presetSwitches.get(key)
|
|
261
|
+
if (inflight !== undefined) return inflight
|
|
262
|
+
const attempt = presets.select(sessionId, mode).finally(() => {
|
|
263
|
+
if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key)
|
|
264
|
+
})
|
|
265
|
+
this.presetSwitches.set(key, attempt)
|
|
266
|
+
return attempt
|
|
267
|
+
}
|
|
268
|
+
|
|
230
269
|
/**
|
|
231
270
|
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
232
271
|
* slash command. No-op when the task pins none; fails the run when the
|
package/src/core/scheduler.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* a task still running at its due instant is skipped by the controller's
|
|
10
10
|
* runTask guard and simply waits for the next cron match.
|
|
11
11
|
*
|
|
12
|
+
* A deleted task can never fire again from a stale in-memory copy: every
|
|
13
|
+
* tick first re-reads the persisted ledger through the optional `refresh`
|
|
14
|
+
* hook (the controller reloads its store), so the fire decision and the
|
|
15
|
+
* subsequent roll-forward both run on the freshest persisted truth.
|
|
16
|
+
*
|
|
12
17
|
* The ticker is controlled: `start` arms a single interval guarded by an
|
|
13
18
|
* idempotence check (a second start while running is a no-op, so wiring can
|
|
14
19
|
* never leak a duplicate timer), and `stop`/`dispose` clear the timer and
|
|
@@ -25,6 +30,13 @@ import type { TaskRecord } from './tasks.ts'
|
|
|
25
30
|
export interface SchedulerDeps {
|
|
26
31
|
/** Read the current task ledger (the controller snapshot). */
|
|
27
32
|
tasks(): readonly TaskRecord[]
|
|
33
|
+
/**
|
|
34
|
+
* Re-sync the task ledger from its persisted source before the tick decides
|
|
35
|
+
* what is due. The wiring passes the controller's store reload so a task
|
|
36
|
+
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
37
|
+
* Optional: absent in pure in-memory harnesses.
|
|
38
|
+
*/
|
|
39
|
+
refresh?: () => void
|
|
28
40
|
/** Clock; defaults to Date.now in the controller wiring. */
|
|
29
41
|
now(): number
|
|
30
42
|
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
@@ -112,6 +124,9 @@ export class SchedulerService {
|
|
|
112
124
|
async tick(): Promise<void> {
|
|
113
125
|
if (this.disposed) return
|
|
114
126
|
if (this.deps.ready !== undefined && !this.deps.ready()) return
|
|
127
|
+
// Freshest persisted truth before any fire decision: a task deleted in
|
|
128
|
+
// another tab (or a stale in-memory copy) must never be triggered.
|
|
129
|
+
this.deps.refresh?.()
|
|
115
130
|
const now = this.deps.now()
|
|
116
131
|
for (const task of this.deps.tasks()) {
|
|
117
132
|
const schedule = task.schedule
|
package/src/core/store.ts
CHANGED
|
@@ -148,6 +148,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
148
148
|
// fall back to the session default instead of dropping the row.
|
|
149
149
|
task.workspaceId = normalizeTargetId(row.workspaceId)
|
|
150
150
|
task.mode = normalizeTargetId(row.mode)
|
|
151
|
+
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
151
152
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
152
153
|
tasks.push(task)
|
|
153
154
|
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -79,8 +79,17 @@ export interface TaskRecord {
|
|
|
79
79
|
* `/permission <id>` slash command; absent leaves the session default.
|
|
80
80
|
*/
|
|
81
81
|
permission?: TaskPermission
|
|
82
|
+
/**
|
|
83
|
+
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
84
|
+
* and execution history but leave the main board; absent means on-board.
|
|
85
|
+
*/
|
|
86
|
+
archivedAt?: number
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/** Statuses a settled task may be archived from. */
|
|
90
|
+
export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
91
|
+
|
|
92
|
+
|
|
84
93
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
85
94
|
export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
|
|
86
95
|
|
|
@@ -103,6 +112,12 @@ export interface NewTaskInput {
|
|
|
103
112
|
mode?: string
|
|
104
113
|
/** Permission preset applied to the execution session; absent = session default. */
|
|
105
114
|
permission?: TaskPermission
|
|
115
|
+
/**
|
|
116
|
+
* Optional scheduled-run rule requested at creation time (the new-task
|
|
117
|
+
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
118
|
+
* case arms it only when enabled and the expression is valid.
|
|
119
|
+
*/
|
|
120
|
+
schedule?: { enabled: boolean; cron: string }
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/** The five kanban columns, in display order. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive/restore task use case: move a settled (done/failed) task off the
|
|
3
|
+
* main board and back. Purely a marker transition — the task keeps its
|
|
4
|
+
* status, execution history and transcript references, so archiving never
|
|
5
|
+
* loses traceability and restoring lands it in the exact column it left.
|
|
6
|
+
*/
|
|
7
|
+
import type { TaskRecord } from '../tasks.ts'
|
|
8
|
+
import { ARCHIVABLE_STATUSES } from '../tasks.ts'
|
|
9
|
+
|
|
10
|
+
/** Result of an archive transition. */
|
|
11
|
+
export interface ArchiveTaskResult {
|
|
12
|
+
/** The next ledger. */
|
|
13
|
+
tasks: readonly TaskRecord[]
|
|
14
|
+
/** Whether the archive was applied (false = unknown task / not archivable). */
|
|
15
|
+
archived: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Archive one task: only settled statuses (done/failed) can be archived;
|
|
20
|
+
* a running or not-yet-settled task stays on the board (its runner still
|
|
21
|
+
* owns its lifecycle). Already-archived tasks are a no-op.
|
|
22
|
+
*/
|
|
23
|
+
export function applyArchiveTask(
|
|
24
|
+
tasks: readonly TaskRecord[],
|
|
25
|
+
id: string,
|
|
26
|
+
now: number,
|
|
27
|
+
): ArchiveTaskResult {
|
|
28
|
+
let applied = false
|
|
29
|
+
const next = tasks.map(task => {
|
|
30
|
+
if (task.id !== id || task.archivedAt !== undefined) return task
|
|
31
|
+
if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
|
|
32
|
+
applied = true
|
|
33
|
+
return { ...task, archivedAt: now, updatedAt: now }
|
|
34
|
+
})
|
|
35
|
+
return { tasks: next, archived: applied }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Restore one task back onto the main board (clears the archive marker). */
|
|
39
|
+
export function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult {
|
|
40
|
+
let applied = false
|
|
41
|
+
const next = tasks.map(task => {
|
|
42
|
+
if (task.id !== id || task.archivedAt === undefined) return task
|
|
43
|
+
applied = true
|
|
44
|
+
const { archivedAt: _archived, ...rest } = task
|
|
45
|
+
return { ...rest, updatedAt: now }
|
|
46
|
+
})
|
|
47
|
+
return { tasks: next, archived: applied }
|
|
48
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* title. Pure ledger transition (no persistence or notify — the controller
|
|
4
4
|
* orchestrates those), so it is unit-testable without any runtime face.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
|
|
7
8
|
|
|
8
9
|
/** Result of a create transition: the new task (when accepted) + the next ledger. */
|
|
9
10
|
export interface CreateTaskResult {
|
|
@@ -17,7 +18,7 @@ export interface CreateTaskResult {
|
|
|
17
18
|
* Apply a create against the current ledger. Returns the new task and the
|
|
18
19
|
* appended ledger, or the unchanged ledger when the title is blank.
|
|
19
20
|
* @param tasks - current ledger.
|
|
20
|
-
* @param input - raw user input (title/description/prompt).
|
|
21
|
+
* @param input - raw user input (title/description/prompt + optional schedule).
|
|
21
22
|
* @param now - clock instant (ms epoch).
|
|
22
23
|
* @param id - minted task id.
|
|
23
24
|
*/
|
|
@@ -28,6 +29,14 @@ export function applyCreateTask(
|
|
|
28
29
|
id: string,
|
|
29
30
|
): CreateTaskResult {
|
|
30
31
|
if (input.title.trim() === '') return { task: undefined, tasks }
|
|
31
|
-
|
|
32
|
+
let task = createTask(input, now, id)
|
|
33
|
+
// Arm the requested schedule (new-task dialog): only an enabled rule with
|
|
34
|
+
// a valid cron is applied; blank, invalid, or disabled requests leave the
|
|
35
|
+
// task unscheduled.
|
|
36
|
+
const requested = input.schedule
|
|
37
|
+
if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
|
|
38
|
+
const cron = requested.cron.trim()
|
|
39
|
+
task = withSchedule(task, { enabled: true, cron, nextRunAt: nextRunAtMs(cron, now) }, now)
|
|
40
|
+
}
|
|
32
41
|
return { task, tasks: [...tasks, task] }
|
|
33
42
|
}
|