@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/protocol.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
|
|
2
|
+
import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
3
|
+
import { parseLedger } from './core/store.ts'
|
|
4
|
+
|
|
5
|
+
export const TASK_BOARD_SCHEMA_VERSION = 2 as const
|
|
6
|
+
export const TASK_BOARD_API_PREFIX = '/api/task-board'
|
|
7
|
+
|
|
8
|
+
export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
|
|
9
|
+
|
|
10
|
+
export interface TaskBoardPowerSnapshot {
|
|
11
|
+
platform: string
|
|
12
|
+
phase: PowerPhase
|
|
13
|
+
enabled: boolean
|
|
14
|
+
runningSessions: number
|
|
15
|
+
armedSchedules: number
|
|
16
|
+
sessionStateKnown: boolean
|
|
17
|
+
lastError?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskBoardSchedulerSnapshot {
|
|
21
|
+
timeZone: string
|
|
22
|
+
/** Opaque identity of the current Host ledger generation. */
|
|
23
|
+
ledgerId?: string
|
|
24
|
+
lastTickAt?: number
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TaskBoardSnapshot {
|
|
29
|
+
schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
|
|
30
|
+
revision: number
|
|
31
|
+
tasks: TaskRecord[]
|
|
32
|
+
scheduler: TaskBoardSchedulerSnapshot
|
|
33
|
+
power: TaskBoardPowerSnapshot
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** SSE event frame: revision/scheduler/power only, never the task list. */
|
|
37
|
+
export interface TaskBoardEventPayload {
|
|
38
|
+
revision: number
|
|
39
|
+
scheduler: TaskBoardSchedulerSnapshot
|
|
40
|
+
power: TaskBoardPowerSnapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TaskBoardAction =
|
|
44
|
+
| { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
|
|
45
|
+
| { kind: 'create'; id: string; input: NewTaskInput }
|
|
46
|
+
| { kind: 'update'; taskId: string; patch: TaskUpdatePatch }
|
|
47
|
+
| { kind: 'delete'; taskId: string }
|
|
48
|
+
| { kind: 'move'; taskId: string; status: TaskStatus }
|
|
49
|
+
| { kind: 'archive'; taskId: string }
|
|
50
|
+
| { kind: 'restore'; taskId: string }
|
|
51
|
+
| { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string } }
|
|
52
|
+
| { kind: 'run'; taskId: string }
|
|
53
|
+
| { kind: 'rerun'; taskId: string }
|
|
54
|
+
|
|
55
|
+
export interface TaskBoardActionEnvelope {
|
|
56
|
+
requestId: string
|
|
57
|
+
action: TaskBoardAction
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
? value as Record<string, unknown>
|
|
63
|
+
: undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
67
|
+
return Object.keys(value).every(key => allowed.includes(key))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function optionalString(value: unknown): boolean {
|
|
71
|
+
return value === undefined || typeof value === 'string'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const FORBIDDEN_IMPORT_FIELDS = new Set(['args', 'command', 'executable', 'powershell', 'shell'])
|
|
75
|
+
|
|
76
|
+
function hasForbiddenImportField(value: unknown): boolean {
|
|
77
|
+
if (Array.isArray(value)) return value.some(hasForbiddenImportField)
|
|
78
|
+
const row = record(value)
|
|
79
|
+
if (row === undefined) return false
|
|
80
|
+
return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenImportField(nested))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function optionalFiniteNumber(value: unknown): boolean {
|
|
84
|
+
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
88
|
+
if (value.schedule !== undefined) {
|
|
89
|
+
const schedule = record(value.schedule)
|
|
90
|
+
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
91
|
+
if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
|
|
92
|
+
}
|
|
93
|
+
if (value.executions !== undefined) {
|
|
94
|
+
if (!Array.isArray(value.executions)) return false
|
|
95
|
+
for (const item of value.executions) {
|
|
96
|
+
const execution = record(item)
|
|
97
|
+
if (execution === undefined || typeof execution.id !== 'string' || !optionalString(execution.sessionId)) return false
|
|
98
|
+
if (typeof execution.startedAt !== 'number' || !Number.isFinite(execution.startedAt)) return false
|
|
99
|
+
if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false
|
|
100
|
+
if (execution.result !== undefined && !['succeeded', 'failed', 'cancelled'].includes(String(execution.result))) return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function importedTask(value: unknown): TaskRecord | undefined {
|
|
107
|
+
const input = record(value)
|
|
108
|
+
if (input === undefined || hasForbiddenImportField(input) || !validImportedKnownFields(input)) return undefined
|
|
109
|
+
const task = parseLedger(JSON.stringify([value]))[0]
|
|
110
|
+
if (task === undefined) return undefined
|
|
111
|
+
return {
|
|
112
|
+
id: task.id,
|
|
113
|
+
title: task.title,
|
|
114
|
+
description: task.description,
|
|
115
|
+
prompt: task.prompt,
|
|
116
|
+
status: task.status,
|
|
117
|
+
createdAt: task.createdAt,
|
|
118
|
+
updatedAt: task.updatedAt,
|
|
119
|
+
executions: task.executions.map(execution => ({
|
|
120
|
+
id: execution.id,
|
|
121
|
+
sessionId: execution.sessionId,
|
|
122
|
+
startedAt: execution.startedAt,
|
|
123
|
+
endedAt: execution.endedAt,
|
|
124
|
+
result: execution.result,
|
|
125
|
+
error: execution.error,
|
|
126
|
+
})),
|
|
127
|
+
...(task.schedule === undefined ? {} : {
|
|
128
|
+
schedule: {
|
|
129
|
+
enabled: task.schedule.enabled,
|
|
130
|
+
cron: task.schedule.cron,
|
|
131
|
+
nextRunAt: task.schedule.nextRunAt,
|
|
132
|
+
lastTriggeredAt: task.schedule.lastTriggeredAt,
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
|
|
136
|
+
...(task.mode === undefined ? {} : { mode: task.mode }),
|
|
137
|
+
...(task.permission === undefined ? {} : { permission: task.permission }),
|
|
138
|
+
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createInput(value: unknown): value is NewTaskInput {
|
|
143
|
+
const input = record(value)
|
|
144
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule'])) return false
|
|
145
|
+
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
146
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false
|
|
147
|
+
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
148
|
+
if (input.schedule !== undefined) {
|
|
149
|
+
const schedule = record(input.schedule)
|
|
150
|
+
if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
|
|
151
|
+
if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
152
|
+
}
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function updatePatch(value: unknown): boolean {
|
|
157
|
+
const patch = record(value)
|
|
158
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission'])) return false
|
|
159
|
+
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode'] as const) {
|
|
160
|
+
if (!optionalString(patch[key])) return false
|
|
161
|
+
}
|
|
162
|
+
return patch.permission === undefined || isTaskPermission(patch.permission)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function schedulePatch(value: unknown): boolean {
|
|
166
|
+
const patch = record(value)
|
|
167
|
+
return patch !== undefined
|
|
168
|
+
&& exactKeys(patch, ['enabled', 'cron'])
|
|
169
|
+
&& (patch.enabled === undefined || typeof patch.enabled === 'boolean')
|
|
170
|
+
&& (patch.cron === undefined || typeof patch.cron === 'string')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
|
|
174
|
+
const envelope = record(value)
|
|
175
|
+
if (envelope === undefined || !exactKeys(envelope, ['requestId', 'action'])) return undefined
|
|
176
|
+
if (typeof envelope.requestId !== 'string' || envelope.requestId.trim() === '' || envelope.requestId.length > 256) return undefined
|
|
177
|
+
const action = record(envelope.action)
|
|
178
|
+
if (action === undefined || typeof action.kind !== 'string') return undefined
|
|
179
|
+
const taskId = typeof action.taskId === 'string' && action.taskId !== '' ? action.taskId : undefined
|
|
180
|
+
switch (action.kind) {
|
|
181
|
+
case 'import':
|
|
182
|
+
if (!exactKeys(action, ['kind', 'sourceId', 'tasks'])) return undefined
|
|
183
|
+
if (typeof action.sourceId !== 'string' || action.sourceId === '' || !Array.isArray(action.tasks)) return undefined
|
|
184
|
+
{
|
|
185
|
+
const tasks = action.tasks.map(importedTask)
|
|
186
|
+
return tasks.every((task): task is TaskRecord => task !== undefined)
|
|
187
|
+
? { requestId: envelope.requestId, action: { kind: 'import', sourceId: action.sourceId, tasks } }
|
|
188
|
+
: undefined
|
|
189
|
+
}
|
|
190
|
+
case 'create':
|
|
191
|
+
if (!exactKeys(action, ['kind', 'id', 'input'])) return undefined
|
|
192
|
+
return typeof action.id === 'string' && action.id !== '' && createInput(action.input)
|
|
193
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'create' }> }
|
|
194
|
+
: undefined
|
|
195
|
+
case 'update':
|
|
196
|
+
if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
|
|
197
|
+
return taskId !== undefined && updatePatch(action.patch)
|
|
198
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'update' }> }
|
|
199
|
+
: undefined
|
|
200
|
+
case 'set-schedule':
|
|
201
|
+
if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
|
|
202
|
+
return taskId !== undefined && schedulePatch(action.patch)
|
|
203
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'set-schedule' }> }
|
|
204
|
+
: undefined
|
|
205
|
+
case 'move':
|
|
206
|
+
if (!exactKeys(action, ['kind', 'taskId', 'status'])) return undefined
|
|
207
|
+
return taskId !== undefined && isTaskStatus(action.status)
|
|
208
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'move' }> }
|
|
209
|
+
: undefined
|
|
210
|
+
case 'delete':
|
|
211
|
+
case 'archive':
|
|
212
|
+
case 'restore':
|
|
213
|
+
case 'run':
|
|
214
|
+
case 'rerun':
|
|
215
|
+
if (!exactKeys(action, ['kind', 'taskId'])) return undefined
|
|
216
|
+
return taskId === undefined ? undefined : { requestId: envelope.requestId, action: action as TaskBoardAction }
|
|
217
|
+
default:
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { TaskRecord } from './tasks.ts';
|
|
2
|
-
/** Everything the scheduler needs from its host (the board controller). */
|
|
3
|
-
export interface SchedulerDeps {
|
|
4
|
-
/** Read the current task ledger (the controller snapshot). */
|
|
5
|
-
tasks(): readonly TaskRecord[];
|
|
6
|
-
/**
|
|
7
|
-
* Re-sync the task ledger from its persisted source before the tick decides
|
|
8
|
-
* what is due. The wiring passes the controller's store reload so a task
|
|
9
|
-
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
10
|
-
* Optional: absent in pure in-memory harnesses.
|
|
11
|
-
*/
|
|
12
|
-
refresh?: () => void;
|
|
13
|
-
/** Clock; defaults to Date.now in the controller wiring. */
|
|
14
|
-
now(): number;
|
|
15
|
-
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
16
|
-
* true when the run was accepted, false when rejected (e.g. the task is
|
|
17
|
-
* already running). */
|
|
18
|
-
runTask(id: string): Promise<boolean>;
|
|
19
|
-
/** Persist a rolled-forward schedule (next due + this trigger instant). */
|
|
20
|
-
applySchedule(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void;
|
|
21
|
-
/** Tick cadence; defaults to 60_000 ms. */
|
|
22
|
-
tickMs?: number;
|
|
23
|
-
/**
|
|
24
|
-
* Gate: while false the tick no-ops (e.g. the session list baseline has not
|
|
25
|
-
* arrived on page load, so executions would fail). Defaults to always ready.
|
|
26
|
-
*/
|
|
27
|
-
ready?: () => boolean;
|
|
28
|
-
/** Environment listeners for tab-visibility recovery (browser only). */
|
|
29
|
-
environment?: {
|
|
30
|
-
addEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
31
|
-
removeEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* The schedule heartbeat (see module doc). `tick` is public so tests and
|
|
36
|
-
* callers can drive a check without waiting for the interval.
|
|
37
|
-
*/
|
|
38
|
-
export declare class SchedulerService {
|
|
39
|
-
private readonly deps;
|
|
40
|
-
private timer;
|
|
41
|
-
private environmentListener;
|
|
42
|
-
private disposed;
|
|
43
|
-
private started;
|
|
44
|
-
/** Cron expressions that matched no instant in the scan window, per task —
|
|
45
|
-
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
46
|
-
private readonly unmatchableRepair;
|
|
47
|
-
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
48
|
-
constructor(deps: SchedulerDeps);
|
|
49
|
-
/**
|
|
50
|
-
* Start ticking: one immediate check (catch-up after reload) + the interval.
|
|
51
|
-
* Single-instance: a second start while already armed is a no-op, so a
|
|
52
|
-
* re-entrant mount can never stack a duplicate timer.
|
|
53
|
-
*/
|
|
54
|
-
start(): void;
|
|
55
|
-
/**
|
|
56
|
-
* Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
|
|
57
|
-
* callers that treat the scheduler as a controlled ticker; `dispose` is an
|
|
58
|
-
* alias.
|
|
59
|
-
*/
|
|
60
|
-
stop(): void;
|
|
61
|
-
/** Stop ticking and drop listeners (idempotent). */
|
|
62
|
-
dispose(): void;
|
|
63
|
-
/**
|
|
64
|
-
* Check every enabled schedule and trigger the due ones. Idempotent per
|
|
65
|
-
* task per tick: the schedule is rolled forward only after runTask accepts
|
|
66
|
-
* the run, so a rejected run keeps its due slot and is retried on the next
|
|
67
|
-
* tick instead of being silently dropped.
|
|
68
|
-
*/
|
|
69
|
-
tick(): Promise<void>;
|
|
70
|
-
}
|
package/src/core/scheduler.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
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
|
-
* A deleted task can never fire again from a stale in-memory copy: every
|
|
13
|
-
* tick first re-reads the persisted ledger through the optional `refresh`
|
|
14
|
-
* hook (the controller reloads its store), so the fire decision and the
|
|
15
|
-
* subsequent roll-forward both run on the freshest persisted truth.
|
|
16
|
-
*
|
|
17
|
-
* The ticker is controlled: `start` arms a single interval guarded by an
|
|
18
|
-
* idempotence check (a second start while running is a no-op, so wiring can
|
|
19
|
-
* never leak a duplicate timer), and `stop`/`dispose` clear the timer and
|
|
20
|
-
* drop the environment listeners. The board unmount path calls stop/dispose,
|
|
21
|
-
* so no interval or listener outlives the board.
|
|
22
|
-
*
|
|
23
|
-
* Framework-free: all runtime access flows through the injected deps
|
|
24
|
-
* (structural faces), so tests drive ticks directly without timers.
|
|
25
|
-
*/
|
|
26
|
-
import { nextRunAtMs } from './schedule.ts'
|
|
27
|
-
import type { TaskRecord } from './tasks.ts'
|
|
28
|
-
|
|
29
|
-
/** Everything the scheduler needs from its host (the board controller). */
|
|
30
|
-
export interface SchedulerDeps {
|
|
31
|
-
/** Read the current task ledger (the controller snapshot). */
|
|
32
|
-
tasks(): readonly TaskRecord[]
|
|
33
|
-
/**
|
|
34
|
-
* Re-sync the task ledger from its persisted source before the tick decides
|
|
35
|
-
* what is due. The wiring passes the controller's store reload so a task
|
|
36
|
-
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
37
|
-
* Optional: absent in pure in-memory harnesses.
|
|
38
|
-
*/
|
|
39
|
-
refresh?: () => void
|
|
40
|
-
/** Clock; defaults to Date.now in the controller wiring. */
|
|
41
|
-
now(): number
|
|
42
|
-
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
43
|
-
* true when the run was accepted, false when rejected (e.g. the task is
|
|
44
|
-
* already running). */
|
|
45
|
-
runTask(id: string): Promise<boolean>
|
|
46
|
-
/** Persist a rolled-forward schedule (next due + this trigger instant). */
|
|
47
|
-
applySchedule(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void
|
|
48
|
-
/** Tick cadence; defaults to 60_000 ms. */
|
|
49
|
-
tickMs?: number
|
|
50
|
-
/**
|
|
51
|
-
* Gate: while false the tick no-ops (e.g. the session list baseline has not
|
|
52
|
-
* arrived on page load, so executions would fail). Defaults to always ready.
|
|
53
|
-
*/
|
|
54
|
-
ready?: () => boolean
|
|
55
|
-
/** Environment listeners for tab-visibility recovery (browser only). */
|
|
56
|
-
environment?: {
|
|
57
|
-
addEventListener(type: 'visibilitychange', listener: () => void): void
|
|
58
|
-
removeEventListener(type: 'visibilitychange', listener: () => void): void
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The schedule heartbeat (see module doc). `tick` is public so tests and
|
|
64
|
-
* callers can drive a check without waiting for the interval.
|
|
65
|
-
*/
|
|
66
|
-
export class SchedulerService {
|
|
67
|
-
private timer: ReturnType<typeof setInterval> | undefined
|
|
68
|
-
private environmentListener: (() => void) | undefined
|
|
69
|
-
private disposed = false
|
|
70
|
-
private started = false
|
|
71
|
-
/** Cron expressions that matched no instant in the scan window, per task —
|
|
72
|
-
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
73
|
-
private readonly unmatchableRepair = new Map<string, string>()
|
|
74
|
-
|
|
75
|
-
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
76
|
-
constructor(private readonly deps: SchedulerDeps) {}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Start ticking: one immediate check (catch-up after reload) + the interval.
|
|
80
|
-
* Single-instance: a second start while already armed is a no-op, so a
|
|
81
|
-
* re-entrant mount can never stack a duplicate timer.
|
|
82
|
-
*/
|
|
83
|
-
start(): void {
|
|
84
|
-
if (this.disposed) return
|
|
85
|
-
if (this.started) return
|
|
86
|
-
this.started = true
|
|
87
|
-
// Immediate catch-up tick: schedules whose due instant passed while the
|
|
88
|
-
// tab was closed are triggered as soon as the runtime is ready.
|
|
89
|
-
this.tick()
|
|
90
|
-
this.timer = setInterval(() => { this.tick() }, this.deps.tickMs ?? 60_000)
|
|
91
|
-
if (this.deps.environment !== undefined) {
|
|
92
|
-
this.environmentListener = () => { this.tick() }
|
|
93
|
-
this.deps.environment.addEventListener('visibilitychange', this.environmentListener)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
|
|
99
|
-
* callers that treat the scheduler as a controlled ticker; `dispose` is an
|
|
100
|
-
* alias.
|
|
101
|
-
*/
|
|
102
|
-
stop(): void {
|
|
103
|
-
this.dispose()
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Stop ticking and drop listeners (idempotent). */
|
|
107
|
-
dispose(): void {
|
|
108
|
-
if (this.disposed && this.timer === undefined && this.environmentListener === undefined) return
|
|
109
|
-
this.disposed = true
|
|
110
|
-
this.started = false
|
|
111
|
-
if (this.timer !== undefined) {
|
|
112
|
-
clearInterval(this.timer)
|
|
113
|
-
this.timer = undefined
|
|
114
|
-
}
|
|
115
|
-
if (this.environmentListener !== undefined && this.deps.environment !== undefined) {
|
|
116
|
-
this.deps.environment.removeEventListener('visibilitychange', this.environmentListener)
|
|
117
|
-
this.environmentListener = undefined
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Check every enabled schedule and trigger the due ones. Idempotent per
|
|
123
|
-
* task per tick: the schedule is rolled forward only after runTask accepts
|
|
124
|
-
* the run, so a rejected run keeps its due slot and is retried on the next
|
|
125
|
-
* tick instead of being silently dropped.
|
|
126
|
-
*/
|
|
127
|
-
async tick(): Promise<void> {
|
|
128
|
-
if (this.disposed) return
|
|
129
|
-
if (this.deps.ready !== undefined && !this.deps.ready()) return
|
|
130
|
-
// Freshest persisted truth before any fire decision: a task deleted in
|
|
131
|
-
// another tab (or a stale in-memory copy) must never be triggered.
|
|
132
|
-
this.deps.refresh?.()
|
|
133
|
-
const now = this.deps.now()
|
|
134
|
-
for (const task of this.deps.tasks()) {
|
|
135
|
-
const schedule = task.schedule
|
|
136
|
-
if (schedule === undefined || !schedule.enabled) continue
|
|
137
|
-
if (schedule.nextRunAt === undefined) {
|
|
138
|
-
// Missing next-run instant (repaired/legacy data): recompute from the
|
|
139
|
-
// cron expression and wait; an unparseable expression is skipped. A
|
|
140
|
-
// grammatically valid rule with no match in the window (e.g. Feb 30)
|
|
141
|
-
// would otherwise re-run the full minute-by-minute scan every tick.
|
|
142
|
-
if (this.unmatchableRepair.get(task.id) === schedule.cron) continue
|
|
143
|
-
const repaired = nextRunAtMs(schedule.cron, now)
|
|
144
|
-
if (repaired === undefined) {
|
|
145
|
-
this.unmatchableRepair.set(task.id, schedule.cron)
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
this.unmatchableRepair.delete(task.id)
|
|
149
|
-
this.deps.applySchedule(task.id, repaired, undefined)
|
|
150
|
-
continue
|
|
151
|
-
}
|
|
152
|
-
if (schedule.nextRunAt > now) continue
|
|
153
|
-
// Advance from the due instant (not this tick's wall-clock) and only
|
|
154
|
-
// after the run is accepted: a rejected run keeps its due slot.
|
|
155
|
-
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
156
|
-
const accepted = await this.deps.runTask(task.id)
|
|
157
|
-
if (accepted) this.deps.applySchedule(task.id, next, now)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|