@linxin666/dsh-client-ui-task-board 0.3.6 → 0.3.9

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 (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -4
  3. package/README.zh.md +9 -4
  4. package/lib/client.js +688 -66
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +698 -190
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +3 -0
  9. package/lib/types/client/host-api.d.ts +2 -2
  10. package/lib/types/client/index.d.ts +10 -2
  11. package/lib/types/client/locales.d.ts +19 -0
  12. package/lib/types/client/settings-form.d.ts +35 -43
  13. package/lib/types/core/controller.d.ts +11 -2
  14. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  15. package/lib/types/core/handover.d.ts +58 -0
  16. package/lib/types/core/tasks.d.ts +70 -1
  17. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  18. package/lib/types/host-ledger.d.ts +17 -2
  19. package/lib/types/host-runner.d.ts +43 -23
  20. package/lib/types/host-service.d.ts +8 -5
  21. package/lib/types/index.d.ts +9 -1
  22. package/lib/types/protocol.d.ts +15 -2
  23. package/package.json +26 -17
  24. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  25. package/src/client/board/NewTaskModal.tsx +52 -0
  26. package/src/client/board/TaskBoard.tsx +5 -3
  27. package/src/client/board/TaskCard.tsx +3 -0
  28. package/src/client/board/TaskDetail.tsx +54 -0
  29. package/src/client/host-api.ts +5 -5
  30. package/src/client/index.ts +67 -7
  31. package/src/client/locales.ts +40 -2
  32. package/src/client/settings-form.ts +78 -92
  33. package/src/core/controller.ts +35 -10
  34. package/src/core/freeze-snapshot.ts +213 -0
  35. package/src/core/handover.ts +117 -0
  36. package/src/core/store.ts +48 -1
  37. package/src/core/tasks.ts +87 -0
  38. package/src/core/use-cases/task-update.ts +30 -10
  39. package/src/dsh-home.ts +1 -1
  40. package/src/host-ledger.ts +137 -59
  41. package/src/host-routes.ts +1 -1
  42. package/src/host-runner.ts +211 -97
  43. package/src/host-service.ts +14 -10
  44. package/src/index.ts +24 -10
  45. package/src/protocol.ts +98 -14
@@ -4,14 +4,15 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
4
4
  import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
7
- import { parseLedger } from './core/store.ts'
7
+ import { isTaskRecord, parseLedger } from './core/store.ts'
8
8
  import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
10
10
  import { applyCreateTask } from './core/use-cases/task-create.ts'
11
11
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
12
12
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
13
13
  import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
14
- import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
14
+ import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
15
+ import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
15
16
 
16
17
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
17
18
  importedSources?: string[]
@@ -22,6 +23,9 @@ interface PersistedRequest {
22
23
  fingerprint: string
23
24
  }
24
25
 
26
+ /** On-disk document of any schema generation (schemaVersion untyped until the load branches decide). */
27
+ type ParsedLedgerDocument = Omit<Partial<LedgerDocument>, 'schemaVersion'> & { schemaVersion?: unknown }
28
+
25
29
  interface LedgerDocument {
26
30
  schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
27
31
  revision: number
@@ -273,7 +277,11 @@ export class HostTaskLedger {
273
277
  /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
274
278
  readonly schedulerFile: string
275
279
 
276
- constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now) {
280
+ /** Session-default permission the confirmation gate compares against. */
281
+ readonly sessionDefaultPermission: TaskPermission
282
+
283
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) {
284
+ this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
277
285
  mkdirSync(dir, { recursive: true })
278
286
  this.file = join(dir, 'ledger-v2.json')
279
287
  this.lockFile = join(dir, 'ledger-v2.lock')
@@ -368,7 +376,11 @@ export class HostTaskLedger {
368
376
  }
369
377
  }
370
378
 
371
- applyRequest(requestId: string, action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
379
+ applyRequest(
380
+ requestId: string,
381
+ action: TaskBoardAction,
382
+ initiator?: string,
383
+ ): { state: LedgerState; run?: OpenedRun } {
372
384
  const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
373
385
  const cached = this.requestCache.get(requestId)
374
386
  if (cached !== undefined) {
@@ -382,7 +394,7 @@ export class HostTaskLedger {
382
394
  while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value as string)
383
395
  this.syncRecentRequests()
384
396
  try {
385
- return this.apply(action)
397
+ return this.apply(action, initiator)
386
398
  } catch (error) {
387
399
  this.requestCache.delete(requestId)
388
400
  this.syncRecentRequests()
@@ -393,6 +405,14 @@ export class HostTaskLedger {
393
405
  openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
394
406
  const task = this.document.tasks.find(item => item.id === taskId)
395
407
  if (task === undefined || task.archivedAt !== undefined) return undefined
408
+ if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
409
+ // An unconfirmed above-default permission must never run unattended:
410
+ // cron refuses the card and rolls to the next occurrence, exactly
411
+ // like the already-running refusal.
412
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
413
+ this.commit()
414
+ return undefined
415
+ }
396
416
  if (task.status === 'running' || hasOpenExecution(task)) {
397
417
  this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
398
418
  this.commit()
@@ -446,7 +466,7 @@ export class HostTaskLedger {
446
466
  this.commit()
447
467
  }
448
468
 
449
- private apply(action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
469
+ private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } {
450
470
  const now = this.now()
451
471
  let run: OpenedRun | undefined
452
472
  switch (action.kind) {
@@ -473,7 +493,10 @@ export class HostTaskLedger {
473
493
  if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
474
494
  throw new Error('invalid schedule')
475
495
  }
476
- const result = applyCreateTask(this.document.tasks, action.input, now, action.id)
496
+ const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
497
+ ? action.input
498
+ : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
499
+ const result = applyCreateTask(this.document.tasks, input, now, action.id)
477
500
  if (result.task === undefined) throw new Error('invalid task')
478
501
  this.document.tasks = [...result.tasks]
479
502
  break
@@ -490,7 +513,12 @@ export class HostTaskLedger {
490
513
  throw new Error('task has already been executed')
491
514
  }
492
515
  if ('title' in action.patch && action.patch.title?.trim() === '') throw new Error('title is required')
493
- this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)]
516
+ // A replaced snapshot is re-stamped with the updating session (the
517
+ // initiator), so a swapped freeze cannot keep the old author stamp.
518
+ const patch = action.patch.freeze === null || action.patch.freeze === undefined || initiator === undefined || initiator === ''
519
+ ? action.patch
520
+ : { ...action.patch, freeze: { ...action.patch.freeze, frozenBy: initiator } }
521
+ this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
494
522
  break
495
523
  }
496
524
  case 'delete':
@@ -522,6 +550,15 @@ export class HostTaskLedger {
522
550
  this.document.tasks = [...result.tasks]
523
551
  break
524
552
  }
553
+ case 'confirm-permission': {
554
+ const task = this.document.tasks.find(item => item.id === action.taskId)
555
+ if (task === undefined) throw new Error('task not found')
556
+ if (task.permissionConfirmedAt !== undefined) break
557
+ this.document.tasks = this.document.tasks.map(item => item.id === action.taskId
558
+ ? { ...item, permissionConfirmedAt: now, updatedAt: now }
559
+ : item)
560
+ break
561
+ }
525
562
  case 'set-schedule': {
526
563
  const task = this.document.tasks.find(task => task.id === action.taskId)
527
564
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
@@ -535,8 +572,11 @@ export class HostTaskLedger {
535
572
  const task = this.document.tasks.find(item => item.id === action.taskId)
536
573
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
537
574
  if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
575
+ if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
576
+ throw new Error(`confirmation-required: the effective permission is above the session default (${this.sessionDefaultPermission}); confirm the card's permission binding first`)
577
+ }
538
578
  const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
539
- run = startExecution(base, now, crypto.randomUUID())
579
+ run = startExecution(base, now, crypto.randomUUID(), initiator)
540
580
  this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
541
581
  break
542
582
  }
@@ -578,61 +618,99 @@ export class HostTaskLedger {
578
618
  if (changed && persist) this.commit()
579
619
  }
580
620
 
621
+ /**
622
+ * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
623
+ * migration reuses the v3 normalization, but it first proves every task
624
+ * row is structurally valid: a v2 document that would silently drop or
625
+ * coerce rows fails loudly instead (no quarantined-empty restart).
626
+ */
627
+ private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
628
+ if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
629
+ throw new Error('v2 document contains structurally invalid task rows')
630
+ }
631
+ return this.normalizeDocument(parsed)
632
+ }
633
+
581
634
  private load(dir: string): LedgerDocument {
582
635
  const existed = existsSync(this.file)
636
+ // schemaVersion stays unknown-typed here: on-disk documents may be v2
637
+ // (legacy), v3, or any future/invalid value the branches below sort out.
638
+ let parsed: ParsedLedgerDocument
583
639
  try {
584
- const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
585
- if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
586
- const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
587
- const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
588
- if (typeof value !== 'object' || value === null) return []
589
- const row = value as { id?: unknown; schedule?: unknown }
590
- if (typeof row.schedule !== 'object' || row.schedule === null) return []
591
- const cron = (row.schedule as { cron?: unknown }).cron
592
- return typeof cron !== 'string' || !isValidCron(cron)
593
- ? [typeof row.id === 'string' ? row.id : 'unknown']
594
- : []
595
- })
596
- const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
597
- const sidecarLastTickAt = this.readSchedulerSidecar()
598
- // A sidecar write can be newer than the last full commit (crash between
599
- // the two); lastTickAt only ever moves forward, so take the greater.
600
- const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
601
- ? documentLastTickAt
602
- : sidecarLastTickAt
603
- return {
604
- schemaVersion: TASK_BOARD_SCHEMA_VERSION,
605
- revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
606
- tasks,
607
- scheduler: {
608
- timeZone: timeZone(),
609
- ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
610
- ...(lastTickAt === undefined ? {} : { lastTickAt }),
611
- ...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
612
- ...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
613
- ...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
614
- },
615
- recentRequests: Array.isArray(parsed.recentRequests)
616
- ? parsed.recentRequests.flatMap((entry) => {
617
- if (typeof entry !== 'object' || entry === null) return []
618
- const request = entry as { requestId?: unknown; fingerprint?: unknown }
619
- return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
620
- ? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
621
- : []
622
- }).slice(-MAX_REQUEST_CACHE)
623
- : [],
624
- }
640
+ parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
625
641
  } catch (error) {
626
- if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`)
627
- mkdirSync(dir, { recursive: true })
628
- return {
629
- schemaVersion: TASK_BOARD_SCHEMA_VERSION,
630
- revision: 0,
631
- tasks: [],
632
- scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) },
633
- recentRequests: [],
642
+ return this.recoverCorrupt(dir, existed, error)
643
+ }
644
+ if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION) {
645
+ try {
646
+ return this.migrateLegacyDocument(parsed)
647
+ } catch (error) {
648
+ // Migration failure is explicit: the original v2 file stays in place
649
+ // for manual recovery and the ledger refuses to start (fail closed).
650
+ throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
634
651
  }
635
652
  }
653
+ try {
654
+ if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
655
+ return this.normalizeDocument(parsed)
656
+ } catch (error) {
657
+ return this.recoverCorrupt(dir, existed, error)
658
+ }
659
+ }
660
+
661
+ private normalizeDocument(parsed: ParsedLedgerDocument): LedgerDocument {
662
+ const tasks = parseHostTasks(parsed.tasks as readonly unknown[]).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
663
+ const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
664
+ if (typeof value !== 'object' || value === null) return []
665
+ const row = value as { id?: unknown; schedule?: unknown }
666
+ if (typeof row.schedule !== 'object' || row.schedule === null) return []
667
+ const cron = (row.schedule as { cron?: unknown }).cron
668
+ return typeof cron !== 'string' || !isValidCron(cron)
669
+ ? [typeof row.id === 'string' ? row.id : 'unknown']
670
+ : []
671
+ })
672
+ const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
673
+ const sidecarLastTickAt = this.readSchedulerSidecar()
674
+ // A sidecar write can be newer than the last full commit (crash between
675
+ // the two); lastTickAt only ever moves forward, so take the greater.
676
+ const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
677
+ ? documentLastTickAt
678
+ : sidecarLastTickAt
679
+ return {
680
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
681
+ revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
682
+ tasks,
683
+ scheduler: {
684
+ timeZone: timeZone(),
685
+ ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
686
+ ...(lastTickAt === undefined ? {} : { lastTickAt }),
687
+ ...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
688
+ ...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
689
+ ...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
690
+ },
691
+ recentRequests: Array.isArray(parsed.recentRequests)
692
+ ? parsed.recentRequests.flatMap((entry) => {
693
+ if (typeof entry !== 'object' || entry === null) return []
694
+ const request = entry as { requestId?: unknown; fingerprint?: unknown }
695
+ return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
696
+ ? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
697
+ : []
698
+ }).slice(-MAX_REQUEST_CACHE)
699
+ : [],
700
+ }
701
+ }
702
+
703
+ /** Quarantine an unreadable document and start from an empty ledger. */
704
+ private recoverCorrupt(dir: string, existed: boolean, error: unknown): LedgerDocument {
705
+ if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`)
706
+ mkdirSync(dir, { recursive: true })
707
+ return {
708
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
709
+ revision: 0,
710
+ tasks: [],
711
+ scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) },
712
+ recentRequests: [],
713
+ }
636
714
  }
637
715
 
638
716
  private syncRecentRequests(): void {
@@ -772,4 +850,4 @@ export class HostTaskLedger {
772
850
  }
773
851
  throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
774
852
  }
775
- }
853
+ }
@@ -151,7 +151,7 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
151
151
  if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
152
152
  return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
153
153
  }
154
- writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { 'cache-control': 'no-store' })
154
+ writeJson(res, 200, service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
155
155
  } catch (error) {
156
156
  const message = error instanceof Error ? error.message : String(error)
157
157
  writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })