@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.
Files changed (50) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +211 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2592 -0
  5. package/lib/index.js +54 -0
  6. package/lib/invariant.js +6 -0
  7. package/lib/types/client/PluginSettingsCard.d.ts +77 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
  9. package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
  10. package/lib/types/client/board/NewTaskModal.d.ts +6 -0
  11. package/lib/types/client/board/TaskBoard.d.ts +5 -0
  12. package/lib/types/client/board/TaskCard.d.ts +12 -0
  13. package/lib/types/client/board/TaskDetail.d.ts +7 -0
  14. package/lib/types/client/board-mount.d.ts +10 -0
  15. package/lib/types/client/index.d.ts +43 -0
  16. package/lib/types/client/locales.d.ts +105 -0
  17. package/lib/types/client/settings-form.d.ts +118 -0
  18. package/lib/types/client/sidebar-entry.d.ts +25 -0
  19. package/lib/types/core/controller.d.ts +129 -0
  20. package/lib/types/core/execution.d.ts +136 -0
  21. package/lib/types/core/schedule.d.ts +40 -0
  22. package/lib/types/core/scheduler.d.ts +49 -0
  23. package/lib/types/core/store.d.ts +36 -0
  24. package/lib/types/core/tasks.d.ts +111 -0
  25. package/lib/types/index.d.ts +44 -0
  26. package/lib/types/invariant.d.ts +3 -0
  27. package/package.json +85 -0
  28. package/src/client/PluginSettingsCard.tsx +207 -0
  29. package/src/client/TaskBoardSettingsCard.tsx +124 -0
  30. package/src/client/board/ConfirmDialog.tsx +40 -0
  31. package/src/client/board/NewTaskModal.tsx +82 -0
  32. package/src/client/board/TaskBoard.tsx +107 -0
  33. package/src/client/board/TaskCard.tsx +62 -0
  34. package/src/client/board/TaskDetail.tsx +286 -0
  35. package/src/client/board-mount.tsx +74 -0
  36. package/src/client/board.module.css +849 -0
  37. package/src/client/css-modules.d.ts +5 -0
  38. package/src/client/index.ts +167 -0
  39. package/src/client/locales.ts +211 -0
  40. package/src/client/settings-card.module.css +261 -0
  41. package/src/client/settings-form.ts +294 -0
  42. package/src/client/sidebar-entry.ts +118 -0
  43. package/src/core/controller.ts +379 -0
  44. package/src/core/execution.ts +259 -0
  45. package/src/core/schedule.ts +140 -0
  46. package/src/core/scheduler.ts +104 -0
  47. package/src/core/store.ts +183 -0
  48. package/src/core/tasks.ts +204 -0
  49. package/src/index.ts +94 -0
  50. 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
+ }