@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
@@ -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
- }
@@ -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
-