@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

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 (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +23 -2
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shared scheduling helpers for the board's schedule editors (the new-task
3
+ * dialog and the task detail panel): the time-zone picker inventory, the
4
+ * relative next-run wording, and the combined absolute + relative label.
5
+ *
6
+ * Everything here is browser-side presentation built on the same pure engine
7
+ * the Host schedules with (`core/schedule.ts`), so the preview a user sees is
8
+ * computed by the code that will actually arm the rule.
9
+ */
10
+ import { isValidTimeZone } from '../core/schedule.ts'
11
+ import { t } from './locales.ts'
12
+
13
+ /** One selectable zone, in the order the picker shows it. */
14
+ export interface ZoneChoice {
15
+ /** Canonical IANA id, or `''` for the Host-zone entry. */
16
+ id: string
17
+ /** Display label. */
18
+ label: string
19
+ }
20
+
21
+ /**
22
+ * The runtime's zone inventory, read once. `Intl.supportedValuesOf` is not
23
+ * available in every engine, so a missing inventory degrades to `UTC` rather
24
+ * than offering a list that cannot resolve.
25
+ *
26
+ * The inventory is deliberately NOT truncated. A capped list silently omits
27
+ * whole regions, and because a `<select>` whose value matches no option falls
28
+ * back to its first option, an omitted zone would make the control show — and
29
+ * then save — a different zone than the rule actually stores.
30
+ */
31
+ let inventoryCache: readonly string[] | undefined
32
+
33
+ function zoneInventory(): readonly string[] {
34
+ if (inventoryCache !== undefined) return inventoryCache
35
+ let inventory: readonly string[] = []
36
+ try {
37
+ inventory = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
38
+ } catch {
39
+ inventory = []
40
+ }
41
+ inventoryCache = inventory.length > 0 ? inventory : ['UTC']
42
+ return inventoryCache
43
+ }
44
+
45
+ /**
46
+ * Zones offered by the picker: the Host zone first (the default, and the entry
47
+ * a rule with no stored zone follows), then the runtime's own inventory.
48
+ *
49
+ * The Host entry is always present, even before the Host snapshot reports a
50
+ * zone: it is the entry that stores no zone, so dropping it would leave a rule
51
+ * unable to express "follow the Host" and would let the control silently
52
+ * display an unrelated zone.
53
+ *
54
+ * A stored zone the inventory does not list (a rule written on another machine
55
+ * or under a different ICU build) is appended, so opening the editor can never
56
+ * silently rewrite the rule's zone.
57
+ * @param hostTimeZone - zone the Host reported for this deployment, when known.
58
+ * @param storedTimeZone - zone the edited rule already stores, when any.
59
+ * @returns the de-duplicated choice list.
60
+ */
61
+ export function zoneChoices(hostTimeZone: string | undefined, storedTimeZone?: string): ZoneChoice[] {
62
+ const host = hostTimeZone !== undefined && isValidTimeZone(hostTimeZone) ? hostTimeZone : undefined
63
+ const choices: ZoneChoice[] = []
64
+ const seen = new Set<string>()
65
+ choices.push({
66
+ id: '',
67
+ label: host === undefined
68
+ ? t('detail.schedule.timeZoneHostUnknown')
69
+ : t('detail.schedule.timeZoneHost', { timeZone: host }),
70
+ })
71
+ if (host !== undefined) seen.add(host)
72
+ for (const zone of zoneInventory()) {
73
+ if (seen.has(zone) || !isValidTimeZone(zone)) continue
74
+ seen.add(zone)
75
+ choices.push({ id: zone, label: zone })
76
+ }
77
+ if (storedTimeZone !== undefined && !seen.has(storedTimeZone) && isValidTimeZone(storedTimeZone)) {
78
+ choices.push({ id: storedTimeZone, label: storedTimeZone })
79
+ }
80
+ return choices
81
+ }
82
+
83
+ /** Compact duration parts, largest unit first, with at most two units kept. */
84
+ function durationParts(ms: number): string {
85
+ const total = Math.max(0, Math.floor(ms / 1000))
86
+ const days = Math.floor(total / 86400)
87
+ const hours = Math.floor((total % 86400) / 3600)
88
+ const minutes = Math.floor((total % 3600) / 60)
89
+ const seconds = total % 60
90
+ const parts: string[] = []
91
+ if (days > 0) parts.push(t('detail.schedule.duration.days', { count: String(days) }))
92
+ if (hours > 0) parts.push(t('detail.schedule.duration.hours', { count: String(hours) }))
93
+ if (minutes > 0) parts.push(t('detail.schedule.duration.minutes', { count: String(minutes) }))
94
+ if (parts.length < 2 && (seconds > 0 || parts.length === 0)) {
95
+ parts.push(t('detail.schedule.duration.seconds', { count: String(seconds) }))
96
+ }
97
+ return parts.slice(0, 2).join(' ')
98
+ }
99
+
100
+ /**
101
+ * The relative half of a next-run label: how far away the instant is, or how
102
+ * far past it the board is running. `now` is passed in rather than sampled so
103
+ * callers can share one clock reading across a row, and tests stay
104
+ * deterministic.
105
+ * @param targetMs - the instant being described.
106
+ * @param now - the reference instant.
107
+ * @returns localized relative wording.
108
+ */
109
+ export function relativeTimeLabel(targetMs: number, now: number): string {
110
+ const delta = targetMs - now
111
+ if (delta < 0) return t('detail.schedule.nextRunOverdue', { duration: durationParts(-delta) })
112
+ return `${durationParts(delta)}`
113
+ }
114
+
115
+ /**
116
+ * The full next-run label: the absolute wall clock in the schedule's own zone,
117
+ * plus the relative distance in parentheses. Showing both matters because a
118
+ * rule's zone can differ from the reader's, so the wall clock alone is
119
+ * ambiguous and the distance alone loses the actual moment.
120
+ * @param targetMs - the instant being described.
121
+ * @param timeZone - zone to render the absolute half in.
122
+ * @param formatAbsolute - formatter for the absolute half (keeps Intl caching in the caller).
123
+ * @param now - the reference instant.
124
+ * @param translate - translate function, bound so this module stays dependency-light.
125
+ * @returns the combined label.
126
+ */
127
+ export function nextRunLabel(
128
+ targetMs: number,
129
+ timeZone: string | undefined,
130
+ formatAbsolute: (ms: number, timeZone?: string) => string,
131
+ now: number,
132
+ ): string {
133
+ const absolute = formatAbsolute(targetMs, timeZone)
134
+ return `${absolute} (${relativeTimeLabel(targetMs, now)})`
135
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The board's child seats, threaded from the slot registration that declares
3
+ * them down to the components that consume them.
4
+ *
5
+ * The framework hands the declaring component a `renderSlot` binding; the board
6
+ * wraps that binding in a React context so a provider's contribution can render
7
+ * deep inside TaskDetail, the card, or the settings card without those
8
+ * components knowing which providers exist.
9
+ *
10
+ * @module dsh-task-board/client/seats
11
+ */
12
+ import { createContext, useContext } from 'react'
13
+ import type { ReactNode } from 'react'
14
+ import {
15
+ TASK_BOARD_CARD_DECORATION,
16
+ TASK_BOARD_DETAIL_SECTION,
17
+ TASK_BOARD_SETTINGS_SECTION,
18
+ type TaskBoardCardDecorationProps,
19
+ type TaskBoardDetailSectionProps,
20
+ type TaskBoardSettingsSectionProps,
21
+ } from '../core/extension.ts'
22
+
23
+ /** The three provider seats, adapted to plain render functions. */
24
+ export interface TaskBoardSeats {
25
+ detailSection: (props: TaskBoardDetailSectionProps) => ReactNode
26
+ settingsSection: (props: TaskBoardSettingsSectionProps) => ReactNode
27
+ cardDecoration: (props: TaskBoardCardDecorationProps) => ReactNode
28
+ }
29
+
30
+ /** Nothing registered: every seat renders empty. */
31
+ export const EMPTY_TASK_BOARD_SEATS: TaskBoardSeats = {
32
+ detailSection: () => null,
33
+ settingsSection: () => null,
34
+ cardDecoration: () => null,
35
+ }
36
+
37
+ /** The framework `renderSlot` binding shape (key + owner props share). */
38
+ export type SlotRenderBinding = (key: string, owner: Record<string, unknown>) => ReactNode
39
+
40
+ /** Adapt one framework renderSlot binding to the board's seat interface. */
41
+ export function seatsFromRenderSlot(renderSlot: SlotRenderBinding | undefined): TaskBoardSeats {
42
+ if (renderSlot === undefined) return EMPTY_TASK_BOARD_SEATS
43
+ return {
44
+ detailSection: props => renderSlot(TASK_BOARD_DETAIL_SECTION, props as unknown as Record<string, unknown>),
45
+ settingsSection: props => renderSlot(TASK_BOARD_SETTINGS_SECTION, props as unknown as Record<string, unknown>),
46
+ cardDecoration: props => renderSlot(TASK_BOARD_CARD_DECORATION, props as unknown as Record<string, unknown>),
47
+ }
48
+ }
49
+
50
+ const TaskBoardSeatsContext = createContext<TaskBoardSeats>(EMPTY_TASK_BOARD_SEATS)
51
+
52
+ /** Provider the board's registered components wrap their subtree in. */
53
+ export const TaskBoardSeatsProvider = TaskBoardSeatsContext.Provider
54
+
55
+ /** Read the current seat set (empty when no board registration is above). */
56
+ export function useTaskBoardSeats(): TaskBoardSeats {
57
+ return useContext(TaskBoardSeatsContext)
58
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The board's browser-half extension service (`taskBoard`).
3
+ *
4
+ * It gives a provider's browser half the client capability face from the
5
+ * contract: dispatch over the board's same-origin action channel, card
6
+ * visibility predicates, and a subscription to the board mirror. The board
7
+ * controller is attached and detached as the master switch mounts and unmounts
8
+ * the UI, so the service can be provided before any controller exists.
9
+ *
10
+ * @module dsh-task-board/client/service
11
+ */
12
+ import type { BoardController } from '../core/controller.ts'
13
+ import type {
14
+ TaskBoardClientFace,
15
+ TaskBoardClientMirror,
16
+ TaskBoardExtensionActionRequest,
17
+ TaskBoardVisibilityPredicate,
18
+ } from '../core/extension.ts'
19
+ import type { TaskRecord } from '../core/tasks.ts'
20
+
21
+ export class TaskBoardClientService implements TaskBoardClientFace {
22
+ private controller: BoardController | undefined
23
+ private enabled = false
24
+ private unsubscribeController: (() => void) | undefined
25
+ /** Predicates registered before the controller exists, replayed on attach. */
26
+ private readonly pendingVisibility: TaskBoardVisibilityPredicate[] = []
27
+ private readonly listeners = new Set<(mirror: TaskBoardClientMirror) => void>()
28
+
29
+ /** Bind the live board controller (called when the UI mounts). */
30
+ attach(controller: BoardController): void {
31
+ if (this.controller === controller) return
32
+ this.detach()
33
+ this.controller = controller
34
+ this.unsubscribeController = controller.subscribe(() => { this.notify() })
35
+ for (const predicate of this.pendingVisibility.splice(0)) controller.registerVisibility(predicate)
36
+ this.notify()
37
+ }
38
+
39
+ /** Release the current controller (called when the UI unmounts). */
40
+ detach(): void {
41
+ if (this.controller === undefined) return
42
+ this.unsubscribeController?.()
43
+ this.unsubscribeController = undefined
44
+ this.controller = undefined
45
+ this.notify()
46
+ }
47
+
48
+ /** Apply the board's master switch. */
49
+ setEnabled(enabled: boolean): void {
50
+ if (this.enabled === enabled) return
51
+ this.enabled = enabled
52
+ this.notify()
53
+ }
54
+
55
+ dispatch(request: TaskBoardExtensionActionRequest): Promise<boolean> {
56
+ if (this.controller === undefined) return Promise.resolve(false)
57
+ return this.controller.dispatchExtension(request)
58
+ }
59
+
60
+ registerVisibility(predicate: TaskBoardVisibilityPredicate): () => void {
61
+ if (this.controller !== undefined) return this.controller.registerVisibility(predicate)
62
+ this.pendingVisibility.push(predicate)
63
+ return () => {
64
+ const index = this.pendingVisibility.indexOf(predicate)
65
+ if (index !== -1) this.pendingVisibility.splice(index, 1)
66
+ }
67
+ }
68
+
69
+ subscribe(callback: (mirror: TaskBoardClientMirror) => void): () => void {
70
+ this.listeners.add(callback)
71
+ return () => { this.listeners.delete(callback) }
72
+ }
73
+
74
+ snapshot(): TaskBoardClientMirror {
75
+ const state = this.controller?.getSnapshot()
76
+ return {
77
+ enabled: this.enabled,
78
+ tasks: (state?.tasks ?? []) as readonly TaskRecord[],
79
+ extensions: state?.extensions ?? {},
80
+ }
81
+ }
82
+
83
+ private notify(): void {
84
+ const mirror = this.snapshot()
85
+ for (const listener of [...this.listeners]) {
86
+ try {
87
+ listener(mirror)
88
+ } catch (error) {
89
+ // A provider's mirror callback must never break the board.
90
+ console.error('[dsh-task-board] extension mirror callback failed', error)
91
+ }
92
+ }
93
+ }
94
+ }
@@ -36,8 +36,8 @@
36
36
  border: 0;
37
37
  border-radius: 12px;
38
38
  align-items: center;
39
- gap: 12px;
40
- padding: 14px 16px;
39
+ gap: 13px;
40
+ padding: 16px;
41
41
  display: flex;
42
42
  }
43
43
 
@@ -51,8 +51,8 @@
51
51
  box-sizing: border-box;
52
52
  border-radius: 12px;
53
53
  align-items: center;
54
- gap: 12px;
55
- padding: 14px 16px;
54
+ gap: 13px;
55
+ padding: 16px;
56
56
  display: flex;
57
57
  }
58
58
 
@@ -64,6 +64,25 @@
64
64
  display: flex;
65
65
  }
66
66
 
67
+ /* Optional leading plugin mark: a quiet tile that takes the ink color on
68
+ hover and while the card is open, so the whole row reads as one control. */
69
+ .mark {
70
+ flex: none;
71
+ display: grid;
72
+ place-items: center;
73
+ width: 34px;
74
+ height: 34px;
75
+ border-radius: 10px;
76
+ background: var(--dsw-alias-bg-module-platform);
77
+ color: var(--dsw-alias-label-secondary);
78
+ transition: color 0.16s;
79
+ }
80
+
81
+ .header:hover .mark,
82
+ .cardOpen .mark {
83
+ color: var(--dsw-alias-label-primary);
84
+ }
85
+
67
86
  .name {
68
87
  color: var(--dsw-alias-label-primary);
69
88
  font-size: 15px;
@@ -95,7 +114,13 @@
95
114
  .chevron {
96
115
  color: var(--dsw-alias-label-tertiary);
97
116
  flex: none;
98
- transition: transform 0.16s;
117
+ width: 16px;
118
+ height: 16px;
119
+ transition: transform 0.16s, color 0.16s;
120
+ }
121
+
122
+ .header:hover .chevron {
123
+ color: var(--dsw-alias-label-secondary);
99
124
  }
100
125
 
101
126
  .chevronOpen {
@@ -406,6 +431,7 @@
406
431
  @media (prefers-reduced-motion: reduce) {
407
432
  .card,
408
433
  .header,
434
+ .mark,
409
435
  .chevron,
410
436
  .chevronOpen,
411
437
  .discard,
@@ -215,6 +215,8 @@ export class CardForm<T> {
215
215
  private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
+ /** A save asked for while one was in flight; it runs once the first settles (#1754). */
219
+ private saveQueued = false
218
220
  private failed = false
219
221
  private failedReason: string | undefined
220
222
 
@@ -283,7 +285,7 @@ export class CardForm<T> {
283
285
  resetField: (field) => {
284
286
  this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
285
287
  },
286
- save: () => { void this.save() },
288
+ save: () => { void this.requestSave() },
287
289
  discard: () => {
288
290
  if (this.staged.size === 0 && !this.failed) return
289
291
  this.staged.clear()
@@ -310,6 +312,30 @@ export class CardForm<T> {
310
312
  * the user can correct them instead of retyping.
311
313
  * @returns settlement after the mutation and the read-back.
312
314
  */
315
+ /**
316
+ * Run one save, and re-run it once if another was asked for while this one
317
+ * was still in flight.
318
+ *
319
+ * A save is a Host round trip that also drives the profile reconcile, and
320
+ * the Host runs that write inside one exclusive transaction. Answering a
321
+ * press that arrives mid-flight by returning immediately dropped the edit
322
+ * with no explanation, which is the "the save button stops working after a
323
+ * few rounds" report (#1754). Serializing instead means a save pressed while
324
+ * another is still settling runs against the settled state - which is what
325
+ * the operator meant by pressing it again.
326
+ * @returns settlement after the mutation and the read-back.
327
+ */
328
+ async requestSave(): Promise<void> {
329
+ if (this.saving) {
330
+ this.saveQueued = true
331
+ return
332
+ }
333
+ await this.save()
334
+ if (!this.saveQueued) return
335
+ this.saveQueued = false
336
+ await this.requestSave()
337
+ }
338
+
313
339
  async save(): Promise<void> {
314
340
  const plan = this.plan()
315
341
  const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
@@ -12,12 +12,14 @@
12
12
  * owns only the orchestration seam (state, persistence, notify, navigation).
13
13
  */
14
14
  import type { TaskStore } from './store.ts'
15
- import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
15
+ import { hasOpenExecution, withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
16
16
  import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
17
17
  import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
19
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
20
+ import { resolveHostTimeZone } from './schedule.ts'
20
21
  import { applySetParent } from './use-cases/task-parent.ts'
22
+ import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
21
23
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
22
24
  import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
23
25
  import type {
@@ -27,6 +29,10 @@ import type {
27
29
  TaskBoardParseRequest,
28
30
  TaskBoardSnapshot,
29
31
  } from '../protocol.ts'
32
+ import type {
33
+ TaskBoardExtensionActionRequest,
34
+ TaskBoardVisibilityPredicate,
35
+ } from './extension.ts'
30
36
 
31
37
  export interface TaskBoardTransport {
32
38
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
@@ -135,7 +141,7 @@ export interface ExecutionOptionsSnapshot {
135
141
  * (revision/scheduler/power); a full snapshot also carries the deployment
136
142
  * constants the UI reads (session default permission, subtask depth).
137
143
  */
138
- export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable'>
144
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'extensions'>
139
145
 
140
146
  /** Immutable controller snapshot for UI subscriptions. */
141
147
  export interface ControllerSnapshot {
@@ -153,6 +159,15 @@ export interface ControllerSnapshot {
153
159
  canParseTask?: boolean
154
160
  transportError?: string
155
161
  host?: HostMirror
162
+ /**
163
+ * Provider card-visibility predicates, in registration order. A task shows
164
+ * only while every predicate accepts it; providers hide their own off-board
165
+ * cards here instead of the board hard-coding a provider rule. Optional so a
166
+ * hand-built snapshot (tests, a shell-less composition) defaults to visible.
167
+ */
168
+ visibility?: readonly TaskBoardVisibilityPredicate[]
169
+ /** Read-only summaries published by running extensions, keyed by extension id. */
170
+ extensions?: Readonly<Record<string, unknown>>
156
171
  }
157
172
 
158
173
  /** The selected task (resolved from the ledger), or undefined. */
@@ -200,6 +215,8 @@ export class BoardController {
200
215
  private readonly taskQueues = new Map<string, Promise<void>>()
201
216
  private transportError: string | undefined
202
217
  private hostState: HostMirror | undefined
218
+ /** Provider visibility predicates (immutable array; replaced on change). */
219
+ private visibility: readonly TaskBoardVisibilityPredicate[] = []
203
220
  private remoteSubscribed = false
204
221
  private remoteInitialization: Promise<boolean> | undefined
205
222
 
@@ -250,9 +267,42 @@ export class BoardController {
250
267
  ...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
251
268
  ...(this.transportError === undefined ? {} : { transportError: this.transportError }),
252
269
  ...(this.hostState === undefined ? {} : { host: this.hostState }),
270
+ visibility: this.visibility,
271
+ ...(this.hostState?.extensions === undefined ? {} : { extensions: this.hostState.extensions }),
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Register one card-visibility predicate. The returned disposer removes it;
277
+ * both changes notify subscribers so the board re-renders.
278
+ */
279
+ registerVisibility(predicate: TaskBoardVisibilityPredicate): () => void {
280
+ this.visibility = [...this.visibility, predicate]
281
+ this.notify()
282
+ return () => {
283
+ const next = this.visibility.filter(candidate => candidate !== predicate)
284
+ if (next.length === this.visibility.length) return
285
+ this.visibility = next
286
+ this.notify()
253
287
  }
254
288
  }
255
289
 
290
+ /**
291
+ * Deliver one provider action through the board's same-origin channel. A
292
+ * board without a Host transport (the legacy in-memory path) refuses it.
293
+ */
294
+ async dispatchExtension(request: TaskBoardExtensionActionRequest): Promise<boolean> {
295
+ if (this.deps.transport === undefined) return false
296
+ const action: TaskBoardAction = {
297
+ kind: 'extension-action',
298
+ extensionId: request.extensionId,
299
+ action: request.action,
300
+ ...(request.taskId === undefined ? {} : { taskId: request.taskId }),
301
+ ...(request.payload === undefined ? {} : { payload: request.payload }),
302
+ }
303
+ return await this.commitRemote(action, request.taskId)
304
+ }
305
+
256
306
  subscribe(fn: () => void): () => void {
257
307
  this.listeners.add(fn)
258
308
  return () => { this.listeners.delete(fn) }
@@ -506,6 +556,44 @@ export class BoardController {
506
556
  return true
507
557
  }
508
558
 
559
+ /**
560
+ * Rename one label across the whole ledger; a name already in use merges the
561
+ * two labels. Host-backed: the ledger owns the transition and confirms it, so
562
+ * the board's label row reflects whether the Host accepted the edit.
563
+ * @returns true when the edit was accepted by the authority.
564
+ */
565
+ async renameTag(from: string, to: string): Promise<boolean> {
566
+ if (this.deps.transport !== undefined) {
567
+ return await this.commitRemote({ kind: 'rename-tag', from, to })
568
+ }
569
+ const result = applyRenameTag(this.tasks, from, to, this.now())
570
+ if (result.error !== undefined) return false
571
+ if (result.changed) {
572
+ this.tasks = [...result.tasks]
573
+ this.persistAndNotify()
574
+ }
575
+ return true
576
+ }
577
+
578
+ /**
579
+ * Remove one label from every task that carries it, board and archive alike —
580
+ * the board's label row is drawn from the whole ledger, so a label left on an
581
+ * archived card would come straight back.
582
+ * @returns true when the edit was accepted by the authority.
583
+ */
584
+ async deleteTag(name: string): Promise<boolean> {
585
+ if (this.deps.transport !== undefined) {
586
+ return await this.commitRemote({ kind: 'delete-tag', name })
587
+ }
588
+ const result = applyDeleteTag(this.tasks, name, this.now())
589
+ if (result.error !== undefined) return false
590
+ if (result.changed) {
591
+ this.tasks = [...result.tasks]
592
+ this.persistAndNotify()
593
+ }
594
+ return true
595
+ }
596
+
509
597
  // --- scheduling ---------------------------------------------------------------
510
598
 
511
599
  /**
@@ -517,8 +605,12 @@ export class BoardController {
517
605
  * @param patch - fields to change (absent fields keep their current value).
518
606
  * @returns true when applied, false when rejected (invalid cron / unknown task).
519
607
  */
520
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
521
- const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
608
+ setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
609
+ // The Host zone is the fallback a rule with no stored zone follows: the
610
+ // mirrored snapshot carries it, and a controller without a Host mirror
611
+ // (pure client tests) falls back to the process zone.
612
+ const hostTimeZone = this.hostState?.scheduler.timeZone ?? resolveHostTimeZone()
613
+ const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now(), hostTimeZone)
522
614
  if (!applied) return false
523
615
  if (this.deps.transport !== undefined) {
524
616
  void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
@@ -567,7 +659,9 @@ export class BoardController {
567
659
  */
568
660
  async runTask(id: string): Promise<boolean> {
569
661
  const task = this.tasks.find(candidate => candidate.id === id)
570
- if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
662
+ // Only an open execution blocks a second launch; the 'running' column is a
663
+ // board statement and a card parked there by hand still runs on request.
664
+ if (task === undefined || task.archivedAt !== undefined || hasOpenExecution(task)) return false
571
665
  if (this.deps.transport === undefined) return false
572
666
  return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
573
667
  }
@@ -719,6 +813,7 @@ export class BoardController {
719
813
  const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
720
814
  const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
721
815
  const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
816
+ const extensions = snapshot.extensions ?? this.hostState?.extensions
722
817
  return {
723
818
  revision: snapshot.revision,
724
819
  scheduler: snapshot.scheduler,
@@ -726,6 +821,7 @@ export class BoardController {
726
821
  ...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
727
822
  ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
728
823
  ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
824
+ ...(extensions === undefined ? {} : { extensions }),
729
825
  }
730
826
  }
731
827
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The family health ledger's degraded-record contract (issues #1528, #1730).
3
+ *
4
+ * A family plugin row that fails during apply is recorded by the dsh-web-all
5
+ * shell as a degraded record; the shell serves the ledger through its
6
+ * loopback-only `GET /api/dsh-web-all/degraded` route because the failed
7
+ * plugin could not register any route of its own — the task board whose ledger
8
+ * lock is held by another DSH process is exactly that case, and without this
9
+ * the panel could only report "the Host API is not mounted".
10
+ *
11
+ * Both halves define this shape: the shell WRITES it (packages/dsh-web-all,
12
+ * src/degraded.ts — the two packages have no import edge, one is bundled into
13
+ * the other's profile) and a plugin's browser half PARSES it. The edits move
14
+ * together.
15
+ */
16
+
17
+ /** Where a degraded row failed: module import, plugin shape, or fiber start. */
18
+ export type DegradedStage = 'import' | 'shape' | 'start'
19
+
20
+ /** One degraded family row as the shell's health route serves it. */
21
+ export interface DegradedRow {
22
+ /** Real plugin package name from the shell row config. */
23
+ plugin: string
24
+ /** Where the failure happened. */
25
+ stage: DegradedStage
26
+ /** The shell's one-line reason, for display. */
27
+ reason: string
28
+ }
29
+
30
+ /**
31
+ * The one degraded record naming `plugin`, or undefined when that row is not
32
+ * degraded (or the payload does not come from the shell's own shape). A
33
+ * plugin may appear once: the shell keys the ledger by package name and
34
+ * refreshes the record on every new failure.
35
+ * @param body - parsed JSON body of the health route.
36
+ * @param plugin - the real plugin package name to look for.
37
+ * @returns the degraded row, or undefined when the ledger does not name it.
38
+ */
39
+ export function degradedRowOf(body: unknown, plugin: string): DegradedRow | undefined {
40
+ if (typeof body !== 'object' || body === null) return undefined
41
+ const ledger = (body as { ok?: unknown; degraded?: unknown })
42
+ if (ledger.ok !== true || !Array.isArray(ledger.degraded)) return undefined
43
+ for (const entry of ledger.degraded) {
44
+ if (typeof entry !== 'object' || entry === null) continue
45
+ const record = entry as { plugin?: unknown; stage?: unknown; reason?: unknown }
46
+ if (record.plugin !== plugin) continue
47
+ const stage = record.stage === 'import' || record.stage === 'shape' || record.stage === 'start' ? record.stage : undefined
48
+ if (stage === undefined) continue
49
+ return { plugin, stage, reason: typeof record.reason === 'string' ? record.reason : '' }
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ /** The ledger owner pid a task-board lock reason names, when it names one. */
55
+ const OWNED_BY_PROCESS_RE = /already owned by process\s+(\d+)/
56
+
57
+ /**
58
+ * The marker the ledger appends when it could NOT confirm the recorded PID
59
+ * belongs to the lock's real owner: the PID was reused by an unrelated process
60
+ * after a crash, and the reason then carries the manual-removal step instead.
61
+ * That reason must be rendered verbatim — telling the user to close "the other
62
+ * DSH instance" would send them after a process that never touched the board.
63
+ */
64
+ const UNCONFIRMED_OWNER_MARKER = 'reused after a crash'
65
+
66
+ /**
67
+ * Extract the pid of the LIVE process a ledger-lock failure blames, when the
68
+ * reason identifies one. The task board's lock refusal is the one degraded
69
+ * reason that carries an owner the user can act on, so the panel renders the
70
+ * pid in its own sentence and adds the guidance that actually applies.
71
+ * @param reason - the shell's one-line failure reason.
72
+ * @returns the confirmed owner pid, or undefined when the reason names none or
73
+ * warns that the PID may have been reused (its own text owns the recovery).
74
+ */
75
+ export function ledgerOwnerPid(reason: string): number | undefined {
76
+ if (reason.includes(UNCONFIRMED_OWNER_MARKER)) return undefined
77
+ const match = OWNED_BY_PROCESS_RE.exec(reason)
78
+ if (match === null) return undefined
79
+ const pid = Number(match[1])
80
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
81
+ }