@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.
- package/README.i18n.yaml +2 -2
- package/README.md +56 -18
- package/README.zh.md +56 -18
- package/lib/client.js +6242 -958
- package/lib/client.js.map +1 -1
- package/lib/index.js +3475 -234
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +23 -2
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +141 -3
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +38 -1
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +110 -5
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +107 -11
- package/lib/types/host-runner.d.ts +99 -4
- package/lib/types/host-service.d.ts +114 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +53 -3
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +30 -15
- package/src/client/board/NewTaskModal.tsx +288 -151
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +238 -86
- package/src/client/board/TaskDetail.tsx +154 -59
- package/src/client/board/TaskForm.tsx +61 -6
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +602 -68
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +48 -6
- package/src/client/locales.ts +290 -12
- package/src/client/native-panel.tsx +18 -2
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +101 -5
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +26 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +138 -10
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +58 -24
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +342 -51
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +219 -20
- package/src/host-service.ts +346 -26
- package/src/index.ts +204 -16
- package/src/protocol.ts +122 -9
- package/src/tool-surface.ts +95 -0
- package/lib/types/host/run-guarded.d.ts +0 -39
- 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:
|
|
40
|
-
padding:
|
|
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:
|
|
55
|
-
padding:
|
|
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
|
-
|
|
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.
|
|
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)
|
package/src/core/controller.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|