@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.
- package/README.i18n.yaml +2 -2
- package/README.md +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
package/src/core/schedule.ts
CHANGED
|
@@ -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
|
|
77
|
-
*
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* Legacy v1 browser persistence and the store seam used by pure client tests.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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
|
|
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(
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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).
|
|
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
|
-
|
|
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
|
|
26
|
-
* untouched); an enabled rule computes the
|
|
27
|
-
*
|
|
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
|
}
|
package/src/dsh-home.ts
ADDED
|
@@ -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
|
+
}
|