@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.0

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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +82 -104
  3. package/README.zh.md +82 -104
  4. package/lib/client.js +589 -602
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2187 -6
  7. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  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/board/status-key.d.ts +4 -0
  11. package/lib/types/client/host-api.d.ts +18 -0
  12. package/lib/types/client/locales.d.ts +10 -0
  13. package/lib/types/core/controller.d.ts +40 -15
  14. package/lib/types/core/schedule.d.ts +4 -2
  15. package/lib/types/core/tasks.d.ts +7 -5
  16. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  17. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  18. package/lib/types/dsh-home.d.ts +16 -0
  19. package/lib/types/host-ledger.d.ts +42 -0
  20. package/lib/types/host-routes.d.ts +24 -0
  21. package/lib/types/host-runner.d.ts +30 -0
  22. package/lib/types/host-service.d.ts +41 -0
  23. package/lib/types/index.d.ts +20 -10
  24. package/lib/types/loopback.d.ts +24 -0
  25. package/lib/types/mount-once.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +72 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +10 -0
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +22 -13
  33. package/src/client/board/TaskCard.tsx +24 -8
  34. package/src/client/board/TaskDetail.tsx +84 -59
  35. package/src/client/board/status-key.ts +11 -0
  36. package/src/client/host-api.ts +95 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/core/controller.ts +205 -24
  40. package/src/core/schedule.ts +20 -3
  41. package/src/core/store.ts +5 -14
  42. package/src/core/tasks.ts +7 -7
  43. package/src/core/use-cases/task-archive.ts +14 -5
  44. package/src/core/use-cases/task-schedule.ts +6 -5
  45. package/src/core/use-cases/task-update.ts +1 -6
  46. package/src/dsh-home.ts +36 -0
  47. package/src/host-ledger.ts +478 -0
  48. package/src/host-routes.ts +188 -0
  49. package/src/host-runner.ts +134 -0
  50. package/src/host-service.ts +209 -0
  51. package/src/index.ts +58 -12
  52. package/src/loopback.ts +63 -0
  53. package/src/mount-once.ts +48 -0
  54. package/src/power-inhibitor.ts +321 -0
  55. package/src/protocol.ts +213 -0
  56. package/lib/types/core/scheduler.d.ts +0 -67
  57. package/src/core/scheduler.ts +0 -151
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
- * It keeps the ledger in memory, persists every mutation through the
5
- * {@link TaskStore}, drives real executions through the
6
- * {@link ExecutionService}, and closes the board view whenever the user
7
- * navigates to a session (the sessions-list `current` selection changes).
4
+ * In production it projects the Host ledger and submits confirmed actions;
5
+ * the legacy store/execution seams remain for v1 migration tests. It also
6
+ * closes the board whenever the user navigates to a session.
8
7
  * Framework-free (structural runtime faces) so the whole orchestration is
9
8
  * unit-testable with fakes.
10
9
  *
@@ -13,7 +12,7 @@
13
12
  * owns only the orchestration seam (state, persistence, notify, execution,
14
13
  * navigation, reconciliation).
15
14
  */
16
- import { ExecutionService, type ExecutionEvent } from './execution.ts'
15
+ import type { ExecutionEvent, ExecutionService } from './execution.ts'
17
16
  import type { TaskStore } from './store.ts'
18
17
  import {
19
18
  settleExecution, startExecution, withStatus,
@@ -24,6 +23,14 @@ import { applyCreateTask } from './use-cases/task-create.ts'
24
23
  import { applyDeleteTask } from './use-cases/task-delete.ts'
25
24
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
26
25
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
26
+ import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
27
+
28
+ export interface TaskBoardTransport {
29
+ bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
30
+ state(): Promise<TaskBoardSnapshot>
31
+ action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
32
+ subscribe(listener: () => void): () => void
33
+ }
27
34
 
28
35
  /** The sessions face the controller needs for navigation awareness. */
29
36
  export interface SessionsControllerFace {
@@ -38,7 +45,8 @@ export interface SessionsControllerFace {
38
45
  /** Controller dependencies (all swappable in tests). */
39
46
  export interface ControllerDeps {
40
47
  store: TaskStore
41
- exec: ExecutionService
48
+ /** Legacy browser execution seam used only by isolated v1 tests. */
49
+ exec?: ExecutionService
42
50
  sessions: SessionsControllerFace
43
51
  /** Clock; defaults to Date.now. */
44
52
  now?: () => number
@@ -46,6 +54,8 @@ export interface ControllerDeps {
46
54
  uuid?: () => string
47
55
  /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
48
56
  reconcileDebounceMs?: number
57
+ /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
58
+ transport?: TaskBoardTransport
49
59
  }
50
60
 
51
61
  /** One workspace option the execution-target pickers offer. */
@@ -80,6 +90,9 @@ export interface ControllerSnapshot {
80
90
  selectedTaskId: string | undefined
81
91
  /** Picker option sets (workspace list + agent-preset roster). */
82
92
  executionOptions: ExecutionOptionsSnapshot
93
+ pendingTaskIds: readonly string[]
94
+ transportError?: string
95
+ host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
83
96
  }
84
97
 
85
98
  /** The selected task (resolved from the ledger), or undefined. */
@@ -109,6 +122,10 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
109
122
  return sessions.list.getSnapshot().current
110
123
  }
111
124
 
125
+ function messageOf(error: unknown): string {
126
+ return error instanceof Error ? error.message : String(error)
127
+ }
128
+
112
129
  /**
113
130
  * Board controller (see module doc). All mutations bump the snapshot and
114
131
  * persist through the store; UI and DOM mounts subscribe and re-render.
@@ -123,6 +140,12 @@ export class BoardController {
123
140
  private disposers: Array<() => void> = []
124
141
  private readonly now: () => number
125
142
  private readonly uuid: () => string
143
+ private readonly pendingTaskIds = new Set<string>()
144
+ private readonly taskQueues = new Map<string, Promise<void>>()
145
+ private transportError: string | undefined
146
+ private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
147
+ private remoteSubscribed = false
148
+ private remoteInitialization: Promise<boolean> | undefined
126
149
 
127
150
  /** @param deps - store, execution service, and the sessions navigation face. */
128
151
  constructor(private readonly deps: ControllerDeps) {
@@ -135,15 +158,19 @@ export class BoardController {
135
158
  /** Load the persisted ledger and start the navigation/status subscriptions. */
136
159
  start(): void {
137
160
  this.tasks = this.deps.store.load()
138
- void this.reconcileRunningTasks()
161
+ if (this.deps.transport !== undefined) {
162
+ void this.initializeRemote()
163
+ } else {
164
+ void this.reconcileRunningTasks()
165
+ }
139
166
  // A sibling tab may have edited or deleted the ledger (same origin,
140
167
  // storage events). Reload on external change so a task deleted in
141
168
  // another tab stops firing here — and is never written back by this
142
169
  // tab's stale copy (scheduler roll-forward, execution settlement).
143
- const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
170
+ const unsubscribeExternal = this.deps.transport === undefined ? this.deps.store.subscribeExternal?.(() => {
144
171
  this.tasks = this.deps.store.load()
145
172
  this.notify()
146
- })
173
+ }) : undefined
147
174
  if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
148
175
  this.disposers.push(this.deps.sessions.list.subscribe(() => {
149
176
  this.onSessionsChanged()
@@ -168,6 +195,9 @@ export class BoardController {
168
195
  archiveView: this.archiveView,
169
196
  selectedTaskId: this.selectedTaskId,
170
197
  executionOptions: this.executionOptions,
198
+ pendingTaskIds: [...this.pendingTaskIds],
199
+ ...(this.transportError === undefined ? {} : { transportError: this.transportError }),
200
+ ...(this.hostState === undefined ? {} : { host: this.hostState }),
171
201
  }
172
202
  }
173
203
 
@@ -176,6 +206,16 @@ export class BoardController {
176
206
  return () => { this.listeners.delete(fn) }
177
207
  }
178
208
 
209
+ /** Whether production mutations are confirmed by the Host transport. */
210
+ isHostBacked(): boolean {
211
+ return this.deps.transport !== undefined
212
+ }
213
+
214
+ /** Retry initial migration/state synchronization after an explicit Host error. */
215
+ async retryHostSync(): Promise<boolean> {
216
+ return await this.initializeRemote()
217
+ }
218
+
179
219
  // --- view state -------------------------------------------------------------
180
220
 
181
221
  openBoard(): void {
@@ -229,14 +269,30 @@ export class BoardController {
229
269
  // --- task mutations (use-case transitions in core/use-cases) -----------------
230
270
 
231
271
  createTask(input: NewTaskInput): TaskRecord | undefined {
232
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid())
272
+ const id = this.uuid()
273
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id)
233
274
  if (task === undefined) return undefined
234
275
  this.tasks = [...tasks]
235
276
  this.persistAndNotify()
236
277
  return task
237
278
  }
238
279
 
280
+ /** Create through the Host and expose the task only after confirmation. */
281
+ async createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined> {
282
+ if (this.deps.transport === undefined) return this.createTask(input)
283
+ const id = this.uuid()
284
+ const preview = applyCreateTask(this.tasks, input, this.now(), id).task
285
+ if (preview === undefined) return undefined
286
+ return await this.commitRemote({ kind: 'create', id, input }, id)
287
+ ? this.tasks.find(task => task.id === id)
288
+ : undefined
289
+ }
290
+
239
291
  updateTask(id: string, patch: TaskUpdatePatch): void {
292
+ if (this.deps.transport !== undefined) {
293
+ void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
294
+ return
295
+ }
240
296
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
241
297
  this.persistAndNotify()
242
298
  }
@@ -251,11 +307,19 @@ export class BoardController {
251
307
  }
252
308
 
253
309
  moveTask(id: string, status: TaskStatus): void {
310
+ if (this.deps.transport !== undefined) {
311
+ void this.commitRemote({ kind: 'move', taskId: id, status }, id)
312
+ return
313
+ }
254
314
  this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
255
315
  this.persistAndNotify()
256
316
  }
257
317
 
258
318
  deleteTask(id: string): void {
319
+ if (this.deps.transport !== undefined) {
320
+ void this.commitRemote({ kind: 'delete', taskId: id }, id)
321
+ return
322
+ }
259
323
  const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id)
260
324
  this.tasks = [...tasks]
261
325
  if (selectionCleared) this.selectedTaskId = undefined
@@ -271,6 +335,10 @@ export class BoardController {
271
335
  archiveTask(id: string): boolean {
272
336
  const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
273
337
  if (!archived) return false
338
+ if (this.deps.transport !== undefined) {
339
+ void this.commitRemote({ kind: 'archive', taskId: id }, id)
340
+ return true
341
+ }
274
342
  this.tasks = [...tasks]
275
343
  this.persistAndNotify()
276
344
  return true
@@ -280,7 +348,14 @@ export class BoardController {
280
348
  restoreTask(id: string): boolean {
281
349
  const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
282
350
  if (!archived) return false
351
+ if (this.deps.transport !== undefined) {
352
+ void this.commitRemote({ kind: 'restore', taskId: id }, id).then(restored => {
353
+ if (restored && this.selectedTaskId === id) this.closeTask()
354
+ })
355
+ return true
356
+ }
283
357
  this.tasks = [...tasks]
358
+ if (this.selectedTaskId === id) this.selectedTaskId = undefined
284
359
  this.persistAndNotify()
285
360
  return true
286
361
  }
@@ -299,15 +374,18 @@ export class BoardController {
299
374
  setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
300
375
  const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
301
376
  if (!applied) return false
377
+ if (this.deps.transport !== undefined) {
378
+ void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
379
+ return true
380
+ }
302
381
  this.tasks = [...tasks]
303
382
  this.persistAndNotify()
304
383
  return true
305
384
  }
306
385
 
307
386
  /**
308
- * Roll a task's schedule forward (scheduler callback): persist the next due
309
- * instant and the trigger instant of this run. No-op when the task has no
310
- * schedule rule (it was deleted mid-tick, for example).
387
+ * Legacy pure-controller seam retained for migration-focused tests. The
388
+ * production browser never rolls schedules; the Host ledger owns them.
311
389
  */
312
390
  applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
313
391
  const next = applyScheduleRollForward(this.tasks, id, nextRunAt, lastTriggeredAt, this.now())
@@ -316,12 +394,8 @@ export class BoardController {
316
394
  }
317
395
 
318
396
  /**
319
- * Reload the ledger from the persisted store without notifying subscribers.
320
- * The scheduler calls this before every tick so a task deleted in another
321
- * tab (or a stale in-memory copy) can never be fired from this tab: the
322
- * fire decision and the subsequent roll-forward both run on the freshest
323
- * persisted truth. Deliberately silent — same-origin external changes still
324
- * reach subscribers through the storage-event subscription.
397
+ * Reload the legacy v1 store without notifying subscribers. Production v2
398
+ * reads Host snapshots instead; this remains only for isolated legacy tests.
325
399
  */
326
400
  reloadFromStore(): void {
327
401
  this.tasks = this.deps.store.load()
@@ -345,7 +419,10 @@ export class BoardController {
345
419
  */
346
420
  async runTask(id: string): Promise<boolean> {
347
421
  const task = this.tasks.find(candidate => candidate.id === id)
348
- if (task === undefined || task.status === 'running') return false
422
+ if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
423
+ if (this.deps.transport !== undefined) {
424
+ return await this.commitRemote({ kind: 'run', taskId: id }, id)
425
+ }
349
426
  const { task: next, execution } = startExecution(task, this.now(), this.uuid())
350
427
  this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
351
428
  this.persistAndNotify()
@@ -354,6 +431,7 @@ export class BoardController {
354
431
  // reconciliation must not pre-empt it with a session that has not
355
432
  // started a turn yet (its list row is idle, not completed).
356
433
  this.activeExecutionIds.add(execution.id)
434
+ if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
357
435
  await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
358
436
  return true
359
437
  }
@@ -361,7 +439,11 @@ export class BoardController {
361
439
  /** Re-run a settled task: move it back to 'todo' first, then execute. */
362
440
  async rerunTask(id: string): Promise<void> {
363
441
  const task = this.tasks.find(candidate => candidate.id === id)
364
- if (task === undefined) return
442
+ if (task === undefined || task.archivedAt !== undefined) return
443
+ if (this.deps.transport !== undefined) {
444
+ await this.commitRemote({ kind: 'rerun', taskId: id }, id)
445
+ return
446
+ }
365
447
  if (task.status !== 'running') {
366
448
  this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
367
449
  this.persistAndNotify()
@@ -392,7 +474,7 @@ export class BoardController {
392
474
  // conversation snapshots stay cold until opened). Coalesce the burst of
393
475
  // list notifications into one reconcile pass instead of fanning out a
394
476
  // history read per notification; see scheduleReconcile.
395
- this.scheduleReconcile()
477
+ if (this.deps.transport === undefined) this.scheduleReconcile()
396
478
  if (!this.boardOpen) return
397
479
  const current = currentOf(this.deps.sessions)
398
480
  if (current !== this.lastCurrent) this.closeBoard()
@@ -426,6 +508,7 @@ export class BoardController {
426
508
 
427
509
  /** Settle tasks left 'running' whose sessions already finished. */
428
510
  private async reconcileRunningTasks(): Promise<void> {
511
+ if (this.deps.exec === undefined) return
429
512
  if (this.reconcileInFlight) return
430
513
  this.reconcileInFlight = true
431
514
  try {
@@ -461,8 +544,107 @@ export class BoardController {
461
544
  }
462
545
 
463
546
  private persistAndNotify(): void {
464
- this.deps.store.save(this.tasks)
547
+ if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
548
+ this.notify()
549
+ }
550
+
551
+ private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
552
+ const transport = this.deps.transport
553
+ if (transport === undefined) return true
554
+ if (taskId === undefined) return await this.performRemote(action)
555
+ const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
556
+ const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
557
+ const tail = operation.then(() => {}, () => {})
558
+ this.taskQueues.set(taskId, tail)
559
+ this.pendingTaskIds.add(taskId)
560
+ this.notify()
561
+ try {
562
+ return await operation
563
+ } finally {
564
+ if (this.taskQueues.get(taskId) === tail) {
565
+ this.taskQueues.delete(taskId)
566
+ this.pendingTaskIds.delete(taskId)
567
+ this.notify()
568
+ }
569
+ }
570
+ }
571
+
572
+ private async performRemote(action: TaskBoardAction): Promise<boolean> {
573
+ const transport = this.deps.transport
574
+ if (transport === undefined) return true
575
+ this.transportError = undefined
576
+ this.notify()
577
+ try {
578
+ const accepted = this.acceptRemote(await transport.action(action))
579
+ return accepted || await this.refreshRemote()
580
+ } catch (error) {
581
+ await this.refreshRemote(messageOf(error))
582
+ return false
583
+ }
584
+ }
585
+
586
+ private async initializeRemote(): Promise<boolean> {
587
+ if (this.remoteInitialization !== undefined) return await this.remoteInitialization
588
+ const initialization = this.doInitializeRemote()
589
+ this.remoteInitialization = initialization
590
+ try {
591
+ return await initialization
592
+ } finally {
593
+ if (this.remoteInitialization === initialization) this.remoteInitialization = undefined
594
+ }
595
+ }
596
+
597
+ private async doInitializeRemote(): Promise<boolean> {
598
+ const transport = this.deps.transport
599
+ if (transport === undefined) return true
600
+ try {
601
+ this.acceptRemote(await transport.bootstrap(this.tasks))
602
+ if (!this.remoteSubscribed) {
603
+ this.remoteSubscribed = true
604
+ this.disposers.push(transport.subscribe(() => { void this.refreshRemote() }))
605
+ }
606
+ return true
607
+ } catch (error) {
608
+ this.transportError = messageOf(error)
609
+ this.notify()
610
+ return false
611
+ }
612
+ }
613
+
614
+ private async refreshRemote(preserveError?: string): Promise<boolean> {
615
+ const transport = this.deps.transport
616
+ if (transport === undefined) return true
617
+ try {
618
+ this.acceptRemote(await transport.state())
619
+ if (preserveError !== undefined) {
620
+ this.transportError = preserveError
621
+ this.notify()
622
+ }
623
+ return true
624
+ } catch (error) {
625
+ this.transportError = preserveError ?? messageOf(error)
626
+ this.notify()
627
+ return false
628
+ }
629
+ }
630
+
631
+ private acceptRemote(snapshot: TaskBoardSnapshot): boolean {
632
+ const currentLedgerId = this.hostState?.scheduler.ledgerId
633
+ const nextLedgerId = snapshot.scheduler.ledgerId
634
+ const sameGeneration = currentLedgerId === nextLedgerId
635
+ if (sameGeneration && this.hostState !== undefined && snapshot.revision < this.hostState.revision) return false
636
+ this.tasks = [...snapshot.tasks]
637
+ this.hostState = { revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power }
638
+ this.transportError = undefined
639
+ if (this.selectedTaskId !== undefined && !this.tasks.some(task => task.id === this.selectedTaskId)) {
640
+ this.selectedTaskId = undefined
641
+ }
642
+ if (!this.archiveView && this.selectedTaskId !== undefined
643
+ && this.tasks.find(task => task.id === this.selectedTaskId)?.archivedAt !== undefined) {
644
+ this.selectedTaskId = undefined
645
+ }
465
646
  this.notify()
647
+ return true
466
648
  }
467
649
 
468
650
  private notify(): void {
@@ -484,4 +666,3 @@ function attachSessionId(
484
666
  execution.id === executionId ? { ...execution, sessionId } : execution),
485
667
  }
486
668
  }
487
-
@@ -73,16 +73,19 @@ export function isValidCron(expr: string): boolean {
73
73
  /**
74
74
  * Compute the next matching instant after `fromMs` (ms epoch), in local time,
75
75
  * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
76
- * of the matching minute's start, or undefined when nothing matches within
77
- * 366 days (e.g. `0 0 30 2 *`).
76
+ * of the matching minute's start, or undefined when the calendar constraint
77
+ * can never match (for example `0 0 30 2 *`). The five-year horizon includes
78
+ * a full leap cycle, so a valid February 29 schedule remains reachable from
79
+ * every non-leap year.
78
80
  */
79
81
  export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
80
82
  const schedule = parseCron(expr)
81
83
  if (schedule === null) return undefined
84
+ if (!hasPossibleCalendarDay(schedule)) return undefined
82
85
  const from = new Date(fromMs)
83
86
  // Scan from the next minute; Date rolls overflow (Feb 30 → Mar 2) for free.
84
87
  const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0)
85
- const limitMs = fromMs + 366 * 24 * 60 * 60 * 1000
88
+ const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
86
89
  while (scan.getTime() <= limitMs) {
87
90
  if (matches(schedule, scan)) return scan.getTime()
88
91
  scan.setMinutes(scan.getMinutes() + 1)
@@ -90,6 +93,20 @@ export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
90
93
  return undefined
91
94
  }
92
95
 
96
+ /** Reject impossible month/day pairs without spending the multi-year scan. */
97
+ function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
98
+ if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
99
+ const maximumDay = new Map<number, number>([
100
+ [1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
101
+ [7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
102
+ ])
103
+ for (const month of schedule.months) {
104
+ const maximum = maximumDay.get(month) ?? 0
105
+ if ([...schedule.days].some(day => day <= maximum)) return true
106
+ }
107
+ return false
108
+ }
109
+
93
110
  /** Parse one comma-list field into the match set. */
94
111
  function parseField(field: string, min: number, max: number, out: Set<number>): boolean {
95
112
  if (field === '*') {
package/src/core/store.ts CHANGED
@@ -1,20 +1,16 @@
1
1
  /**
2
- * Task persistence: a small storage seam with a localStorage backend.
2
+ * Legacy v1 browser persistence and the store seam used by pure client tests.
3
3
  *
4
- * The task-board client plugin runs in the browser, and dsh exposes no
5
- * browser-writable file channel (same conclusion the skin-center research
6
- * reached for cordis.patch.yml), so tasks persist in the browser's
7
- * localStorage under a versioned key — the same persistence mechanism dsh's
8
- * own client snapshot stores use (`createSnapshotStore` persist). Data
9
- * survives page refreshes and dsh restarts (same origin), and survives
10
- * plugin uninstall (the key is simply left in place).
4
+ * Production v2 state is Host-authoritative. This backend is retained only to
5
+ * read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
6
+ * so it remains a read-only rollback copy after migration.
11
7
  *
12
8
  * The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
13
9
  * channel later); tests run against the in-memory backend and a jsdom
14
10
  * localStorage backend.
15
11
  */
16
12
  import { isValidCron } from './schedule.ts'
17
- import { isTaskPermission, isTaskStatus, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
13
+ import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
18
14
 
19
15
  /** Persistence seam for the task ledger. */
20
16
  export interface TaskStore {
@@ -80,11 +76,6 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
80
76
  return true
81
77
  }
82
78
 
83
- /** Collapse a blank persisted target string to undefined (clears the pin). */
84
- function normalizeTargetId(value: string | undefined): string | undefined {
85
- return value !== undefined && value.trim() === '' ? undefined : value
86
- }
87
-
88
79
  /** A task record is structurally valid if it round-trips through the UI. */
89
80
  export function isTaskRecord(value: unknown): value is TaskRecord {
90
81
  return isTaskRecordShape(value) && isTaskStatus(value.status)
package/src/core/tasks.ts CHANGED
@@ -29,9 +29,8 @@ export interface ExecutionRecord {
29
29
  }
30
30
 
31
31
  /**
32
- * A scheduled-run rule attached to a task. The browser-side scheduler ticks
33
- * every minute and triggers the task when `nextRunAt` is due; the rule is
34
- * persisted with the task (localStorage), so scheduling survives refreshes.
32
+ * A scheduled-run rule attached to a task. The Host scheduler triggers the
33
+ * task when `nextRunAt` is due and persists the rule in the Host ledger.
35
34
  */
36
35
  export interface ScheduleRule {
37
36
  /** Whether the schedule is armed. */
@@ -81,7 +80,8 @@ export interface TaskRecord {
81
80
  permission?: TaskPermission
82
81
  /**
83
82
  * When the task was archived (ms epoch). Archived tasks keep their status
84
- * and execution history but leave the main board; absent means on-board.
83
+ * and execution history, leave the main board, and cannot run until restored;
84
+ * absent means on-board.
85
85
  */
86
86
  archivedAt?: number
87
87
  }
@@ -146,12 +146,12 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
146
146
  }
147
147
 
148
148
  /** Whether a manual move target is allowed from the given status. */
149
- export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
150
- return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
149
+ export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
150
+ return from !== 'running' && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
151
151
  }
152
152
 
153
153
  /** Normalize one optional execution-target string: trim; blank collapses to undefined. */
154
- function normalizeTargetId(value: string | undefined): string | undefined {
154
+ export function normalizeTargetId(value: string | undefined): string | undefined {
155
155
  const trimmed = value?.trim()
156
156
  return trimmed === undefined || trimmed === '' ? undefined : trimmed
157
157
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Archive/restore task use case: move a settled (done/failed) task off the
3
- * main board and back. Purely a marker transition — the task keeps its
4
- * status, execution history and transcript references, so archiving never
5
- * loses traceability and restoring lands it in the exact column it left.
3
+ * main board and back. The task keeps its status, execution history, and
4
+ * transcript references, while archiving disarms any schedule so it cannot
5
+ * create more execution records until the user restores and re-enables it.
6
6
  */
7
7
  import type { TaskRecord } from '../tasks.ts'
8
8
  import { ARCHIVABLE_STATUSES } from '../tasks.ts'
@@ -18,7 +18,8 @@ export interface ArchiveTaskResult {
18
18
  /**
19
19
  * Archive one task: only settled statuses (done/failed) can be archived;
20
20
  * a running or not-yet-settled task stays on the board (its runner still
21
- * owns its lifecycle). Already-archived tasks are a no-op.
21
+ * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
22
+ * are a no-op.
22
23
  */
23
24
  export function applyArchiveTask(
24
25
  tasks: readonly TaskRecord[],
@@ -30,7 +31,15 @@ export function applyArchiveTask(
30
31
  if (task.id !== id || task.archivedAt !== undefined) return task
31
32
  if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
32
33
  applied = true
33
- return { ...task, archivedAt: now, updatedAt: now }
34
+ const schedule = task.schedule === undefined
35
+ ? undefined
36
+ : { ...task.schedule, enabled: false, nextRunAt: undefined }
37
+ return {
38
+ ...task,
39
+ ...(schedule === undefined ? {} : { schedule }),
40
+ archivedAt: now,
41
+ updatedAt: now,
42
+ }
34
43
  })
35
44
  return { tasks: next, archived: applied }
36
45
  }
@@ -22,9 +22,9 @@ export interface SetScheduleResult {
22
22
  }
23
23
 
24
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.
25
+ * Set an on-board task's schedule rule. A blank or invalid cron, or an
26
+ * archived task, is rejected (state untouched); an enabled rule computes the
27
+ * next run instant immediately, a disabled one carries no next-run instant.
28
28
  * @param tasks - current ledger.
29
29
  * @param id - the task to schedule.
30
30
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -37,12 +37,13 @@ export function applySetSchedule(
37
37
  now: number,
38
38
  ): SetScheduleResult {
39
39
  const task = tasks.find(candidate => candidate.id === id)
40
- if (task === undefined) return { tasks, applied: false }
40
+ if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
41
41
  const current = task.schedule
42
42
  const cron = (patch.cron ?? current?.cron ?? '').trim()
43
43
  if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
44
44
  const enabled = patch.enabled ?? current?.enabled ?? false
45
45
  const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
46
+ if (enabled && nextRunAt === undefined) return { tasks, applied: false }
46
47
  return {
47
48
  tasks: tasks.map(candidate =>
48
49
  candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
@@ -68,7 +69,7 @@ export function applyScheduleNextRun(
68
69
  now: number,
69
70
  ): readonly TaskRecord[] {
70
71
  return tasks.map(task =>
71
- task.id === id && task.schedule !== undefined
72
+ task.id === id && task.archivedAt === undefined && task.schedule !== undefined
72
73
  ? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
73
74
  : task)
74
75
  }
@@ -8,16 +8,11 @@
8
8
  * back to the runtime default); an unknown permission string is ignored so
9
9
  * stale UI can never persist a value the execution service rejects.
10
10
  */
11
- import { isTaskPermission, type TaskRecord, type TaskPermission } from '../tasks.ts'
11
+ import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
12
12
 
13
13
  /** Editable fields on a task (the update patch surface). */
14
14
  export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
15
15
 
16
- /** Collapse a blank target string to undefined (clears the pin). */
17
- function normalizeTargetId(value: string | undefined): string | undefined {
18
- return value !== undefined && value.trim() === '' ? undefined : value
19
- }
20
-
21
16
  /** Keep an unknown permission string from entering the ledger. */
22
17
  function normalizePermission(
23
18
  current: TaskPermission | undefined,
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }