@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
@@ -73,23 +73,91 @@ export function isValidCron(expr: string): boolean {
73
73
  /**
74
74
  * Compute the next matching instant after `fromMs` (ms epoch), in local time,
75
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 *`).
76
+ * of the matching minute's start, or undefined when the calendar constraint
77
+ * can never match (for example `0 0 30 2 *`). The five-year horizon includes
78
+ * a full leap cycle, so a valid February 29 schedule remains reachable from
79
+ * every non-leap year.
80
+ *
81
+ * Walks candidate year/month/day/hour/minute values straight from the parsed
82
+ * field sets instead of scanning every minute: a sparse expression such as
83
+ * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
84
+ * next leap day. Wall-clock field construction + the final `matches` re-check
85
+ * preserve the old minute scan's DST semantics exactly (nonexistent spring
86
+ * minutes normalize forward and the repeated fall-back hour is never visited).
78
87
  */
79
88
  export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
80
89
  const schedule = parseCron(expr)
81
90
  if (schedule === null) return undefined
91
+ if (!hasPossibleCalendarDay(schedule)) return undefined
82
92
  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)
93
+ const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
94
+
95
+ const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
96
+ const sortedHours = [...schedule.hours].sort((a, b) => a - b)
97
+ const sortedMonths = [...schedule.months].sort((a, b) => a - b)
98
+
99
+ let year = from.getFullYear()
100
+ let month = from.getMonth() + 1
101
+ let day = from.getDate()
102
+ let hour = from.getHours()
103
+ // Strictly after fromMs: the old scan started from the next minute.
104
+ let minute = from.getMinutes() + 1
105
+
106
+ while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
107
+ for (const candidateMonth of sortedMonths) {
108
+ if (candidateMonth < month) continue
109
+ const daysInMonth = new Date(year, candidateMonth, 0).getDate()
110
+ const dayStart = candidateMonth === month ? day : 1
111
+ for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
112
+ const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0)
113
+ if (!dayCandidate(schedule, dayProbe)) continue
114
+ const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
115
+ for (const candidateHour of sortedHours) {
116
+ if (candidateHour < hourStart) continue
117
+ const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
118
+ for (const candidateMinute of sortedMinutes) {
119
+ if (candidateMinute < minuteStart) continue
120
+ const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0)
121
+ const time = candidate.getTime()
122
+ if (time <= fromMs) continue
123
+ if (time > limitMs) return undefined
124
+ if (matches(schedule, candidate)) return time
125
+ }
126
+ }
127
+ }
128
+ }
129
+ year += 1
130
+ month = 1
131
+ day = 1
132
+ hour = 0
133
+ minute = 0
89
134
  }
90
135
  return undefined
91
136
  }
92
137
 
138
+ /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
139
+ function dayCandidate(schedule: CronSchedule, date: Date): boolean {
140
+ const dayMatches = schedule.days.has(date.getDate())
141
+ const weekdayMatches = schedule.weekdays.has(date.getDay())
142
+ if (schedule.dayWildcard) return weekdayMatches
143
+ if (schedule.weekdayWildcard) return dayMatches
144
+ return dayMatches || weekdayMatches
145
+ }
146
+
147
+ /** Reject impossible month/day pairs without spending the multi-year scan. */
148
+ function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
149
+ if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
150
+ const maximumDay = new Map<number, number>([
151
+ [1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
152
+ [7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
153
+ ])
154
+ for (const month of schedule.months) {
155
+ const maximum = maximumDay.get(month) ?? 0
156
+ if ([...schedule.days].some(day => day <= maximum)) return true
157
+ }
158
+ return false
159
+ }
160
+
93
161
  /** Parse one comma-list field into the match set. */
94
162
  function parseField(field: string, min: number, max: number, out: Set<number>): boolean {
95
163
  if (field === '*') {
@@ -128,11 +196,7 @@ function matches(schedule: CronSchedule, date: Date): boolean {
128
196
  if (!schedule.minutes.has(date.getMinutes())) return false
129
197
  if (!schedule.hours.has(date.getHours())) return false
130
198
  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
199
+ return dayCandidate(schedule, date)
136
200
  }
137
201
 
138
202
  function isDigits(value: string): boolean {
package/src/core/store.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  /**
2
- * Task persistence: a small storage seam with a localStorage backend.
2
+ * Legacy v1 browser persistence and the store seam used by pure client tests.
3
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).
4
+ * Production v2 state is Host-authoritative. This backend is retained only to
5
+ * read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
6
+ * so it remains a read-only rollback copy after migration.
11
7
  *
12
8
  * The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
13
9
  * channel later); tests run against the in-memory backend and a jsdom
package/src/core/tasks.ts CHANGED
@@ -29,9 +29,8 @@ export interface ExecutionRecord {
29
29
  }
30
30
 
31
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.
32
+ * A scheduled-run rule attached to a task. The Host scheduler triggers the
33
+ * task when `nextRunAt` is due and persists the rule in the Host ledger.
35
34
  */
36
35
  export interface ScheduleRule {
37
36
  /** Whether the schedule is armed. */
@@ -81,7 +80,8 @@ export interface TaskRecord {
81
80
  permission?: TaskPermission
82
81
  /**
83
82
  * 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.
83
+ * and execution history, leave the main board, and cannot run until restored;
84
+ * absent means on-board.
85
85
  */
86
86
  archivedAt?: number
87
87
  }
@@ -146,8 +146,8 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
146
146
  }
147
147
 
148
148
  /** Whether a manual move target is allowed from the given status. */
149
- export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
150
- return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
149
+ export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
150
+ return from !== 'running' && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
151
151
  }
152
152
 
153
153
  /** Normalize one optional execution-target string: trim; blank collapses to undefined. */
@@ -1,8 +1,8 @@
1
1
  /**
2
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.
3
+ * main board and back. The task keeps its status, execution history, and
4
+ * transcript references, while archiving disarms any schedule so it cannot
5
+ * create more execution records until the user restores and re-enables it.
6
6
  */
7
7
  import type { TaskRecord } from '../tasks.ts'
8
8
  import { ARCHIVABLE_STATUSES } from '../tasks.ts'
@@ -18,7 +18,8 @@ export interface ArchiveTaskResult {
18
18
  /**
19
19
  * Archive one task: only settled statuses (done/failed) can be archived;
20
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.
21
+ * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
22
+ * are a no-op.
22
23
  */
23
24
  export function applyArchiveTask(
24
25
  tasks: readonly TaskRecord[],
@@ -30,7 +31,15 @@ export function applyArchiveTask(
30
31
  if (task.id !== id || task.archivedAt !== undefined) return task
31
32
  if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
32
33
  applied = true
33
- return { ...task, archivedAt: now, updatedAt: now }
34
+ const schedule = task.schedule === undefined
35
+ ? undefined
36
+ : { ...task.schedule, enabled: false, nextRunAt: undefined }
37
+ return {
38
+ ...task,
39
+ ...(schedule === undefined ? {} : { schedule }),
40
+ archivedAt: now,
41
+ updatedAt: now,
42
+ }
34
43
  })
35
44
  return { tasks: next, archived: applied }
36
45
  }
@@ -22,9 +22,9 @@ export interface SetScheduleResult {
22
22
  }
23
23
 
24
24
  /**
25
- * Set a task's schedule rule. A blank or invalid cron is rejected (state
26
- * untouched); an enabled rule computes the next run instant immediately, a
27
- * disabled or invalid one carries no next-run instant.
25
+ * Set an on-board task's schedule rule. A blank or invalid cron, or an
26
+ * archived task, is rejected (state untouched); an enabled rule computes the
27
+ * next run instant immediately, a disabled one carries no next-run instant.
28
28
  * @param tasks - current ledger.
29
29
  * @param id - the task to schedule.
30
30
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -37,12 +37,13 @@ export function applySetSchedule(
37
37
  now: number,
38
38
  ): SetScheduleResult {
39
39
  const task = tasks.find(candidate => candidate.id === id)
40
- if (task === undefined) return { tasks, applied: false }
40
+ if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
41
41
  const current = task.schedule
42
42
  const cron = (patch.cron ?? current?.cron ?? '').trim()
43
43
  if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
44
44
  const enabled = patch.enabled ?? current?.enabled ?? false
45
45
  const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
46
+ if (enabled && nextRunAt === undefined) return { tasks, applied: false }
46
47
  return {
47
48
  tasks: tasks.map(candidate =>
48
49
  candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
@@ -68,7 +69,7 @@ export function applyScheduleNextRun(
68
69
  now: number,
69
70
  ): readonly TaskRecord[] {
70
71
  return tasks.map(task =>
71
- task.id === id && task.schedule !== undefined
72
+ task.id === id && task.archivedAt === undefined && task.schedule !== undefined
72
73
  ? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
73
74
  : task)
74
75
  }
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }