@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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 +4 -1
  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/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -7,8 +7,9 @@
7
7
  * the Host schedules with (`core/schedule.ts`), so the preview a user sees is
8
8
  * computed by the code that will actually arm the rule.
9
9
  */
10
- import { isValidTimeZone } from '../core/schedule.ts'
11
- import { t } from './locales.ts'
10
+ import { isValidTimeZone, partsInZone, resolveHostTimeZone, zonedEpoch } from '../core/schedule.ts'
11
+ import { scheduleRunBudget, type ScheduleRule, type ScheduleStopReason } from '../core/tasks.ts'
12
+ import { t, type TaskBoardKey } from './locales.ts'
12
13
 
13
14
  /** One selectable zone, in the order the picker shows it. */
14
15
  export interface ZoneChoice {
@@ -112,6 +113,103 @@ export function relativeTimeLabel(targetMs: number, now: number): string {
112
113
  return `${durationParts(delta)}`
113
114
  }
114
115
 
116
+ /** Zero-pad a calendar/clock field for a `datetime-local` value. */
117
+ function pad(value: number): string {
118
+ return String(value).padStart(2, '0')
119
+ }
120
+
121
+ /**
122
+ * The `datetime-local` input value for an instant, read in an explicit zone:
123
+ * `YYYY-MM-DDTHH:mm`. Uses the shared engine's zone reader, so the editor
124
+ * shows the same wall clock the Host schedules by.
125
+ * @param epochMs - the instant to render.
126
+ * @param timeZone - zone to read it in; absent uses the Host zone.
127
+ * @returns the input value.
128
+ */
129
+ export function zonedInputValue(epochMs: number, timeZone?: string): string {
130
+ const parts = partsInZone(usableZone(timeZone), epochMs)
131
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`
132
+ }
133
+
134
+ /**
135
+ * Parse a `datetime-local` input value back to an instant, read in an explicit
136
+ * zone. Returns undefined when the text is not a whole calendar wall clock or
137
+ * names one the zone skips (a spring-forward gap), so the editor can refuse an
138
+ * unusable instant instead of silently moving it.
139
+ * @param value - the input text.
140
+ * @param timeZone - zone to interpret it in; absent uses the Host zone.
141
+ * @returns the instant, or undefined when it is not a usable wall clock.
142
+ */
143
+ export function parseZonedInput(value: string, timeZone?: string): number | undefined {
144
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim())
145
+ if (match === null) return undefined
146
+ const [year, month, day, hour, minute] = match.slice(1).map(Number)
147
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) return undefined
148
+ return zonedEpoch(usableZone(timeZone), year, month, day, hour, minute)
149
+ }
150
+
151
+ /**
152
+ * The default instant a new one-shot editor opens on: the next whole hour,
153
+ * rendered in the zone the rule will be read in.
154
+ * @param now - reference instant.
155
+ * @param timeZone - zone the rule is read in.
156
+ * @returns the input value for a near-future default.
157
+ */
158
+ export function defaultZonedInputValue(now: number, timeZone?: string): string {
159
+ const nextHour = Math.ceil(now / 3_600_000) * 3_600_000
160
+ return zonedInputValue(nextHour, timeZone)
161
+ }
162
+
163
+ /** The zone the input helpers resolve against: an explicit resolved zone, or the Host zone. */
164
+ function usableZone(timeZone: string | undefined): string {
165
+ return timeZone !== undefined && isValidTimeZone(timeZone) ? timeZone : resolveHostTimeZone()
166
+ }
167
+
168
+ /**
169
+ * The editor's run-cap option for a stored cap: the two named presets, the
170
+ * unlimited entry, or a custom value the editor shows in its own field.
171
+ * @param maxRuns - the stored cap, when any.
172
+ * @returns the select option value.
173
+ */
174
+ export function runCapOption(maxRuns: number | undefined): string {
175
+ if (maxRuns === undefined) return 'unlimited'
176
+ if (maxRuns === 1) return '1'
177
+ if (maxRuns === 2) return '2'
178
+ return 'custom'
179
+ }
180
+
181
+ /** The locale key describing why a rule stopped or skipped an occurrence. */
182
+ export function stopReasonKey(reason: ScheduleStopReason): TaskBoardKey {
183
+ return `detail.schedule.stop.${reason}` as TaskBoardKey
184
+ }
185
+
186
+ /**
187
+ * The run-budget line: how many scheduled executions the Host opened and how
188
+ * many remain. Derived from the counter and the cap rather than stored, so the
189
+ * display cannot drift from the ledger.
190
+ * @param rule - the schedule rule.
191
+ * @returns localized wording.
192
+ */
193
+ export function budgetLabel(rule: ScheduleRule): string {
194
+ const budget = scheduleRunBudget(rule)
195
+ const done = String(rule.runCount)
196
+ if (budget === undefined) return t('detail.schedule.runsUnlimited', { done })
197
+ const remaining = Math.max(budget - rule.runCount, 0)
198
+ return `${t('detail.schedule.runs', { done, total: String(budget) })} · ${t('detail.schedule.remaining', { count: String(remaining) })}`
199
+ }
200
+
201
+ /** The line describing a rule that stopped itself, or undefined while it may still fire. */
202
+ export function endedLabel(rule: ScheduleRule): string | undefined {
203
+ if (rule.endedReason === undefined) return undefined
204
+ return t('detail.schedule.ended', { reason: t(stopReasonKey(rule.endedReason)) })
205
+ }
206
+
207
+ /** The line describing the last skipped occurrence, or undefined when none. */
208
+ export function skippedLabel(rule: ScheduleRule): string | undefined {
209
+ if (rule.skippedReason === undefined) return undefined
210
+ return t('detail.schedule.skipped', { reason: t(stopReasonKey(rule.skippedReason)) })
211
+ }
212
+
115
213
  /**
116
214
  * The full next-run label: the absolute wall clock in the schedule's own zone,
117
215
  * plus the relative distance in parentheses. Showing both matters because a
@@ -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,13 +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
- import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
19
+ import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule, type SetSchedulePatch } from './use-cases/task-schedule.ts'
20
20
  import { resolveHostTimeZone } from './schedule.ts'
21
21
  import { applySetParent } from './use-cases/task-parent.ts'
22
+ import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
22
23
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
23
24
  import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
24
25
  import type {
@@ -28,6 +29,10 @@ import type {
28
29
  TaskBoardParseRequest,
29
30
  TaskBoardSnapshot,
30
31
  } from '../protocol.ts'
32
+ import type {
33
+ TaskBoardExtensionActionRequest,
34
+ TaskBoardVisibilityPredicate,
35
+ } from './extension.ts'
31
36
 
32
37
  export interface TaskBoardTransport {
33
38
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
@@ -136,7 +141,7 @@ export interface ExecutionOptionsSnapshot {
136
141
  * (revision/scheduler/power); a full snapshot also carries the deployment
137
142
  * constants the UI reads (session default permission, subtask depth).
138
143
  */
139
- export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable'>
144
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'extensions'>
140
145
 
141
146
  /** Immutable controller snapshot for UI subscriptions. */
142
147
  export interface ControllerSnapshot {
@@ -154,6 +159,15 @@ export interface ControllerSnapshot {
154
159
  canParseTask?: boolean
155
160
  transportError?: string
156
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>>
157
171
  }
158
172
 
159
173
  /** The selected task (resolved from the ledger), or undefined. */
@@ -201,6 +215,8 @@ export class BoardController {
201
215
  private readonly taskQueues = new Map<string, Promise<void>>()
202
216
  private transportError: string | undefined
203
217
  private hostState: HostMirror | undefined
218
+ /** Provider visibility predicates (immutable array; replaced on change). */
219
+ private visibility: readonly TaskBoardVisibilityPredicate[] = []
204
220
  private remoteSubscribed = false
205
221
  private remoteInitialization: Promise<boolean> | undefined
206
222
 
@@ -251,9 +267,42 @@ export class BoardController {
251
267
  ...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
252
268
  ...(this.transportError === undefined ? {} : { transportError: this.transportError }),
253
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()
254
287
  }
255
288
  }
256
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
+
257
306
  subscribe(fn: () => void): () => void {
258
307
  this.listeners.add(fn)
259
308
  return () => { this.listeners.delete(fn) }
@@ -507,18 +556,58 @@ export class BoardController {
507
556
  return true
508
557
  }
509
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
+
510
597
  // --- scheduling ---------------------------------------------------------------
511
598
 
512
599
  /**
513
- * Update a task's schedule rule. A blank or invalid cron expression is
514
- * rejected (returns false, state untouched). When the rule ends up enabled
515
- * the next run instant is computed immediately; a disabled rule carries no
516
- * next-run instant. Delegates the domain transition to the schedule use case.
600
+ * Update a task's schedule rule. An unusable plan (blank or invalid cron, a
601
+ * one-shot with no future instant, a non-positive run cap) is rejected
602
+ * (returns false, state untouched). When the rule ends up enabled the next
603
+ * run instant is computed immediately — a one-shot's next instant IS its
604
+ * planned instant — and a disabled rule carries none. Delegates the domain
605
+ * transition to the schedule use case.
517
606
  * @param id - the task to schedule.
518
607
  * @param patch - fields to change (absent fields keep their current value).
519
- * @returns true when applied, false when rejected (invalid cron / unknown task).
608
+ * @returns true when applied, false when rejected (unknown task / unusable plan).
520
609
  */
521
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
610
+ setSchedule(id: string, patch: SetSchedulePatch): boolean {
522
611
  // The Host zone is the fallback a rule with no stored zone follows: the
523
612
  // mirrored snapshot carries it, and a controller without a Host mirror
524
613
  // (pure client tests) falls back to the process zone.
@@ -572,7 +661,9 @@ export class BoardController {
572
661
  */
573
662
  async runTask(id: string): Promise<boolean> {
574
663
  const task = this.tasks.find(candidate => candidate.id === id)
575
- if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
664
+ // Only an open execution blocks a second launch; the 'running' column is a
665
+ // board statement and a card parked there by hand still runs on request.
666
+ if (task === undefined || task.archivedAt !== undefined || hasOpenExecution(task)) return false
576
667
  if (this.deps.transport === undefined) return false
577
668
  return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
578
669
  }
@@ -724,6 +815,8 @@ export class BoardController {
724
815
  const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
725
816
  const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
726
817
  const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
818
+ const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
819
+ const extensions = snapshot.extensions ?? this.hostState?.extensions
727
820
  return {
728
821
  revision: snapshot.revision,
729
822
  scheduler: snapshot.scheduler,
@@ -731,6 +824,8 @@ export class BoardController {
731
824
  ...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
732
825
  ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
733
826
  ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
827
+ ...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
828
+ ...(extensions === undefined ? {} : { extensions }),
734
829
  }
735
830
  }
736
831