@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4
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 +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
package/src/core/controller.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
|
17
17
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
18
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
19
19
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
20
|
+
import { resolveHostTimeZone } from './schedule.ts'
|
|
20
21
|
import { applySetParent } from './use-cases/task-parent.ts'
|
|
21
22
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
22
23
|
import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
|
|
@@ -62,6 +63,21 @@ function currentOf(sessions: SessionsControllerFace | undefined): string | undef
|
|
|
62
63
|
return sessions?.current()
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/** Stable id shared by the board's sidebar panel row and its main-slot page. */
|
|
67
|
+
export const TASK_BOARD_PANEL_ID = 'task-board'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The layout's panel-navigation face. The board no longer owns the center
|
|
71
|
+
* column at the DOM level: selecting the panel is the layout's business, and
|
|
72
|
+
* this is the one call that asks for it (null returns to the conversation).
|
|
73
|
+
* Optional, so a composition without the layout service keeps the board's
|
|
74
|
+
* pure state transitions testable.
|
|
75
|
+
*/
|
|
76
|
+
export interface PanelNavigationFace {
|
|
77
|
+
/** Select the board panel, or null to hand the column back to the conversation. */
|
|
78
|
+
select(panelId: string | null): void
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
/** Controller dependencies (all swappable in tests). */
|
|
66
82
|
export interface ControllerDeps {
|
|
67
83
|
store: TaskStore
|
|
@@ -72,6 +88,8 @@ export interface ControllerDeps {
|
|
|
72
88
|
uuid?: () => string
|
|
73
89
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
74
90
|
transport?: TaskBoardTransport
|
|
91
|
+
/** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
|
|
92
|
+
panel?: PanelNavigationFace
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
@@ -253,15 +271,31 @@ export class BoardController {
|
|
|
253
271
|
|
|
254
272
|
// --- view state -------------------------------------------------------------
|
|
255
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Show the board. The layout owns which panel the center column renders, so
|
|
276
|
+
* the state flip and the panel selection travel together here; a composition
|
|
277
|
+
* with no layout face (tests, a shell-less host) still flips the state.
|
|
278
|
+
*
|
|
279
|
+
* The selection is requested AFTER the snapshot flips so a subscriber
|
|
280
|
+
* rendering against `boardOpen` never observes "open" while the shell still
|
|
281
|
+
* shows the conversation.
|
|
282
|
+
*/
|
|
256
283
|
openBoard(): void {
|
|
257
284
|
if (this.boardOpen) return
|
|
258
285
|
this.boardOpen = true
|
|
259
286
|
this.notify()
|
|
287
|
+
this.selectPanel(TASK_BOARD_PANEL_ID)
|
|
260
288
|
}
|
|
261
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Return the center column to the conversation. The layout's own selection is
|
|
292
|
+
* the source of truth for what the column renders, so this asks for the
|
|
293
|
+
* conversation explicitly rather than only clearing local state.
|
|
294
|
+
*/
|
|
262
295
|
closeBoard(): void {
|
|
263
296
|
this.boardOpen = false
|
|
264
297
|
this.notify()
|
|
298
|
+
this.selectPanel(null)
|
|
265
299
|
}
|
|
266
300
|
|
|
267
301
|
toggleBoard(): void {
|
|
@@ -269,6 +303,30 @@ export class BoardController {
|
|
|
269
303
|
else this.openBoard()
|
|
270
304
|
}
|
|
271
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Reflect a panel selection that came from OUTSIDE this controller (the user
|
|
308
|
+
* clicked another sidebar row, or the layout dropped the panel id). Keeps
|
|
309
|
+
* `boardOpen` aligned with what the column actually shows without asking the
|
|
310
|
+
* layout to select anything back.
|
|
311
|
+
* @param panelId - the layout's current panel id, or null for the conversation.
|
|
312
|
+
*/
|
|
313
|
+
syncPanelSelection(panelId: string | null): void {
|
|
314
|
+
const open = panelId === TASK_BOARD_PANEL_ID
|
|
315
|
+
if (open === this.boardOpen) return
|
|
316
|
+
this.boardOpen = open
|
|
317
|
+
this.notify()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
|
|
321
|
+
private selectPanel(panelId: string | null): void {
|
|
322
|
+
try {
|
|
323
|
+
this.deps.panel?.select(panelId)
|
|
324
|
+
} catch {
|
|
325
|
+
// The layout service throws by contract before its root entry mounts;
|
|
326
|
+
// the state flip above already stands, and the next selection retries.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
272
330
|
/**
|
|
273
331
|
* Switch between the kanban columns and the archive view. Leaving the
|
|
274
332
|
* archive view with an archived task still selected closes the selection —
|
|
@@ -460,8 +518,12 @@ export class BoardController {
|
|
|
460
518
|
* @param patch - fields to change (absent fields keep their current value).
|
|
461
519
|
* @returns true when applied, false when rejected (invalid cron / unknown task).
|
|
462
520
|
*/
|
|
463
|
-
setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
|
|
464
|
-
|
|
521
|
+
setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
|
|
522
|
+
// The Host zone is the fallback a rule with no stored zone follows: the
|
|
523
|
+
// mirrored snapshot carries it, and a controller without a Host mirror
|
|
524
|
+
// (pure client tests) falls back to the process zone.
|
|
525
|
+
const hostTimeZone = this.hostState?.scheduler.timeZone ?? resolveHostTimeZone()
|
|
526
|
+
const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now(), hostTimeZone)
|
|
465
527
|
if (!applied) return false
|
|
466
528
|
if (this.deps.transport !== undefined) {
|
|
467
529
|
void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
|
|
@@ -547,8 +609,8 @@ export class BoardController {
|
|
|
547
609
|
* (background navigation, the Host runner creating and selecting a fresh
|
|
548
610
|
* execution session, settlement, other plugins), so closing on `current`
|
|
549
611
|
* changes would evict the board without the user asking. The board closes
|
|
550
|
-
* only on explicit user navigation:
|
|
551
|
-
*
|
|
612
|
+
* only on explicit user navigation: selecting another panel (the layout owns
|
|
613
|
+
* selection, and `syncPanelSelection` follows it) or the board's own actions
|
|
552
614
|
* (openSession / close). Keeping the hook preserves the subscription
|
|
553
615
|
* contract for future listeners.
|
|
554
616
|
*/
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The family health ledger's degraded-record contract (issues #1528, #1730).
|
|
3
|
+
*
|
|
4
|
+
* A family plugin row that fails during apply is recorded by the dsh-web-all
|
|
5
|
+
* shell as a degraded record; the shell serves the ledger through its
|
|
6
|
+
* loopback-only `GET /api/dsh-web-all/degraded` route because the failed
|
|
7
|
+
* plugin could not register any route of its own — the task board whose ledger
|
|
8
|
+
* lock is held by another DSH process is exactly that case, and without this
|
|
9
|
+
* the panel could only report "the Host API is not mounted".
|
|
10
|
+
*
|
|
11
|
+
* Both halves define this shape: the shell WRITES it (packages/dsh-web-all,
|
|
12
|
+
* src/degraded.ts — the two packages have no import edge, one is bundled into
|
|
13
|
+
* the other's profile) and a plugin's browser half PARSES it. The edits move
|
|
14
|
+
* together.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where a degraded row failed: module import, plugin shape, or fiber start. */
|
|
18
|
+
export type DegradedStage = 'import' | 'shape' | 'start'
|
|
19
|
+
|
|
20
|
+
/** One degraded family row as the shell's health route serves it. */
|
|
21
|
+
export interface DegradedRow {
|
|
22
|
+
/** Real plugin package name from the shell row config. */
|
|
23
|
+
plugin: string
|
|
24
|
+
/** Where the failure happened. */
|
|
25
|
+
stage: DegradedStage
|
|
26
|
+
/** The shell's one-line reason, for display. */
|
|
27
|
+
reason: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one degraded record naming `plugin`, or undefined when that row is not
|
|
32
|
+
* degraded (or the payload does not come from the shell's own shape). A
|
|
33
|
+
* plugin may appear once: the shell keys the ledger by package name and
|
|
34
|
+
* refreshes the record on every new failure.
|
|
35
|
+
* @param body - parsed JSON body of the health route.
|
|
36
|
+
* @param plugin - the real plugin package name to look for.
|
|
37
|
+
* @returns the degraded row, or undefined when the ledger does not name it.
|
|
38
|
+
*/
|
|
39
|
+
export function degradedRowOf(body: unknown, plugin: string): DegradedRow | undefined {
|
|
40
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
41
|
+
const ledger = (body as { ok?: unknown; degraded?: unknown })
|
|
42
|
+
if (ledger.ok !== true || !Array.isArray(ledger.degraded)) return undefined
|
|
43
|
+
for (const entry of ledger.degraded) {
|
|
44
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
45
|
+
const record = entry as { plugin?: unknown; stage?: unknown; reason?: unknown }
|
|
46
|
+
if (record.plugin !== plugin) continue
|
|
47
|
+
const stage = record.stage === 'import' || record.stage === 'shape' || record.stage === 'start' ? record.stage : undefined
|
|
48
|
+
if (stage === undefined) continue
|
|
49
|
+
return { plugin, stage, reason: typeof record.reason === 'string' ? record.reason : '' }
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The ledger owner pid a task-board lock reason names, when it names one. */
|
|
55
|
+
const OWNED_BY_PROCESS_RE = /already owned by process\s+(\d+)/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The marker the ledger appends when it could NOT confirm the recorded PID
|
|
59
|
+
* belongs to the lock's real owner: the PID was reused by an unrelated process
|
|
60
|
+
* after a crash, and the reason then carries the manual-removal step instead.
|
|
61
|
+
* That reason must be rendered verbatim — telling the user to close "the other
|
|
62
|
+
* DSH instance" would send them after a process that never touched the board.
|
|
63
|
+
*/
|
|
64
|
+
const UNCONFIRMED_OWNER_MARKER = 'reused after a crash'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract the pid of the LIVE process a ledger-lock failure blames, when the
|
|
68
|
+
* reason identifies one. The task board's lock refusal is the one degraded
|
|
69
|
+
* reason that carries an owner the user can act on, so the panel renders the
|
|
70
|
+
* pid in its own sentence and adds the guidance that actually applies.
|
|
71
|
+
* @param reason - the shell's one-line failure reason.
|
|
72
|
+
* @returns the confirmed owner pid, or undefined when the reason names none or
|
|
73
|
+
* warns that the PID may have been reused (its own text owns the recovery).
|
|
74
|
+
*/
|
|
75
|
+
export function ledgerOwnerPid(reason: string): number | undefined {
|
|
76
|
+
if (reason.includes(UNCONFIRMED_OWNER_MARKER)) return undefined
|
|
77
|
+
const match = OWNED_BY_PROCESS_RE.exec(reason)
|
|
78
|
+
if (match === null) return undefined
|
|
79
|
+
const pid = Number(match[1])
|
|
80
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
|
|
81
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm identity of this plugin. The dsh-web-all degraded ledger is keyed by the
|
|
3
|
+
* real plugin package name of a family row, and this plugin's browser half
|
|
4
|
+
* must name its OWN row there to learn why its Host API is missing (issue
|
|
5
|
+
* #1730): the served bundle cannot derive the name, because a family row
|
|
6
|
+
* mounts the package from a versioned profile path. The host half spells the
|
|
7
|
+
* same name in its single-mount guard (src/index.ts); the two move together.
|
|
8
|
+
*/
|
|
9
|
+
export const TASK_BOARD_PACKAGE = '@linxin666/dsh-client-ui-task-board'
|
package/src/core/schedule.ts
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* runs. Framework-free and dependency-free so the scheduler
|
|
4
|
-
* share one tiny pure module.
|
|
2
|
+
* Zone-aware 5-field cron parsing and next-run computation for scheduled task
|
|
3
|
+
* runs. Framework-free and dependency-free (Intl only) so the Host scheduler
|
|
4
|
+
* and the browser next-run preview share one tiny pure module.
|
|
5
5
|
*
|
|
6
6
|
* Grammar: five whitespace-separated fields, 分 时 日 月 周. Every field
|
|
7
7
|
* supports the wildcard, step (wildcard or range + "/n"), single value,
|
|
8
8
|
* inclusive range a-b, and comma lists mixing any of those. Ranges: minutes
|
|
9
9
|
* 0-59, hours 0-23, days 1-31, months 1-12, weekdays 0-7 (0 and 7 both mean
|
|
10
|
-
* Sunday).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Sunday). Invalid expressions parse to null and are rejected by the UI and
|
|
11
|
+
* the Host.
|
|
12
|
+
*
|
|
13
|
+
* Day semantics follow Vixie cron: a field is *starred* when its field text
|
|
14
|
+
* starts with `*`. When day-of-month and day-of-week are both restricted the
|
|
15
|
+
* fields combine with OR; otherwise they combine with AND. The distinction
|
|
16
|
+
* matters for a stepped star such as `*/2`, which still restricts the dates it
|
|
17
|
+
* matches, so `0 0 */2 * 1` means "the Mondays that fall on an even day of
|
|
18
|
+
* month" rather than "every even day, or every Monday".
|
|
19
|
+
*
|
|
20
|
+
* Time zone: every candidate wall clock is interpreted in an explicit IANA
|
|
21
|
+
* zone (the schedule's stored zone, or the Host zone when none is given).
|
|
22
|
+
* A wall clock that does not exist (spring-forward gap) is skipped, and an
|
|
23
|
+
* ambiguous wall clock (fall-back overlap) resolves to the earlier instant, so
|
|
24
|
+
* a repeated local minute fires exactly once. The Host-scheduler semantics are
|
|
25
|
+
* byte-identical to `@deepseek-ai/dsh-schedule`'s resolver, which was verified
|
|
26
|
+
* against it across twelve IANA zones (including 30-minute and 45-minute DST
|
|
27
|
+
* offsets and a date-line jump) and 300k decision instants.
|
|
28
|
+
*
|
|
29
|
+
* That upstream is on the experimental track and is NOT a dependency here (no
|
|
30
|
+
* import, no inject, no peer range), so a change to it fails no test. Re-run the
|
|
31
|
+
* differential check on any `dsh-schedule` release or cohort bump that moves it,
|
|
32
|
+
* and keep this comment honest; the re-verification trigger is recorded in
|
|
33
|
+
* [the owning Agent Note](../../../../.agents/notes/implemented/feature/2026-09-29-task-board-schedule-time-zone.md).
|
|
13
34
|
*/
|
|
14
35
|
|
|
15
36
|
/** The parsed match sets of one cron expression. */
|
|
@@ -20,10 +41,10 @@ export interface CronSchedule {
|
|
|
20
41
|
months: ReadonlySet<number>
|
|
21
42
|
/** Weekdays 0-6, 0 = Sunday (input 7 normalized to 0). */
|
|
22
43
|
weekdays: ReadonlySet<number>
|
|
23
|
-
/** Whether the day-of-month field
|
|
24
|
-
|
|
25
|
-
/** Whether the weekday field
|
|
26
|
-
|
|
44
|
+
/** Whether the day-of-month field text starts with `*` (Vixie starred). */
|
|
45
|
+
dayStarred: boolean
|
|
46
|
+
/** Whether the weekday field text starts with `*` (Vixie starred). */
|
|
47
|
+
weekdayStarred: boolean
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
/** Inclusive ranges per field, in cron order. */
|
|
@@ -35,6 +56,141 @@ const FIELD_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
|
35
56
|
[0, 7], // weekdays (7 = Sunday, normalized below)
|
|
36
57
|
]
|
|
37
58
|
|
|
59
|
+
/** Fallback zone when the process reports no usable IANA zone. */
|
|
60
|
+
export const HOST_TIME_ZONE_FALLBACK = 'UTC'
|
|
61
|
+
|
|
62
|
+
/** IANA `Area/Location`, plus the bare `UTC` the platform reports. */
|
|
63
|
+
const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
|
|
64
|
+
|
|
65
|
+
/** One resolved wall-clock reading in some zone. */
|
|
66
|
+
export interface ZonedParts {
|
|
67
|
+
year: number
|
|
68
|
+
month: number
|
|
69
|
+
day: number
|
|
70
|
+
hour: number
|
|
71
|
+
minute: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Cached formatters: constructing an Intl formatter dominates the cost of a resolve. */
|
|
75
|
+
const FORMATTERS = new Map<string, Intl.DateTimeFormat>()
|
|
76
|
+
|
|
77
|
+
function zoneFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
78
|
+
let formatter = FORMATTERS.get(timeZone)
|
|
79
|
+
if (formatter === undefined) {
|
|
80
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
81
|
+
timeZone,
|
|
82
|
+
hourCycle: 'h23',
|
|
83
|
+
year: 'numeric',
|
|
84
|
+
month: '2-digit',
|
|
85
|
+
day: '2-digit',
|
|
86
|
+
hour: '2-digit',
|
|
87
|
+
minute: '2-digit',
|
|
88
|
+
})
|
|
89
|
+
FORMATTERS.set(timeZone, formatter)
|
|
90
|
+
}
|
|
91
|
+
return formatter
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The wall-clock reading of one instant in an explicit zone. Reading through
|
|
96
|
+
* Intl (rather than the process `Date` accessors) is what makes the result
|
|
97
|
+
* independent of the Host's own TZ.
|
|
98
|
+
* @param timeZone - explicit IANA zone.
|
|
99
|
+
* @param epochMs - instant to read.
|
|
100
|
+
* @returns the zone-local calendar and clock fields.
|
|
101
|
+
*/
|
|
102
|
+
export function partsInZone(timeZone: string, epochMs: number): ZonedParts {
|
|
103
|
+
const fields: Record<string, string> = {}
|
|
104
|
+
for (const part of zoneFormatter(timeZone).formatToParts(new Date(epochMs))) {
|
|
105
|
+
if (part.type !== 'literal') fields[part.type] = part.value
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
year: Number(fields.year),
|
|
109
|
+
month: Number(fields.month),
|
|
110
|
+
day: Number(fields.day),
|
|
111
|
+
hour: Number(fields.hour),
|
|
112
|
+
minute: Number(fields.minute),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The zone's UTC offset in effect at one instant, in milliseconds. */
|
|
117
|
+
function offsetAt(timeZone: string, epochMs: number): number {
|
|
118
|
+
const parts = partsInZone(timeZone, epochMs)
|
|
119
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - epochMs
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a wall clock in an explicit zone to its instant.
|
|
124
|
+
*
|
|
125
|
+
* The offset is probed a day either side of the naive guess so both DST
|
|
126
|
+
* transitions are covered, then every candidate offset whose inverse maps back
|
|
127
|
+
* to the requested wall clock is kept. No survivor means the wall clock does
|
|
128
|
+
* not exist (a spring-forward gap); several means it is ambiguous (a fall-back
|
|
129
|
+
* overlap), and the earliest instant wins so the repeated clock is taken once.
|
|
130
|
+
* @param timeZone - explicit IANA zone.
|
|
131
|
+
* @param year - full calendar year.
|
|
132
|
+
* @param month - 1-12.
|
|
133
|
+
* @param day - day of month.
|
|
134
|
+
* @param hour - 0-23.
|
|
135
|
+
* @param minute - 0-59.
|
|
136
|
+
* @returns the instant in ms epoch, or undefined when the wall clock is skipped.
|
|
137
|
+
*/
|
|
138
|
+
export function zonedEpoch(
|
|
139
|
+
timeZone: string,
|
|
140
|
+
year: number,
|
|
141
|
+
month: number,
|
|
142
|
+
day: number,
|
|
143
|
+
hour: number,
|
|
144
|
+
minute: number,
|
|
145
|
+
): number | undefined {
|
|
146
|
+
const naive = Date.UTC(year, month - 1, day, hour, minute)
|
|
147
|
+
const offsets = new Set([
|
|
148
|
+
offsetAt(timeZone, naive - 86_400_000),
|
|
149
|
+
offsetAt(timeZone, naive),
|
|
150
|
+
offsetAt(timeZone, naive + 86_400_000),
|
|
151
|
+
])
|
|
152
|
+
let earliest: number | undefined
|
|
153
|
+
for (const offset of offsets) {
|
|
154
|
+
const candidate = naive - offset
|
|
155
|
+
const parts = partsInZone(timeZone, candidate)
|
|
156
|
+
if (parts.year !== year || parts.month !== month || parts.day !== day) continue
|
|
157
|
+
if (parts.hour !== hour || parts.minute !== minute) continue
|
|
158
|
+
if (earliest === undefined || candidate < earliest) earliest = candidate
|
|
159
|
+
}
|
|
160
|
+
return earliest
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The Host process's IANA zone, used for schedules that store no zone of their
|
|
165
|
+
* own (a ledger written before zones were persisted).
|
|
166
|
+
* @returns a zone name Intl accepts, never empty.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveHostTimeZone(): string {
|
|
169
|
+
try {
|
|
170
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
171
|
+
return typeof zone === 'string' && zone !== '' ? zone : HOST_TIME_ZONE_FALLBACK
|
|
172
|
+
} catch {
|
|
173
|
+
return HOST_TIME_ZONE_FALLBACK
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a value names a zone this runtime can actually resolve. A stored
|
|
179
|
+
* zone is trusted only after it round-trips through Intl, so a typo or a zone
|
|
180
|
+
* that this ICU build does not know can never arm a schedule.
|
|
181
|
+
* @param value - candidate zone name.
|
|
182
|
+
* @returns true when the name is usable.
|
|
183
|
+
*/
|
|
184
|
+
export function isValidTimeZone(value: string): boolean {
|
|
185
|
+
if (value.trim() !== value || value === '') return false
|
|
186
|
+
if (value !== 'UTC' && !IANA_ZONE.test(value)) return false
|
|
187
|
+
try {
|
|
188
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone !== undefined
|
|
189
|
+
} catch {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
38
194
|
/**
|
|
39
195
|
* Parse a 5-field cron expression.
|
|
40
196
|
* @returns the match sets, or null when the expression is invalid.
|
|
@@ -57,11 +213,11 @@ export function parseCron(expr: string): CronSchedule | null {
|
|
|
57
213
|
days: sets[2],
|
|
58
214
|
months: sets[3],
|
|
59
215
|
weekdays,
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
216
|
+
// Vixie selects the day branch from the field TEXT: only a field starting
|
|
217
|
+
// with '*' is starred. A written-out range such as '1-31' is restricted and
|
|
218
|
+
// still participates in the OR/AND choice.
|
|
219
|
+
dayStarred: fields[2].startsWith('*'),
|
|
220
|
+
weekdayStarred: fields[4].startsWith('*'),
|
|
65
221
|
}
|
|
66
222
|
}
|
|
67
223
|
|
|
@@ -71,57 +227,61 @@ export function isValidCron(expr: string): boolean {
|
|
|
71
227
|
}
|
|
72
228
|
|
|
73
229
|
/**
|
|
74
|
-
* Compute the next matching instant after `fromMs`
|
|
75
|
-
*
|
|
76
|
-
* of the matching minute's start, or undefined
|
|
77
|
-
* can never match (for example `0 0 30 2 *`).
|
|
78
|
-
* a full leap cycle, so a valid February 29
|
|
79
|
-
* every non-leap year.
|
|
230
|
+
* Compute the next matching instant after `fromMs` strictly greater than it,
|
|
231
|
+
* interpreting the expression's wall clock in `timeZone` (the Host zone when
|
|
232
|
+
* omitted). Returns the ms epoch of the matching minute's start, or undefined
|
|
233
|
+
* when the calendar constraint can never match (for example `0 0 30 2 *`).
|
|
234
|
+
* The five-year horizon includes a full leap cycle, so a valid February 29
|
|
235
|
+
* schedule remains reachable from every non-leap year.
|
|
80
236
|
*
|
|
81
237
|
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
82
238
|
* field sets instead of scanning every minute: a sparse expression such as
|
|
83
239
|
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
84
|
-
* next leap day.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
240
|
+
* next leap day. A candidate wall clock that the zone skips (spring-forward
|
|
241
|
+
* gap) is passed over, which is what makes the gap semantics fall out of the
|
|
242
|
+
* candidate walk rather than needing a separate rule.
|
|
243
|
+
* @param expr - 5-field cron expression.
|
|
244
|
+
* @param fromMs - exclusive lower bound, ms epoch.
|
|
245
|
+
* @param timeZone - explicit IANA zone; defaults to the Host zone.
|
|
246
|
+
* @returns the next trigger instant, or undefined when unreachable.
|
|
87
247
|
*/
|
|
88
|
-
export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
248
|
+
export function nextRunAtMs(expr: string, fromMs: number, timeZone?: string): number | undefined {
|
|
89
249
|
const schedule = parseCron(expr)
|
|
90
250
|
if (schedule === null) return undefined
|
|
91
251
|
if (!hasPossibleCalendarDay(schedule)) return undefined
|
|
92
|
-
const
|
|
252
|
+
const zone = timeZone ?? resolveHostTimeZone()
|
|
253
|
+
const start = partsInZone(zone, fromMs)
|
|
93
254
|
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
|
|
94
255
|
|
|
95
256
|
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
|
|
96
257
|
const sortedHours = [...schedule.hours].sort((a, b) => a - b)
|
|
97
258
|
const sortedMonths = [...schedule.months].sort((a, b) => a - b)
|
|
98
259
|
|
|
99
|
-
let year =
|
|
100
|
-
let month =
|
|
101
|
-
let day =
|
|
102
|
-
let hour =
|
|
103
|
-
// Strictly after fromMs: the
|
|
104
|
-
let minute =
|
|
260
|
+
let year = start.year
|
|
261
|
+
let month = start.month
|
|
262
|
+
let day = start.day
|
|
263
|
+
let hour = start.hour
|
|
264
|
+
// Strictly after fromMs: the walk starts from the next minute.
|
|
265
|
+
let minute = start.minute + 1
|
|
105
266
|
|
|
106
|
-
while (
|
|
267
|
+
while (Date.UTC(year, month - 1, 1) <= limitMs + 86_400_000) {
|
|
107
268
|
for (const candidateMonth of sortedMonths) {
|
|
108
269
|
if (candidateMonth < month) continue
|
|
109
|
-
const daysInMonth = new Date(year, candidateMonth, 0).
|
|
270
|
+
const daysInMonth = new Date(Date.UTC(year, candidateMonth, 0)).getUTCDate()
|
|
110
271
|
const dayStart = candidateMonth === month ? day : 1
|
|
111
272
|
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
112
|
-
|
|
113
|
-
if (!dayCandidate(schedule, dayProbe)) continue
|
|
273
|
+
if (!dayCandidate(schedule, year, candidateMonth, candidateDay)) continue
|
|
114
274
|
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
|
|
115
275
|
for (const candidateHour of sortedHours) {
|
|
116
276
|
if (candidateHour < hourStart) continue
|
|
117
277
|
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
|
|
118
278
|
for (const candidateMinute of sortedMinutes) {
|
|
119
279
|
if (candidateMinute < minuteStart) continue
|
|
120
|
-
const
|
|
121
|
-
|
|
280
|
+
const time = zonedEpoch(zone, year, candidateMonth, candidateDay, candidateHour, candidateMinute)
|
|
281
|
+
if (time === undefined) continue
|
|
122
282
|
if (time <= fromMs) continue
|
|
123
283
|
if (time > limitMs) return undefined
|
|
124
|
-
|
|
284
|
+
return time
|
|
125
285
|
}
|
|
126
286
|
}
|
|
127
287
|
}
|
|
@@ -135,18 +295,22 @@ export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
|
135
295
|
return undefined
|
|
136
296
|
}
|
|
137
297
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
298
|
+
/**
|
|
299
|
+
* The Vixie day gate shared by the candidate walk. Both fields restricted is
|
|
300
|
+
* OR; any other combination is AND, because a starred field matches every
|
|
301
|
+
* value and so cannot widen the result.
|
|
302
|
+
*/
|
|
303
|
+
function dayCandidate(schedule: CronSchedule, year: number, month: number, day: number): boolean {
|
|
304
|
+
const dayMatches = schedule.days.has(day)
|
|
305
|
+
// Day of week is a property of the calendar date, so it is zone-independent.
|
|
306
|
+
const weekdayMatches = schedule.weekdays.has(new Date(Date.UTC(year, month - 1, day)).getUTCDay())
|
|
307
|
+
if (!schedule.dayStarred && !schedule.weekdayStarred) return dayMatches || weekdayMatches
|
|
308
|
+
return dayMatches && weekdayMatches
|
|
145
309
|
}
|
|
146
310
|
|
|
147
311
|
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
148
312
|
function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
|
|
149
|
-
if (schedule.
|
|
313
|
+
if (schedule.dayStarred || !schedule.weekdayStarred) return true
|
|
150
314
|
const maximumDay = new Map<number, number>([
|
|
151
315
|
[1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
|
|
152
316
|
[7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
|
|
@@ -194,14 +358,6 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
|
|
|
194
358
|
return true
|
|
195
359
|
}
|
|
196
360
|
|
|
197
|
-
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
198
|
-
function matches(schedule: CronSchedule, date: Date): boolean {
|
|
199
|
-
if (!schedule.minutes.has(date.getMinutes())) return false
|
|
200
|
-
if (!schedule.hours.has(date.getHours())) return false
|
|
201
|
-
if (!schedule.months.has(date.getMonth() + 1)) return false
|
|
202
|
-
return dayCandidate(schedule, date)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
361
|
function isDigits(value: string): boolean {
|
|
206
362
|
return /^\d+$/.test(value)
|
|
207
363
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* channel later); tests run against the in-memory backend and a jsdom
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
|
-
import { isValidCron } from './schedule.ts'
|
|
12
|
+
import { isValidCron, isValidTimeZone } from './schedule.ts'
|
|
13
13
|
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import { isExecutionOutcome } from './subtask.ts'
|
|
15
15
|
import type { TaskHandover } from './handover.ts'
|
|
@@ -68,6 +68,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
68
68
|
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
69
69
|
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
70
70
|
if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
|
|
71
|
+
if (record.goalRun !== undefined && typeof record.goalRun !== 'boolean') return false
|
|
71
72
|
if (!Array.isArray(record.executions)) return false
|
|
72
73
|
for (const execution of record.executions) {
|
|
73
74
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -108,9 +109,14 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
|
108
109
|
// schedule instead of being dropped for later repair.
|
|
109
110
|
if (typeof rule.cron !== 'string') return undefined
|
|
110
111
|
if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
|
|
112
|
+
// A stored zone survives only when this runtime can resolve it; an unusable
|
|
113
|
+
// name is dropped (the rule then follows the Host zone) instead of being
|
|
114
|
+
// kept to fail every later resolve.
|
|
115
|
+
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
111
116
|
return {
|
|
112
117
|
enabled: rule.enabled === true,
|
|
113
118
|
cron: rule.cron,
|
|
119
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
114
120
|
nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
|
|
115
121
|
lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
|
|
116
122
|
}
|
|
@@ -202,6 +208,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
202
208
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
203
209
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
204
210
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
211
|
+
// The goal opt-in is stored only when it is OFF: absent means the default
|
|
212
|
+
// (start each run with /goal), so a hand-edited true normalizes back to it.
|
|
213
|
+
task.goalRun = row.goalRun === false ? false : undefined
|
|
205
214
|
task.freeze = normalizeFreeze(row.freeze)
|
|
206
215
|
task.handover = normalizeHandover(row.handover)
|
|
207
216
|
// Tags are repaired field by field like the schedule: a malformed entry is
|