@linxin666/dsh-client-ui-task-board 0.1.1
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/LICENSE +29 -0
- package/README.md +211 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2592 -0
- package/lib/index.js +54 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts +77 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -0
- package/lib/types/client/board/TaskBoard.d.ts +5 -0
- package/lib/types/client/board/TaskCard.d.ts +12 -0
- package/lib/types/client/board/TaskDetail.d.ts +7 -0
- package/lib/types/client/board-mount.d.ts +10 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/locales.d.ts +105 -0
- package/lib/types/client/settings-form.d.ts +118 -0
- package/lib/types/client/sidebar-entry.d.ts +25 -0
- package/lib/types/core/controller.d.ts +129 -0
- package/lib/types/core/execution.d.ts +136 -0
- package/lib/types/core/schedule.d.ts +40 -0
- package/lib/types/core/scheduler.d.ts +49 -0
- package/lib/types/core/store.d.ts +36 -0
- package/lib/types/core/tasks.d.ts +111 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/package.json +85 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/TaskBoardSettingsCard.tsx +124 -0
- package/src/client/board/ConfirmDialog.tsx +40 -0
- package/src/client/board/NewTaskModal.tsx +82 -0
- package/src/client/board/TaskBoard.tsx +107 -0
- package/src/client/board/TaskCard.tsx +62 -0
- package/src/client/board/TaskDetail.tsx +286 -0
- package/src/client/board-mount.tsx +74 -0
- package/src/client/board.module.css +849 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +211 -0
- package/src/client/settings-card.module.css +261 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/sidebar-entry.ts +118 -0
- package/src/core/controller.ts +379 -0
- package/src/core/execution.ts +259 -0
- package/src/core/schedule.ts +140 -0
- package/src/core/scheduler.ts +104 -0
- package/src/core/store.ts +183 -0
- package/src/core/tasks.ts +204 -0
- package/src/index.ts +94 -0
- package/src/invariant.ts +4 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal 5-field cron parsing and next-run computation for scheduled task
|
|
3
|
+
* runs. Framework-free and dependency-free so the scheduler and controller
|
|
4
|
+
* share one tiny pure module.
|
|
5
|
+
*
|
|
6
|
+
* Grammar: five whitespace-separated fields, 分 时 日 月 周. Every field
|
|
7
|
+
* supports the wildcard, step (wildcard or range + "/n"), single value,
|
|
8
|
+
* inclusive range a-b, and comma lists mixing any of those. Ranges: minutes
|
|
9
|
+
* 0-59, hours 0-23, days 1-31, months 1-12, weekdays 0-7 (0 and 7 both mean
|
|
10
|
+
* Sunday). When both the day and weekday fields are restricted they combine
|
|
11
|
+
* with OR semantics (standard cron). Invalid expressions parse to null and
|
|
12
|
+
* are rejected by the UI/controller.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The parsed match sets of one cron expression. */
|
|
16
|
+
export interface CronSchedule {
|
|
17
|
+
minutes: ReadonlySet<number>
|
|
18
|
+
hours: ReadonlySet<number>
|
|
19
|
+
days: ReadonlySet<number>
|
|
20
|
+
months: ReadonlySet<number>
|
|
21
|
+
/** Weekdays 0-6, 0 = Sunday (input 7 normalized to 0). */
|
|
22
|
+
weekdays: ReadonlySet<number>
|
|
23
|
+
/** Whether the day-of-month field was the literal '*' (unrestricted). */
|
|
24
|
+
dayWildcard: boolean
|
|
25
|
+
/** Whether the weekday field was the literal '*' (unrestricted). */
|
|
26
|
+
weekdayWildcard: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Inclusive ranges per field, in cron order. */
|
|
30
|
+
const FIELD_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
31
|
+
[0, 59], // minutes
|
|
32
|
+
[0, 23], // hours
|
|
33
|
+
[1, 31], // days
|
|
34
|
+
[1, 12], // months
|
|
35
|
+
[0, 7], // weekdays (7 = Sunday, normalized below)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a 5-field cron expression.
|
|
40
|
+
* @returns the match sets, or null when the expression is invalid.
|
|
41
|
+
*/
|
|
42
|
+
export function parseCron(expr: string): CronSchedule | null {
|
|
43
|
+
const fields = expr.trim().split(/\s+/)
|
|
44
|
+
if (fields.length !== 5) return null
|
|
45
|
+
const sets: Set<number>[] = []
|
|
46
|
+
for (let index = 0; index < 5; index++) {
|
|
47
|
+
const [min, max] = FIELD_RANGES[index]
|
|
48
|
+
const set = new Set<number>()
|
|
49
|
+
if (!parseField(fields[index], min, max, set)) return null
|
|
50
|
+
sets.push(set)
|
|
51
|
+
}
|
|
52
|
+
const weekdays = new Set<number>()
|
|
53
|
+
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day)
|
|
54
|
+
return {
|
|
55
|
+
minutes: sets[0],
|
|
56
|
+
hours: sets[1],
|
|
57
|
+
days: sets[2],
|
|
58
|
+
months: sets[3],
|
|
59
|
+
weekdays,
|
|
60
|
+
// Only the literal '*' marks a field unrestricted: an explicit full
|
|
61
|
+
// enumeration such as '1-31' is a restricted field and must not collapse
|
|
62
|
+
// into the wildcard (it participates in day/weekday OR semantics).
|
|
63
|
+
dayWildcard: fields[2] === '*',
|
|
64
|
+
weekdayWildcard: fields[4] === '*',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether the expression parses. */
|
|
69
|
+
export function isValidCron(expr: string): boolean {
|
|
70
|
+
return parseCron(expr) !== null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compute the next matching instant after `fromMs` (ms epoch), in local time,
|
|
75
|
+
* at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
|
|
76
|
+
* of the matching minute's start, or undefined when nothing matches within
|
|
77
|
+
* 366 days (e.g. `0 0 30 2 *`).
|
|
78
|
+
*/
|
|
79
|
+
export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
80
|
+
const schedule = parseCron(expr)
|
|
81
|
+
if (schedule === null) return undefined
|
|
82
|
+
const from = new Date(fromMs)
|
|
83
|
+
// Scan from the next minute; Date rolls overflow (Feb 30 → Mar 2) for free.
|
|
84
|
+
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0)
|
|
85
|
+
const limitMs = fromMs + 366 * 24 * 60 * 60 * 1000
|
|
86
|
+
while (scan.getTime() <= limitMs) {
|
|
87
|
+
if (matches(schedule, scan)) return scan.getTime()
|
|
88
|
+
scan.setMinutes(scan.getMinutes() + 1)
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse one comma-list field into the match set. */
|
|
94
|
+
function parseField(field: string, min: number, max: number, out: Set<number>): boolean {
|
|
95
|
+
if (field === '*') {
|
|
96
|
+
for (let value = min; value <= max; value++) out.add(value)
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
for (const part of field.split(',')) {
|
|
100
|
+
if (part === '') return false
|
|
101
|
+
const [range, stepRaw] = part.split('/')
|
|
102
|
+
let low: number
|
|
103
|
+
let high: number
|
|
104
|
+
if (range === '*') {
|
|
105
|
+
low = min
|
|
106
|
+
high = max
|
|
107
|
+
} else if (range.includes('-')) {
|
|
108
|
+
const [a, b] = range.split('-')
|
|
109
|
+
if (a === '' || b === '' || !isDigits(a) || !isDigits(b)) return false
|
|
110
|
+
low = Number(a)
|
|
111
|
+
high = Number(b)
|
|
112
|
+
} else if (isDigits(range)) {
|
|
113
|
+
low = Number(range)
|
|
114
|
+
high = Number(range)
|
|
115
|
+
} else {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
if (low < min || high > max || low > high) return false
|
|
119
|
+
const step = stepRaw === undefined ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN
|
|
120
|
+
if (!Number.isInteger(step) || step < 1) return false
|
|
121
|
+
for (let value = low; value <= high; value += step) out.add(value)
|
|
122
|
+
}
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
127
|
+
function matches(schedule: CronSchedule, date: Date): boolean {
|
|
128
|
+
if (!schedule.minutes.has(date.getMinutes())) return false
|
|
129
|
+
if (!schedule.hours.has(date.getHours())) return false
|
|
130
|
+
if (!schedule.months.has(date.getMonth() + 1)) return false
|
|
131
|
+
const dayMatches = schedule.days.has(date.getDate())
|
|
132
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay())
|
|
133
|
+
if (schedule.dayWildcard) return weekdayMatches
|
|
134
|
+
if (schedule.weekdayWildcard) return dayMatches
|
|
135
|
+
return dayMatches || weekdayMatches
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isDigits(value: string): boolean {
|
|
139
|
+
return /^\d+$/.test(value)
|
|
140
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side scheduler: the heartbeat behind scheduled task runs.
|
|
3
|
+
*
|
|
4
|
+
* The board is a pure client plugin with no server channel, so "定时任务"
|
|
5
|
+
* lives in the tab: a timer ticks every minute (plus immediately on tab
|
|
6
|
+
* visibility recovery) and triggers any task whose `schedule.nextRunAt` is
|
|
7
|
+
* due, rolling the schedule forward to the next cron match before triggering
|
|
8
|
+
* so the same tick never double-fires. Missed runs are skipped, never queued:
|
|
9
|
+
* a task still running at its due instant is skipped by the controller's
|
|
10
|
+
* runTask guard and simply waits for the next cron match.
|
|
11
|
+
*
|
|
12
|
+
* Framework-free: all runtime access flows through the injected deps
|
|
13
|
+
* (structural faces), so tests drive ticks directly without timers.
|
|
14
|
+
*/
|
|
15
|
+
import { nextRunAtMs } from './schedule.ts'
|
|
16
|
+
import type { TaskRecord } from './tasks.ts'
|
|
17
|
+
|
|
18
|
+
/** Everything the scheduler needs from its host (the board controller). */
|
|
19
|
+
export interface SchedulerDeps {
|
|
20
|
+
/** Read the current task ledger (the controller snapshot). */
|
|
21
|
+
tasks(): readonly TaskRecord[]
|
|
22
|
+
/** Clock; defaults to Date.now in the controller wiring. */
|
|
23
|
+
now(): number
|
|
24
|
+
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
25
|
+
* true when the run was accepted, false when rejected (e.g. the task is
|
|
26
|
+
* already running). */
|
|
27
|
+
runTask(id: string): Promise<boolean>
|
|
28
|
+
/** Persist a rolled-forward schedule (next due + this trigger instant). */
|
|
29
|
+
applySchedule(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void
|
|
30
|
+
/** Tick cadence; defaults to 60_000 ms. */
|
|
31
|
+
tickMs?: number
|
|
32
|
+
/**
|
|
33
|
+
* Gate: while false the tick no-ops (e.g. the session list baseline has not
|
|
34
|
+
* arrived on page load, so executions would fail). Defaults to always ready.
|
|
35
|
+
*/
|
|
36
|
+
ready?: () => boolean
|
|
37
|
+
/** Environment listeners for tab-visibility recovery (browser only). */
|
|
38
|
+
environment?: {
|
|
39
|
+
addEventListener(type: 'visibilitychange', listener: () => void): void
|
|
40
|
+
removeEventListener(type: 'visibilitychange', listener: () => void): void
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The schedule heartbeat (see module doc). `tick` is public so tests and
|
|
46
|
+
* callers can drive a check without waiting for the interval.
|
|
47
|
+
*/
|
|
48
|
+
export class SchedulerService {
|
|
49
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
50
|
+
private disposed = false
|
|
51
|
+
|
|
52
|
+
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
53
|
+
constructor(private readonly deps: SchedulerDeps) {}
|
|
54
|
+
|
|
55
|
+
/** Start ticking: one immediate check (catch-up after reload) + the interval. */
|
|
56
|
+
start(): void {
|
|
57
|
+
if (this.disposed) return
|
|
58
|
+
// Immediate catch-up tick: schedules whose due instant passed while the
|
|
59
|
+
// tab was closed are triggered as soon as the runtime is ready.
|
|
60
|
+
this.tick()
|
|
61
|
+
this.timer = setInterval(() => { this.tick() }, this.deps.tickMs ?? 60_000)
|
|
62
|
+
this.deps.environment?.addEventListener('visibilitychange', this.onVisibility)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stop ticking and drop listeners (idempotent). */
|
|
66
|
+
dispose(): void {
|
|
67
|
+
this.disposed = true
|
|
68
|
+
if (this.timer !== undefined) clearInterval(this.timer)
|
|
69
|
+
this.timer = undefined
|
|
70
|
+
this.deps.environment?.removeEventListener('visibilitychange', this.onVisibility)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check every enabled schedule and trigger the due ones. Idempotent per
|
|
75
|
+
* task per tick: the schedule is rolled forward only after runTask accepts
|
|
76
|
+
* the run, so a rejected run keeps its due slot and is retried on the next
|
|
77
|
+
* tick instead of being silently dropped.
|
|
78
|
+
*/
|
|
79
|
+
async tick(): Promise<void> {
|
|
80
|
+
if (this.disposed) return
|
|
81
|
+
if (this.deps.ready !== undefined && !this.deps.ready()) return
|
|
82
|
+
const now = this.deps.now()
|
|
83
|
+
for (const task of this.deps.tasks()) {
|
|
84
|
+
const schedule = task.schedule
|
|
85
|
+
if (schedule === undefined || !schedule.enabled) continue
|
|
86
|
+
if (schedule.nextRunAt === undefined) {
|
|
87
|
+
// Missing next-run instant (repaired/legacy data): recompute from the
|
|
88
|
+
// cron expression and wait; an unparseable expression is skipped.
|
|
89
|
+
const repaired = nextRunAtMs(schedule.cron, now)
|
|
90
|
+
if (repaired === undefined) continue
|
|
91
|
+
this.deps.applySchedule(task.id, repaired, undefined)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (schedule.nextRunAt > now) continue
|
|
95
|
+
// Advance from the due instant (not this tick's wall-clock) and only
|
|
96
|
+
// after the run is accepted: a rejected run keeps its due slot.
|
|
97
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
98
|
+
const accepted = await this.deps.runTask(task.id)
|
|
99
|
+
if (accepted) this.deps.applySchedule(task.id, next, now)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private readonly onVisibility = (): void => { this.tick() }
|
|
104
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task persistence: a small storage seam with a localStorage backend.
|
|
3
|
+
*
|
|
4
|
+
* The task-board client plugin runs in the browser, and dsh exposes no
|
|
5
|
+
* browser-writable file channel (same conclusion the skin-center research
|
|
6
|
+
* reached for cordis.patch.yml), so tasks persist in the browser's
|
|
7
|
+
* localStorage under a versioned key — the same persistence mechanism dsh's
|
|
8
|
+
* own client snapshot stores use (`createSnapshotStore` persist). Data
|
|
9
|
+
* survives page refreshes and dsh restarts (same origin), and survives
|
|
10
|
+
* plugin uninstall (the key is simply left in place).
|
|
11
|
+
*
|
|
12
|
+
* The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
|
|
13
|
+
* channel later); tests run against the in-memory backend and a jsdom
|
|
14
|
+
* localStorage backend.
|
|
15
|
+
*/
|
|
16
|
+
import { isValidCron } from './schedule.ts'
|
|
17
|
+
import type { ScheduleRule, TaskRecord, TaskStatus } from './tasks.ts'
|
|
18
|
+
import { isTaskStatus } from './tasks.ts'
|
|
19
|
+
|
|
20
|
+
/** Persistence seam for the task ledger. */
|
|
21
|
+
export interface TaskStore {
|
|
22
|
+
/** Read the persisted ledger (empty when nothing is stored yet). */
|
|
23
|
+
load(): TaskRecord[]
|
|
24
|
+
/** Persist the whole ledger (replaces the stored document). */
|
|
25
|
+
save(tasks: readonly TaskRecord[]): void
|
|
26
|
+
/** Drop the persisted ledger (leaves the in-memory state alone). */
|
|
27
|
+
clear(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Storage key for the task ledger document. */
|
|
31
|
+
export const DEFAULT_STORAGE_KEY = 'dsh.taskBoard.v1'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Structural row check with the status left unvalidated (see {@link parseLedger}).
|
|
35
|
+
* The `schedule` field is deliberately NOT checked here: a malformed schedule
|
|
36
|
+
* never drops the task row — {@link normalizeSchedule} repairs or drops the
|
|
37
|
+
* schedule alone.
|
|
38
|
+
*/
|
|
39
|
+
function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'> & { status: unknown } {
|
|
40
|
+
if (typeof value !== 'object' || value === null) return false
|
|
41
|
+
const record = value as Record<string, unknown>
|
|
42
|
+
if (typeof record.id !== 'string' || record.id === '') return false
|
|
43
|
+
if (typeof record.title !== 'string') return false
|
|
44
|
+
if (typeof record.description !== 'string') return false
|
|
45
|
+
if (typeof record.prompt !== 'string') return false
|
|
46
|
+
if (typeof record.createdAt !== 'number') return false
|
|
47
|
+
if (typeof record.updatedAt !== 'number') return false
|
|
48
|
+
if (!Array.isArray(record.executions)) return false
|
|
49
|
+
for (const execution of record.executions) {
|
|
50
|
+
if (typeof execution !== 'object' || execution === null) return false
|
|
51
|
+
const entry = execution as Record<string, unknown>
|
|
52
|
+
if (typeof entry.id !== 'string') return false
|
|
53
|
+
if (entry.sessionId !== undefined && typeof entry.sessionId !== 'string') return false
|
|
54
|
+
if (typeof entry.startedAt !== 'number') return false
|
|
55
|
+
if (entry.endedAt !== undefined && typeof entry.endedAt !== 'number') return false
|
|
56
|
+
if (entry.result !== undefined && entry.result !== 'succeeded' && entry.result !== 'failed' && entry.result !== 'cancelled') return false
|
|
57
|
+
if (entry.error !== undefined && typeof entry.error !== 'string') return false
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A task record is structurally valid if it round-trips through the UI. */
|
|
63
|
+
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
64
|
+
return isTaskRecordShape(value) && isTaskStatus(value.status)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Normalize an unknown persisted status back into the closed status union. */
|
|
68
|
+
function normalizeStatus(status: unknown): TaskStatus {
|
|
69
|
+
return isTaskStatus(status) ? status : 'todo'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Repair a persisted schedule rule: drop rules without a usable cron string,
|
|
74
|
+
* coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
|
|
75
|
+
* when missing (a fresh recompute or the next tick fixes them).
|
|
76
|
+
*/
|
|
77
|
+
function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
78
|
+
if (typeof schedule !== 'object' || schedule === null) return undefined
|
|
79
|
+
const rule = schedule as Record<string, unknown>
|
|
80
|
+
// Reject (drop) a schedule whose cron is not a well-formed 5-field
|
|
81
|
+
// expression: a malformed rule would otherwise linger as a never-firing
|
|
82
|
+
// schedule instead of being dropped for later repair.
|
|
83
|
+
if (typeof rule.cron !== 'string') return undefined
|
|
84
|
+
if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
|
|
85
|
+
return {
|
|
86
|
+
enabled: rule.enabled === true,
|
|
87
|
+
cron: rule.cron,
|
|
88
|
+
nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
|
|
89
|
+
lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse + validate a persisted ledger document; invalid rows are dropped. */
|
|
94
|
+
export function parseLedger(raw: string | null): TaskRecord[] {
|
|
95
|
+
if (raw === null) return []
|
|
96
|
+
let parsed: unknown
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(raw)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('[dsh-task-board] persisted task ledger is not valid JSON; starting empty', error)
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(parsed)) {
|
|
104
|
+
console.error('[dsh-task-board] persisted task ledger is not an array; starting empty')
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
const tasks: TaskRecord[] = []
|
|
108
|
+
for (const row of parsed) {
|
|
109
|
+
// Status is normalized (an unknown status from a future version lands in
|
|
110
|
+
// todo instead of dropping the row); the schedule is repaired field by
|
|
111
|
+
// field; every other field must be valid.
|
|
112
|
+
if (!isTaskRecordShape(row)) {
|
|
113
|
+
console.warn('[dsh-task-board] dropping invalid task row from persisted ledger', row)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
// Always (re)assign the schedule: a repair that returns undefined must
|
|
117
|
+
// clear a malformed persisted rule rather than leave it in the row.
|
|
118
|
+
const task: TaskRecord = { ...row, status: normalizeStatus(row.status) }
|
|
119
|
+
task.schedule = normalizeSchedule(row.schedule)
|
|
120
|
+
tasks.push(task)
|
|
121
|
+
}
|
|
122
|
+
return tasks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** localStorage-backed store (the browser backend). */
|
|
126
|
+
export class LocalStorageTaskStore implements TaskStore {
|
|
127
|
+
/**
|
|
128
|
+
* @param key - storage key for the ledger document.
|
|
129
|
+
* @param storage - storage backend (defaults to the global localStorage; tests inject fakes).
|
|
130
|
+
*/
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly key: string = DEFAULT_STORAGE_KEY,
|
|
133
|
+
private readonly storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined = globalThis.localStorage,
|
|
134
|
+
) {}
|
|
135
|
+
|
|
136
|
+
load(): TaskRecord[] {
|
|
137
|
+
if (this.storage === undefined) return []
|
|
138
|
+
try {
|
|
139
|
+
return parseLedger(this.storage.getItem(this.key))
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// Storage read failures (private mode, quota) degrade to an empty ledger,
|
|
142
|
+
// never break the board.
|
|
143
|
+
console.error('[dsh-task-board] task ledger read failed; starting empty', error)
|
|
144
|
+
return []
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
save(tasks: readonly TaskRecord[]): void {
|
|
149
|
+
if (this.storage === undefined) return
|
|
150
|
+
try {
|
|
151
|
+
this.storage.setItem(this.key, JSON.stringify(tasks))
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Write failures only skip persistence; in-memory state stays live.
|
|
154
|
+
console.error('[dsh-task-board] task ledger write failed (persistence skipped)', error)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
clear(): void {
|
|
159
|
+
if (this.storage === undefined) return
|
|
160
|
+
try {
|
|
161
|
+
this.storage.removeItem(this.key)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('[dsh-task-board] task ledger clear failed', error)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** In-memory backend (tests, and a fallback when storage is unavailable). */
|
|
169
|
+
export class InMemoryTaskStore implements TaskStore {
|
|
170
|
+
private ledger: TaskRecord[] = []
|
|
171
|
+
|
|
172
|
+
load(): TaskRecord[] {
|
|
173
|
+
return this.ledger.map(task => ({ ...task, executions: [...task.executions] }))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
save(tasks: readonly TaskRecord[]): void {
|
|
177
|
+
this.ledger = tasks.map(task => ({ ...task, executions: [...task.executions] }))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
clear(): void {
|
|
181
|
+
this.ledger = []
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task board domain model: task lifecycle statuses, the task record shape,
|
|
3
|
+
* and the pure transition functions the controller and tests share.
|
|
4
|
+
* Framework-free (no cordis, no runtime imports) so the state machine is
|
|
5
|
+
* unit-testable in isolation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Task lifecycle status, one per kanban column. */
|
|
9
|
+
export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One real execution attempt: the run's own id, the dsh session that ran it
|
|
13
|
+
* (filled once the session is created), and the settled outcome once the
|
|
14
|
+
* session's turn ended.
|
|
15
|
+
*/
|
|
16
|
+
export interface ExecutionRecord {
|
|
17
|
+
/** Execution attempt id (uuid). */
|
|
18
|
+
id: string
|
|
19
|
+
/** The dsh session that ran this attempt; absent until creation resolves. */
|
|
20
|
+
sessionId: string | undefined
|
|
21
|
+
/** When the run started (ms epoch). */
|
|
22
|
+
startedAt: number
|
|
23
|
+
/** When the run settled; absent while still running. */
|
|
24
|
+
endedAt: number | undefined
|
|
25
|
+
/** Outcome once settled. */
|
|
26
|
+
result: 'succeeded' | 'failed' | 'cancelled' | undefined
|
|
27
|
+
/** Human failure text when the run failed (prompt rejection or agent error). */
|
|
28
|
+
error: string | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A scheduled-run rule attached to a task. The browser-side scheduler ticks
|
|
33
|
+
* every minute and triggers the task when `nextRunAt` is due; the rule is
|
|
34
|
+
* persisted with the task (localStorage), so scheduling survives refreshes.
|
|
35
|
+
*/
|
|
36
|
+
export interface ScheduleRule {
|
|
37
|
+
/** Whether the schedule is armed. */
|
|
38
|
+
enabled: boolean
|
|
39
|
+
/** 5-field cron expression: `分 时 日 月 周`. */
|
|
40
|
+
cron: string
|
|
41
|
+
/** Next due instant (ms epoch); maintained by the scheduler/controller. */
|
|
42
|
+
nextRunAt: number | undefined
|
|
43
|
+
/** Instant of the latest scheduled trigger (ms epoch). */
|
|
44
|
+
lastTriggeredAt: number | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One task on the board. */
|
|
48
|
+
export interface TaskRecord {
|
|
49
|
+
/** Stable task id (uuid). */
|
|
50
|
+
id: string
|
|
51
|
+
/** Short display title. */
|
|
52
|
+
title: string
|
|
53
|
+
/** Longer human description shown in the detail view. */
|
|
54
|
+
description: string
|
|
55
|
+
/** The prompt sent to dsh when this task is executed. */
|
|
56
|
+
prompt: string
|
|
57
|
+
/** Current column. */
|
|
58
|
+
status: TaskStatus
|
|
59
|
+
/** Creation instant (ms epoch). */
|
|
60
|
+
createdAt: number
|
|
61
|
+
/** Last mutation instant (ms epoch). */
|
|
62
|
+
updatedAt: number
|
|
63
|
+
/** Every execution attempt, most recent last. */
|
|
64
|
+
executions: ExecutionRecord[]
|
|
65
|
+
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
66
|
+
schedule?: ScheduleRule
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Input for creating a task. */
|
|
70
|
+
export interface NewTaskInput {
|
|
71
|
+
title: string
|
|
72
|
+
description: string
|
|
73
|
+
prompt: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The five kanban columns, in display order. */
|
|
77
|
+
export const COLUMNS: readonly { status: TaskStatus; label: string }[] = [
|
|
78
|
+
{ status: 'backlog', label: '待规划' },
|
|
79
|
+
{ status: 'todo', label: '待办' },
|
|
80
|
+
{ status: 'running', label: '进行中' },
|
|
81
|
+
{ status: 'done', label: '已完成' },
|
|
82
|
+
{ status: 'failed', label: '已失败' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/** Statuses a user may move a card to manually (execution states are owned by the runner). */
|
|
86
|
+
export const MANUAL_STATUSES: readonly TaskStatus[] = ['backlog', 'todo']
|
|
87
|
+
|
|
88
|
+
/** Statuses the runner may move a card to from 'running'. */
|
|
89
|
+
export const RUNNER_SETTLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
90
|
+
|
|
91
|
+
/** All valid statuses (closed union guard). */
|
|
92
|
+
export const ALL_STATUSES: readonly TaskStatus[] = [
|
|
93
|
+
'backlog', 'todo', 'running', 'done', 'failed',
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/** Brand an unknown string as a status; undefined when it is not one. */
|
|
97
|
+
export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
98
|
+
return typeof value === 'string' && (ALL_STATUSES as readonly string[]).includes(value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether a manual move target is allowed from the given status. */
|
|
102
|
+
export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
|
|
103
|
+
return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Create a task from user input. */
|
|
107
|
+
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
title: input.title.trim(),
|
|
111
|
+
description: input.description.trim(),
|
|
112
|
+
prompt: input.prompt.trim(),
|
|
113
|
+
status: 'todo',
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
executions: [],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Clone a task with an updated status and a fresh updatedAt. */
|
|
121
|
+
export function withStatus(task: TaskRecord, status: TaskStatus, now: number): TaskRecord {
|
|
122
|
+
return { ...task, status, updatedAt: now }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merge a schedule patch into a task's schedule rule (creating it when
|
|
127
|
+
* absent), with a fresh updatedAt. Keys present in the patch overwrite the
|
|
128
|
+
* current value — including explicit `undefined`, which clears a field (used
|
|
129
|
+
* to disarm `nextRunAt`); absent keys keep their current value.
|
|
130
|
+
*/
|
|
131
|
+
export function withSchedule(
|
|
132
|
+
task: TaskRecord,
|
|
133
|
+
patch: Partial<ScheduleRule>,
|
|
134
|
+
now: number,
|
|
135
|
+
): TaskRecord {
|
|
136
|
+
const current = task.schedule
|
|
137
|
+
const schedule: ScheduleRule = {
|
|
138
|
+
enabled: current?.enabled ?? false,
|
|
139
|
+
cron: current?.cron ?? '',
|
|
140
|
+
nextRunAt: current?.nextRunAt,
|
|
141
|
+
lastTriggeredAt: current?.lastTriggeredAt,
|
|
142
|
+
}
|
|
143
|
+
if ('enabled' in patch) schedule.enabled = patch.enabled ?? false
|
|
144
|
+
if ('cron' in patch) schedule.cron = patch.cron ?? ''
|
|
145
|
+
if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
|
|
146
|
+
if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
|
|
147
|
+
return { ...task, updatedAt: now, schedule }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Open a fresh execution on a task: move it to 'running' and append a
|
|
152
|
+
* running execution record. Returns the new task and the new execution.
|
|
153
|
+
*/
|
|
154
|
+
export function startExecution(
|
|
155
|
+
task: TaskRecord,
|
|
156
|
+
now: number,
|
|
157
|
+
executionId: string,
|
|
158
|
+
): { task: TaskRecord; execution: ExecutionRecord } {
|
|
159
|
+
const execution: ExecutionRecord = {
|
|
160
|
+
id: executionId,
|
|
161
|
+
sessionId: undefined,
|
|
162
|
+
startedAt: now,
|
|
163
|
+
endedAt: undefined,
|
|
164
|
+
result: undefined,
|
|
165
|
+
error: undefined,
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
task: { ...task, status: 'running', updatedAt: now, executions: [...task.executions, execution] },
|
|
169
|
+
execution,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Settle a running execution: record the outcome and move the task into the
|
|
175
|
+
* matching column. No-op (returns the input task) when the execution is not
|
|
176
|
+
* the task's latest or is already settled.
|
|
177
|
+
*/
|
|
178
|
+
export function settleExecution(
|
|
179
|
+
task: TaskRecord,
|
|
180
|
+
executionId: string,
|
|
181
|
+
outcome: 'succeeded' | 'failed' | 'cancelled',
|
|
182
|
+
now: number,
|
|
183
|
+
error: string | undefined,
|
|
184
|
+
): TaskRecord {
|
|
185
|
+
const index = task.executions.findIndex(execution => execution.id === executionId)
|
|
186
|
+
if (index === -1) return task
|
|
187
|
+
const execution = task.executions[index]
|
|
188
|
+
if (execution.endedAt !== undefined) return task
|
|
189
|
+
const settled: ExecutionRecord = { ...execution, endedAt: now, result: outcome, error }
|
|
190
|
+
const executions = [...task.executions]
|
|
191
|
+
executions[index] = settled
|
|
192
|
+
const status: TaskStatus = outcome === 'succeeded' ? 'done'
|
|
193
|
+
: outcome === 'failed' ? 'failed'
|
|
194
|
+
: task.status === 'running' ? 'todo' : task.status
|
|
195
|
+
return { ...task, status, updatedAt: now, executions }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A settled-execution summary string for the detail view. */
|
|
199
|
+
export function executionLabel(execution: ExecutionRecord): string {
|
|
200
|
+
if (execution.result === 'succeeded') return 'succeeded'
|
|
201
|
+
if (execution.result === 'failed') return 'failed'
|
|
202
|
+
if (execution.result === 'cancelled') return 'cancelled'
|
|
203
|
+
return 'running'
|
|
204
|
+
}
|