@linxin666/dsh-client-ui-task-board 0.1.14 → 0.1.16
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/lib/client.js +368 -160
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/board/TaskCard.d.ts +4 -5
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/core/controller.d.ts +6 -1
- package/lib/types/core/scheduler.d.ts +13 -2
- package/lib/types/core/use-cases/task-create.d.ts +22 -0
- package/lib/types/core/use-cases/task-delete.d.ts +21 -0
- package/lib/types/core/use-cases/task-schedule.d.ts +34 -0
- package/lib/types/core/use-cases/task-update.d.ts +17 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/board/TaskBoard.tsx +14 -6
- package/src/client/board/TaskCard.tsx +10 -1
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/core/controller.ts +25 -26
- package/src/core/scheduler.ts +39 -7
- package/src/core/use-cases/task-create.ts +33 -0
- package/src/core/use-cases/task-delete.ts +33 -0
- package/src/core/use-cases/task-schedule.ts +74 -0
- package/src/core/use-cases/task-update.ts +26 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
3
|
* Staged form model behind the plugin settings card. A card stages what the
|
|
3
4
|
* user types and writes it only when they save — the settings write is a
|
|
4
5
|
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
5
|
-
* screen exactly what a save would store.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* screen exactly what a save would store. Family-shared slice inlined into
|
|
7
|
+
* each plugin's client bundle; mirrors the official ui-plugin-config
|
|
8
|
+
* card-store pattern.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
@@ -19,6 +20,14 @@ export type FieldWrite =
|
|
|
19
20
|
export interface FieldSpec {
|
|
20
21
|
/** Field name inside the namespace section. */
|
|
21
22
|
field: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
|
+
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
+
* never compared against the draft on save; the field lands when the scope
|
|
27
|
+
* reports the write succeeded (its secret-set marker under the bridge), so
|
|
28
|
+
* a successful secret save is not misreported as failed.
|
|
29
|
+
*/
|
|
30
|
+
secret?: boolean
|
|
22
31
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
23
32
|
format: (value: unknown) => string
|
|
24
33
|
/**
|
|
@@ -60,6 +69,12 @@ export interface CardShell {
|
|
|
60
69
|
saving: boolean
|
|
61
70
|
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
62
71
|
failed: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The rejection code/message the Host returned for the last failed save,
|
|
74
|
+
* surfaced next to the generic failure text. Undefined while no save has
|
|
75
|
+
* failed (or the failure carried no server reason).
|
|
76
|
+
*/
|
|
77
|
+
failedReason?: string
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/** The write actions the card's slot entry injects. */
|
|
@@ -86,12 +101,63 @@ interface StagedEdit {
|
|
|
86
101
|
interface PlannedWrite {
|
|
87
102
|
/** Field this entry writes. */
|
|
88
103
|
field: string
|
|
104
|
+
/** The durable write this entry performs, described for a batched scope. */
|
|
105
|
+
op: BatchedWrite
|
|
89
106
|
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
90
107
|
run: (() => Promise<boolean>) | undefined
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
/**
|
|
94
|
-
export
|
|
110
|
+
/** One durable write a batched settings scope performs. */
|
|
111
|
+
export interface BatchedWrite {
|
|
112
|
+
/** Field this entry writes. */
|
|
113
|
+
field: string
|
|
114
|
+
/** set stores a value; unset drops the leaf. */
|
|
115
|
+
op: 'set' | 'unset'
|
|
116
|
+
/** Value for op set (absent for unset). */
|
|
117
|
+
value?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Per-field outcome of one batched scope write. */
|
|
121
|
+
export interface BatchedFieldResult {
|
|
122
|
+
/** Field this entry writes. */
|
|
123
|
+
field: string
|
|
124
|
+
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
+
landed: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
+
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
+
* batched refusal fails the whole save rather than per-field.
|
|
132
|
+
*/
|
|
133
|
+
export interface BatchResult {
|
|
134
|
+
/** Whether the whole mutate was accepted. */
|
|
135
|
+
ok: boolean
|
|
136
|
+
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
+
fields: BatchedFieldResult[]
|
|
138
|
+
/** Host rejection code (mutate refused). */
|
|
139
|
+
code?: string
|
|
140
|
+
/** Host rejection message (mutate refused). */
|
|
141
|
+
message?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
+
interface BatchedSettingsScope {
|
|
146
|
+
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
+
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
|
+
export interface NumberConstraints {
|
|
152
|
+
/** The accepted value must be a whole number. */
|
|
153
|
+
integer?: boolean
|
|
154
|
+
/** The accepted value must be at least this. */
|
|
155
|
+
min?: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
|
|
159
|
+
export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
|
|
160
|
+
const { integer = false, min } = constraints
|
|
95
161
|
return {
|
|
96
162
|
field,
|
|
97
163
|
format: value => typeof value === 'number' ? String(value) : '',
|
|
@@ -99,7 +165,10 @@ export function numberField(field: string): FieldSpec {
|
|
|
99
165
|
const trimmed = text.trim()
|
|
100
166
|
if (trimmed === '') return { kind: 'clear' }
|
|
101
167
|
const parsed = Number(trimmed)
|
|
102
|
-
|
|
168
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
169
|
+
if (integer && !Number.isInteger(parsed)) return undefined
|
|
170
|
+
if (min !== undefined && parsed < min) return undefined
|
|
171
|
+
return { kind: 'set', value: parsed }
|
|
103
172
|
},
|
|
104
173
|
}
|
|
105
174
|
}
|
|
@@ -116,19 +185,43 @@ export function textField(field: string): FieldSpec {
|
|
|
116
185
|
}
|
|
117
186
|
}
|
|
118
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
192
|
+
* reporting the write landed.
|
|
193
|
+
*/
|
|
194
|
+
export function secretField(field: string): FieldSpec {
|
|
195
|
+
return { ...textField(field), secret: true }
|
|
196
|
+
}
|
|
197
|
+
|
|
119
198
|
/** A boolean field, edited through true/false draft text. */
|
|
120
199
|
export function booleanField(field: string): FieldSpec {
|
|
121
200
|
return {
|
|
122
201
|
field,
|
|
123
202
|
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
124
203
|
parse: (text) => {
|
|
125
|
-
|
|
126
|
-
if (
|
|
204
|
+
const trimmed = text.trim()
|
|
205
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
206
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
207
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
127
208
|
return undefined
|
|
128
209
|
},
|
|
129
210
|
}
|
|
130
211
|
}
|
|
131
212
|
|
|
213
|
+
/** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
|
|
214
|
+
export function choiceField(field: string, choices: readonly string[]): FieldSpec {
|
|
215
|
+
return {
|
|
216
|
+
field,
|
|
217
|
+
format: value => typeof value === 'string' && choices.includes(value) ? value : '',
|
|
218
|
+
parse: (text) => {
|
|
219
|
+
if (text === '') return { kind: 'clear' }
|
|
220
|
+
return choices.includes(text) ? { kind: 'set', value: text } : undefined
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
132
225
|
/**
|
|
133
226
|
* Stages one card's edits over one settings namespace and writes them on save.
|
|
134
227
|
*
|
|
@@ -143,6 +236,7 @@ export class CardForm<T> {
|
|
|
143
236
|
private readonly listeners = new Set<() => void>()
|
|
144
237
|
private saving = false
|
|
145
238
|
private failed = false
|
|
239
|
+
private failedReason: string | undefined
|
|
146
240
|
|
|
147
241
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
148
242
|
constructor(
|
|
@@ -172,6 +266,7 @@ export class CardForm<T> {
|
|
|
172
266
|
invalid: plan.some(item => item.run === undefined),
|
|
173
267
|
saving: this.saving,
|
|
174
268
|
failed: this.failed,
|
|
269
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
175
270
|
}
|
|
176
271
|
}
|
|
177
272
|
|
|
@@ -202,6 +297,7 @@ export class CardForm<T> {
|
|
|
202
297
|
if (this.staged.size === 0 && !this.failed) return
|
|
203
298
|
this.staged.clear()
|
|
204
299
|
this.failed = false
|
|
300
|
+
this.failedReason = undefined
|
|
205
301
|
this.publish()
|
|
206
302
|
},
|
|
207
303
|
}
|
|
@@ -209,25 +305,57 @@ export class CardForm<T> {
|
|
|
209
305
|
|
|
210
306
|
/**
|
|
211
307
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
308
|
+
*
|
|
309
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
310
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
311
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
312
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
313
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
314
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
212
315
|
* @returns settlement after every write and the read-back.
|
|
213
316
|
*/
|
|
214
317
|
async save(): Promise<void> {
|
|
215
318
|
const plan = this.plan()
|
|
216
|
-
const
|
|
217
|
-
if (plan.length === 0 || this.saving ||
|
|
319
|
+
const valid = plan.filter(item => item.run !== undefined)
|
|
320
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
321
|
+
const plannedWrites = valid.map(item => item.op)
|
|
322
|
+
// Snapshot the fields this save writes, so edits staged while it is in
|
|
323
|
+
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
324
|
+
const fields = new Set(plan.map(item => item.field))
|
|
218
325
|
this.saving = true
|
|
219
326
|
this.failed = false
|
|
327
|
+
this.failedReason = undefined
|
|
220
328
|
this.publish()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
329
|
+
const landed = new Set<string>()
|
|
330
|
+
const batch = this.batchedScope()
|
|
331
|
+
if (batch !== undefined) {
|
|
332
|
+
const result = await batch.mutate(plannedWrites)
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
for (const field of result.fields) {
|
|
335
|
+
if (field.landed) landed.add(field.field)
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
this.failedReason = result.message
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
for (const item of valid) {
|
|
342
|
+
if (await item.run!()) landed.add(item.field)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const field of fields) {
|
|
346
|
+
if (landed.has(field)) this.staged.delete(field)
|
|
224
347
|
}
|
|
225
|
-
if (landed) this.staged.clear()
|
|
226
348
|
this.saving = false
|
|
227
|
-
this.failed =
|
|
349
|
+
this.failed = landed.size !== fields.size
|
|
228
350
|
this.publish()
|
|
229
351
|
}
|
|
230
352
|
|
|
353
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
354
|
+
private batchedScope(): BatchedSettingsScope | undefined {
|
|
355
|
+
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
356
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
231
359
|
/**
|
|
232
360
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
233
361
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -240,14 +368,14 @@ export class CardForm<T> {
|
|
|
240
368
|
for (const [field, staged] of this.staged) {
|
|
241
369
|
const spec = this.specOf(field)
|
|
242
370
|
if (staged.clear) {
|
|
243
|
-
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
371
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
244
372
|
continue
|
|
245
373
|
}
|
|
246
374
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
247
375
|
const write = spec.parse(staged.text)
|
|
248
|
-
if (write === undefined) plan.push({ field, run: undefined })
|
|
249
|
-
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
250
|
-
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
376
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
|
|
377
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
378
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
|
|
251
379
|
}
|
|
252
380
|
return plan
|
|
253
381
|
}
|
|
@@ -259,12 +387,18 @@ export class CardForm<T> {
|
|
|
259
387
|
|
|
260
388
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
261
389
|
await this.scope.set(field, value)
|
|
390
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
391
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
392
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
393
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
394
|
+
if (this.specOf(field).secret) return true
|
|
262
395
|
return this.userLayer()?.[field] === value
|
|
263
396
|
}
|
|
264
397
|
|
|
265
398
|
private stage(field: string, edit: StagedEdit): void {
|
|
266
399
|
this.staged.set(field, edit)
|
|
267
400
|
this.failed = false
|
|
401
|
+
this.failedReason = undefined
|
|
268
402
|
this.publish()
|
|
269
403
|
}
|
|
270
404
|
|
package/src/core/controller.ts
CHANGED
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
* navigates to a session (the sessions-list `current` selection changes).
|
|
8
8
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
9
9
|
* unit-testable with fakes.
|
|
10
|
+
*
|
|
11
|
+
* The per use-case domain transitions (create/update/delete/schedule) live in
|
|
12
|
+
* dedicated modules under core/use-cases and are applied here; the controller
|
|
13
|
+
* owns only the orchestration seam (state, persistence, notify, execution,
|
|
14
|
+
* navigation, reconciliation).
|
|
10
15
|
*/
|
|
11
16
|
import { ExecutionService, type ExecutionEvent } from './execution.ts'
|
|
12
|
-
import { isValidCron, nextRunAtMs } from './schedule.ts'
|
|
13
17
|
import type { TaskStore } from './store.ts'
|
|
14
18
|
import {
|
|
15
|
-
|
|
19
|
+
settleExecution, startExecution, withStatus,
|
|
16
20
|
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
17
21
|
} from './tasks.ts'
|
|
22
|
+
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
|
+
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
|
+
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
25
|
+
import { applyUpdateTask } from './use-cases/task-update.ts'
|
|
18
26
|
|
|
19
27
|
/** The sessions face the controller needs for navigation awareness. */
|
|
20
28
|
export interface SessionsControllerFace {
|
|
@@ -168,21 +176,18 @@ export class BoardController {
|
|
|
168
176
|
this.notify()
|
|
169
177
|
}
|
|
170
178
|
|
|
171
|
-
// --- task mutations
|
|
179
|
+
// --- task mutations (use-case transitions in core/use-cases) -----------------
|
|
172
180
|
|
|
173
181
|
createTask(input: NewTaskInput): TaskRecord | undefined {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
this.tasks = [...this.tasks, task]
|
|
182
|
+
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid())
|
|
183
|
+
if (task === undefined) return undefined
|
|
184
|
+
this.tasks = [...tasks]
|
|
178
185
|
this.persistAndNotify()
|
|
179
186
|
return task
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
|
|
183
|
-
this.tasks = this.tasks
|
|
184
|
-
? { ...task, ...patch, updatedAt: this.now() }
|
|
185
|
-
: task)
|
|
190
|
+
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
186
191
|
this.persistAndNotify()
|
|
187
192
|
}
|
|
188
193
|
|
|
@@ -192,8 +197,9 @@ export class BoardController {
|
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
deleteTask(id: string): void {
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id)
|
|
201
|
+
this.tasks = [...tasks]
|
|
202
|
+
if (selectionCleared) this.selectedTaskId = undefined
|
|
197
203
|
this.persistAndNotify()
|
|
198
204
|
}
|
|
199
205
|
|
|
@@ -203,21 +209,15 @@ export class BoardController {
|
|
|
203
209
|
* Update a task's schedule rule. A blank or invalid cron expression is
|
|
204
210
|
* rejected (returns false, state untouched). When the rule ends up enabled
|
|
205
211
|
* the next run instant is computed immediately; a disabled rule carries no
|
|
206
|
-
* next-run instant.
|
|
212
|
+
* next-run instant. Delegates the domain transition to the schedule use case.
|
|
207
213
|
* @param id - the task to schedule.
|
|
208
214
|
* @param patch - fields to change (absent fields keep their current value).
|
|
209
215
|
* @returns true when applied, false when rejected (invalid cron / unknown task).
|
|
210
216
|
*/
|
|
211
217
|
setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
216
|
-
if (cron === '' || !isValidCron(cron)) return false
|
|
217
|
-
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
218
|
-
const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : undefined
|
|
219
|
-
this.tasks = this.tasks.map(candidate =>
|
|
220
|
-
candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, this.now()) : candidate)
|
|
218
|
+
const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
|
|
219
|
+
if (!applied) return false
|
|
220
|
+
this.tasks = [...tasks]
|
|
221
221
|
this.persistAndNotify()
|
|
222
222
|
return true
|
|
223
223
|
}
|
|
@@ -228,10 +228,8 @@ export class BoardController {
|
|
|
228
228
|
* schedule rule (it was deleted mid-tick, for example).
|
|
229
229
|
*/
|
|
230
230
|
applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
|
|
231
|
-
this.tasks
|
|
232
|
-
|
|
233
|
-
? withSchedule(task, { nextRunAt, lastTriggeredAt }, this.now())
|
|
234
|
-
: task)
|
|
231
|
+
const next = applyScheduleRollForward(this.tasks, id, nextRunAt, lastTriggeredAt, this.now())
|
|
232
|
+
this.tasks = [...next]
|
|
235
233
|
this.persistAndNotify()
|
|
236
234
|
}
|
|
237
235
|
|
|
@@ -392,3 +390,4 @@ function attachSessionId(
|
|
|
392
390
|
execution.id === executionId ? { ...execution, sessionId } : execution),
|
|
393
391
|
}
|
|
394
392
|
}
|
|
393
|
+
|
package/src/core/scheduler.ts
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* a task still running at its due instant is skipped by the controller's
|
|
10
10
|
* runTask guard and simply waits for the next cron match.
|
|
11
11
|
*
|
|
12
|
+
* The ticker is controlled: `start` arms a single interval guarded by an
|
|
13
|
+
* idempotence check (a second start while running is a no-op, so wiring can
|
|
14
|
+
* never leak a duplicate timer), and `stop`/`dispose` clear the timer and
|
|
15
|
+
* drop the environment listeners. The board unmount path calls stop/dispose,
|
|
16
|
+
* so no interval or listener outlives the board.
|
|
17
|
+
*
|
|
12
18
|
* Framework-free: all runtime access flows through the injected deps
|
|
13
19
|
* (structural faces), so tests drive ticks directly without timers.
|
|
14
20
|
*/
|
|
@@ -47,27 +53,54 @@ export interface SchedulerDeps {
|
|
|
47
53
|
*/
|
|
48
54
|
export class SchedulerService {
|
|
49
55
|
private timer: ReturnType<typeof setInterval> | undefined
|
|
56
|
+
private environmentListener: (() => void) | undefined
|
|
50
57
|
private disposed = false
|
|
58
|
+
private started = false
|
|
51
59
|
|
|
52
60
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
53
61
|
constructor(private readonly deps: SchedulerDeps) {}
|
|
54
62
|
|
|
55
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Start ticking: one immediate check (catch-up after reload) + the interval.
|
|
65
|
+
* Single-instance: a second start while already armed is a no-op, so a
|
|
66
|
+
* re-entrant mount can never stack a duplicate timer.
|
|
67
|
+
*/
|
|
56
68
|
start(): void {
|
|
57
69
|
if (this.disposed) return
|
|
70
|
+
if (this.started) return
|
|
71
|
+
this.started = true
|
|
58
72
|
// Immediate catch-up tick: schedules whose due instant passed while the
|
|
59
73
|
// tab was closed are triggered as soon as the runtime is ready.
|
|
60
74
|
this.tick()
|
|
61
75
|
this.timer = setInterval(() => { this.tick() }, this.deps.tickMs ?? 60_000)
|
|
62
|
-
this.deps.environment
|
|
76
|
+
if (this.deps.environment !== undefined) {
|
|
77
|
+
this.environmentListener = () => { this.tick() }
|
|
78
|
+
this.deps.environment.addEventListener('visibilitychange', this.environmentListener)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
|
|
84
|
+
* callers that treat the scheduler as a controlled ticker; `dispose` is an
|
|
85
|
+
* alias.
|
|
86
|
+
*/
|
|
87
|
+
stop(): void {
|
|
88
|
+
this.dispose()
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
/** Stop ticking and drop listeners (idempotent). */
|
|
66
92
|
dispose(): void {
|
|
93
|
+
if (this.disposed && this.timer === undefined && this.environmentListener === undefined) return
|
|
67
94
|
this.disposed = true
|
|
68
|
-
|
|
69
|
-
this.timer
|
|
70
|
-
|
|
95
|
+
this.started = false
|
|
96
|
+
if (this.timer !== undefined) {
|
|
97
|
+
clearInterval(this.timer)
|
|
98
|
+
this.timer = undefined
|
|
99
|
+
}
|
|
100
|
+
if (this.environmentListener !== undefined && this.deps.environment !== undefined) {
|
|
101
|
+
this.deps.environment.removeEventListener('visibilitychange', this.environmentListener)
|
|
102
|
+
this.environmentListener = undefined
|
|
103
|
+
}
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
/**
|
|
@@ -99,6 +132,5 @@ export class SchedulerService {
|
|
|
99
132
|
if (accepted) this.deps.applySchedule(task.id, next, now)
|
|
100
133
|
}
|
|
101
134
|
}
|
|
102
|
-
|
|
103
|
-
private readonly onVisibility = (): void => { this.tick() }
|
|
104
135
|
}
|
|
136
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create-task use case: mint a new task from user input, rejecting a blank
|
|
3
|
+
* title. Pure ledger transition (no persistence or notify — the controller
|
|
4
|
+
* orchestrates those), so it is unit-testable without any runtime face.
|
|
5
|
+
*/
|
|
6
|
+
import { createTask, type NewTaskInput, type TaskRecord } from '../tasks.ts'
|
|
7
|
+
|
|
8
|
+
/** Result of a create transition: the new task (when accepted) + the next ledger. */
|
|
9
|
+
export interface CreateTaskResult {
|
|
10
|
+
/** The minted task, or undefined when the input was rejected (blank title). */
|
|
11
|
+
task: TaskRecord | undefined
|
|
12
|
+
/** The next ledger; identical reference when rejected. */
|
|
13
|
+
tasks: readonly TaskRecord[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply a create against the current ledger. Returns the new task and the
|
|
18
|
+
* appended ledger, or the unchanged ledger when the title is blank.
|
|
19
|
+
* @param tasks - current ledger.
|
|
20
|
+
* @param input - raw user input (title/description/prompt).
|
|
21
|
+
* @param now - clock instant (ms epoch).
|
|
22
|
+
* @param id - minted task id.
|
|
23
|
+
*/
|
|
24
|
+
export function applyCreateTask(
|
|
25
|
+
tasks: readonly TaskRecord[],
|
|
26
|
+
input: NewTaskInput,
|
|
27
|
+
now: number,
|
|
28
|
+
id: string,
|
|
29
|
+
): CreateTaskResult {
|
|
30
|
+
if (input.title.trim() === '') return { task: undefined, tasks }
|
|
31
|
+
const task = createTask(input, now, id)
|
|
32
|
+
return { task, tasks: [...tasks, task] }
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete-task use case: drop a task from the ledger and clear the selection
|
|
3
|
+
* when it pointed at the removed task. Pure ledger transition (no persistence
|
|
4
|
+
* or notify — the controller orchestrates those).
|
|
5
|
+
*/
|
|
6
|
+
import type { TaskRecord } from '../tasks.ts'
|
|
7
|
+
|
|
8
|
+
/** Result of a delete transition. */
|
|
9
|
+
export interface DeleteTaskResult {
|
|
10
|
+
/** The next ledger (the removed task absent). */
|
|
11
|
+
tasks: readonly TaskRecord[]
|
|
12
|
+
/** Whether the previous selection referenced the removed task. */
|
|
13
|
+
selectionCleared: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply a delete across the ledger. The selection (a task id) is cleared when
|
|
18
|
+
* it matches the removed task, so the UI never lingers on a vanished detail.
|
|
19
|
+
* @param tasks - current ledger.
|
|
20
|
+
* @param selectedTaskId - the currently selected task id (may be undefined).
|
|
21
|
+
* @param id - the task to remove.
|
|
22
|
+
*/
|
|
23
|
+
export function applyDeleteTask(
|
|
24
|
+
tasks: readonly TaskRecord[],
|
|
25
|
+
selectedTaskId: string | undefined,
|
|
26
|
+
id: string,
|
|
27
|
+
): DeleteTaskResult {
|
|
28
|
+
const next = tasks.filter(task => task.id !== id)
|
|
29
|
+
return {
|
|
30
|
+
tasks: next,
|
|
31
|
+
selectionCleared: selectedTaskId === id,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
|
|
3
|
+
* Pure ledger transitions (no persistence or notify — the controller
|
|
4
|
+
* orchestrates those). Validation and next-run computation live here, sharing
|
|
5
|
+
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
6
|
+
*/
|
|
7
|
+
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
8
|
+
import { withSchedule, type TaskRecord } from '../tasks.ts'
|
|
9
|
+
|
|
10
|
+
/** Fields the schedule use case may change on a rule. */
|
|
11
|
+
export interface SetSchedulePatch {
|
|
12
|
+
enabled?: boolean
|
|
13
|
+
cron?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Result of arming/disarming a rule. */
|
|
17
|
+
export interface SetScheduleResult {
|
|
18
|
+
/** The next ledger; unchanged reference when rejected. */
|
|
19
|
+
tasks: readonly TaskRecord[]
|
|
20
|
+
/** Whether the rule was applied (false on unknown task / invalid cron). */
|
|
21
|
+
applied: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set a task's schedule rule. A blank or invalid cron is rejected (state
|
|
26
|
+
* untouched); an enabled rule computes the next run instant immediately, a
|
|
27
|
+
* disabled or invalid one carries no next-run instant.
|
|
28
|
+
* @param tasks - current ledger.
|
|
29
|
+
* @param id - the task to schedule.
|
|
30
|
+
* @param patch - rule fields to change (absent fields keep their current value).
|
|
31
|
+
* @param now - clock instant (ms epoch).
|
|
32
|
+
*/
|
|
33
|
+
export function applySetSchedule(
|
|
34
|
+
tasks: readonly TaskRecord[],
|
|
35
|
+
id: string,
|
|
36
|
+
patch: SetSchedulePatch,
|
|
37
|
+
now: number,
|
|
38
|
+
): SetScheduleResult {
|
|
39
|
+
const task = tasks.find(candidate => candidate.id === id)
|
|
40
|
+
if (task === undefined) return { tasks, applied: false }
|
|
41
|
+
const current = task.schedule
|
|
42
|
+
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
43
|
+
if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
|
|
44
|
+
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
45
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
|
|
46
|
+
return {
|
|
47
|
+
tasks: tasks.map(candidate =>
|
|
48
|
+
candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
|
|
49
|
+
applied: true,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Roll a task's schedule rule forward (scheduler callback): persist the next
|
|
55
|
+
* due instant and the trigger instant. No-op for tasks without a rule (deleted
|
|
56
|
+
* mid-tick, for example).
|
|
57
|
+
* @param tasks - current ledger.
|
|
58
|
+
* @param id - the task to roll forward.
|
|
59
|
+
* @param nextRunAt - next due instant (may be undefined to clear).
|
|
60
|
+
* @param lastTriggeredAt - the trigger instant of this run.
|
|
61
|
+
* @param now - clock instant (ms epoch).
|
|
62
|
+
*/
|
|
63
|
+
export function applyScheduleNextRun(
|
|
64
|
+
tasks: readonly TaskRecord[],
|
|
65
|
+
id: string,
|
|
66
|
+
nextRunAt: number | undefined,
|
|
67
|
+
lastTriggeredAt: number | undefined,
|
|
68
|
+
now: number,
|
|
69
|
+
): readonly TaskRecord[] {
|
|
70
|
+
return tasks.map(task =>
|
|
71
|
+
task.id === id && task.schedule !== undefined
|
|
72
|
+
? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
|
|
73
|
+
: task)
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-task use case: apply an editable-field patch (title/description/
|
|
3
|
+
* prompt) with a fresh updatedAt. Pure ledger transition (no persistence or
|
|
4
|
+
* notify — the controller orchestrates those).
|
|
5
|
+
*/
|
|
6
|
+
import type { TaskRecord } from '../tasks.ts'
|
|
7
|
+
|
|
8
|
+
/** Editable fields on a task (the update patch surface). */
|
|
9
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply an update across the ledger. Tasks that do not match the id are left
|
|
13
|
+
* untouched; the matched task receives the patch plus a fresh updatedAt.
|
|
14
|
+
* @param tasks - current ledger.
|
|
15
|
+
* @param id - the task to update.
|
|
16
|
+
* @param patch - editable-field changes.
|
|
17
|
+
* @param now - clock instant (ms epoch).
|
|
18
|
+
*/
|
|
19
|
+
export function applyUpdateTask(
|
|
20
|
+
tasks: readonly TaskRecord[],
|
|
21
|
+
id: string,
|
|
22
|
+
patch: TaskUpdatePatch,
|
|
23
|
+
now: number,
|
|
24
|
+
): readonly TaskRecord[] {
|
|
25
|
+
return tasks.map(task => task.id === id ? { ...task, ...patch, updatedAt: now } : task)
|
|
26
|
+
}
|