@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.
@@ -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. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
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
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
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
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
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
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
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 writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
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
- let landed = true
222
- for (const write of writes) {
223
- landed = await write() && landed
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 = !landed
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
 
@@ -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
- createTask, settleExecution, startExecution, withSchedule, withStatus,
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 title = input.title.trim()
175
- if (title === '') return undefined
176
- const task = createTask(input, this.now(), this.uuid())
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.map(task => task.id === id
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
- this.tasks = this.tasks.filter(task => task.id !== id)
196
- if (this.selectedTaskId === id) this.selectedTaskId = undefined
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 task = this.tasks.find(candidate => candidate.id === id)
213
- if (task === undefined) return false
214
- const current = task.schedule
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 = this.tasks.map(task =>
232
- task.id === id && task.schedule !== undefined
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
+
@@ -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
- /** Start ticking: one immediate check (catch-up after reload) + the interval. */
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?.addEventListener('visibilitychange', this.onVisibility)
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
- if (this.timer !== undefined) clearInterval(this.timer)
69
- this.timer = undefined
70
- this.deps.environment?.removeEventListener('visibilitychange', this.onVisibility)
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
+ }