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

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 (53) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/lib/client.js +1350 -552
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +655 -134
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/locales.d.ts +38 -2
  11. package/lib/types/client/schedule-zone.d.ts +47 -0
  12. package/lib/types/core/controller.d.ts +1 -0
  13. package/lib/types/core/degraded-reason.d.ts +46 -0
  14. package/lib/types/core/package-name.d.ts +9 -0
  15. package/lib/types/core/schedule.d.ts +95 -20
  16. package/lib/types/core/subtask.d.ts +30 -11
  17. package/lib/types/core/tasks.d.ts +26 -2
  18. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  19. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  20. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  21. package/lib/types/host-ledger.d.ts +56 -4
  22. package/lib/types/host-runner.d.ts +76 -3
  23. package/lib/types/host-service.d.ts +26 -0
  24. package/lib/types/protocol.d.ts +9 -3
  25. package/package.json +25 -25
  26. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  27. package/src/client/board/NewTaskModal.tsx +271 -150
  28. package/src/client/board/TaskCard.tsx +24 -5
  29. package/src/client/board/TaskDetail.tsx +83 -33
  30. package/src/client/board/TaskForm.tsx +48 -3
  31. package/src/client/board/preset-label.ts +44 -0
  32. package/src/client/board.module.css +114 -5
  33. package/src/client/host-api.ts +109 -6
  34. package/src/client/index.ts +5 -1
  35. package/src/client/locales.ts +82 -8
  36. package/src/client/schedule-zone.ts +135 -0
  37. package/src/core/controller.ts +7 -2
  38. package/src/core/degraded-reason.ts +81 -0
  39. package/src/core/package-name.ts +9 -0
  40. package/src/core/schedule.ts +211 -55
  41. package/src/core/store.ts +10 -1
  42. package/src/core/subtask.ts +77 -22
  43. package/src/core/tasks.ts +38 -4
  44. package/src/core/use-cases/task-create.ts +14 -2
  45. package/src/core/use-cases/task-schedule.ts +17 -6
  46. package/src/core/use-cases/task-update.ts +4 -1
  47. package/src/host/agent-tools.ts +40 -8
  48. package/src/host-ledger.ts +203 -23
  49. package/src/host-runner.ts +164 -14
  50. package/src/host-service.ts +94 -17
  51. package/src/protocol.ts +27 -8
  52. package/lib/types/host/run-guarded.d.ts +0 -39
  53. package/src/host/run-guarded.ts +0 -76
@@ -372,8 +372,12 @@ export function apply(ctx: ClientContext): void {
372
372
  console.error('[dsh-task-board] model options read failed', error)
373
373
  }
374
374
  }
375
+ void pushPresetOptions()
375
376
  void pushModelOptions()
376
- disposers.push(ctx.on('connection/reset', () => { void pushModelOptions() }))
377
+ disposers.push(ctx.on('connection/reset', () => {
378
+ void pushPresetOptions()
379
+ void pushModelOptions()
380
+ }))
377
381
  // Native panel surfaces: one sidebar row and one center-column page, both
378
382
  // through the official slot seats the shell itself renders. The layout's
379
383
  // panel selection is reconciled back into the controller so the board's own
@@ -29,8 +29,11 @@ export const zh = {
29
29
  'board.updated': '更新于',
30
30
  'board.created': '创建于',
31
31
  'board.hostError': 'Host 操作失败:{error}',
32
- 'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主可能没有加载该插件,也可能后台数据正被另一个 DSH 实例占用。重启 DSH 服务后再试',
32
+ 'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主没有加载这个插件(可能未安装、未启用,或该行启动失败)。重启 DSH 服务后再试',
33
+ 'board.hostError.ledgerLocked': '任务看板的账本已被另一个 DSH 进程占用(占用者 pid {pid}),本实例没有注册后台接口:关闭那个 DSH 实例,或等它退出后再点重试——反复重启本实例无效(宿主原因:{detail})',
34
+ 'board.hostError.degraded': '任务看板这一行在启动时降级,后台接口没有注册(宿主原因:{detail})',
33
35
  'board.hostError.unauthorized': '登录状态已失效,请刷新页面后重试',
36
+ 'board.hostError.forbidden': '当前环境未通过同源校验:桌面外壳或反向代理可能未传递浏览器标记,也没有携带宿主认证凭据',
34
37
  'board.hostError.locked': '任务看板的后台数据被占用:{detail}',
35
38
  'board.hostError.timeout': '后台 {seconds} 秒内没有响应,请稍后重试',
36
39
  'board.hostError.unreachable': '连接不上本机 DSH 服务,请确认它仍在运行',
@@ -55,6 +58,20 @@ export const zh = {
55
58
  'new.aiParseFailed': '解析失败:{error}',
56
59
  'board.retryHost': '重试连接 Host',
57
60
  'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
61
+ // 新建/编辑对话框的区域分组:折叠后每个区域仍以一行摘要显示当前配置。
62
+ 'new.section.parse': 'AI 解析',
63
+ 'new.section.content': '任务内容',
64
+ 'new.section.labels': '标签',
65
+ 'new.section.execution': '执行设置',
66
+ 'new.section.run': '运行方式',
67
+ 'new.section.handover': '交接与续跑',
68
+ 'new.section.schedule': '定时运行',
69
+ 'new.summary.none': '未设置',
70
+ 'new.summary.labelCount': '{count} 个标签',
71
+ 'new.summary.filled': '已填写',
72
+ 'new.summary.scheduleOff': '未启用',
73
+ 'new.summary.multiRound': '多轮 /goal',
74
+ 'new.summary.singleRound': '单轮',
58
75
  'new.title': '标题',
59
76
  'new.titlePlaceholder': '一句话描述要做什么',
60
77
  'new.description': '描述',
@@ -146,13 +163,31 @@ export const zh = {
146
163
  'detail.schedule.invalid': 'Cron 表达式无效',
147
164
  'detail.schedule.notScheduled': '尚未排程',
148
165
  'detail.schedule.dueSoon': '即将运行',
166
+ 'detail.schedule.timeZone': '时区',
167
+ 'detail.schedule.timeZoneHost': 'Host 时区({timeZone})',
168
+ 'detail.schedule.timeZoneHostUnknown': 'Host 时区(未知)',
169
+ 'detail.schedule.timeZoneHint': 'Cron 的墙上时间按此时区解读;夏令时跳变会跳过不存在的时刻,重复的时刻只触发一次。',
170
+ 'detail.schedule.timeZoneSearch': '搜索时区…',
171
+ 'detail.schedule.timeZoneNone': '没有匹配的时区',
172
+ 'detail.schedule.nextRunOverdue': '已逾期 {duration}',
173
+ 'detail.schedule.duration.days': '{count} 天',
174
+ 'detail.schedule.duration.hours': '{count} 小时',
175
+ 'detail.schedule.duration.minutes': '{count} 分钟',
176
+ 'detail.schedule.duration.seconds': '{count} 秒',
149
177
  'card.scheduled': '定时',
150
178
  'new.workspace': '工作区',
151
- 'new.mode': '模式',
179
+ 'new.agentPreset': 'Agent 预设',
152
180
  'new.permission': '权限',
153
181
  'new.model': '模型',
154
182
  'exec.workspace.recent': '最近使用(默认)',
155
- 'exec.mode.default': '部署默认',
183
+ 'exec.mode.inherit': '继承(部署默认)',
184
+ 'exec.mode.inheritWith': '继承(部署默认:{preset})',
185
+ 'exec.mode.builtinGroup': '内置预设',
186
+ 'exec.mode.customGroup': '自定义预设',
187
+ 'preset.builtin.standard': '标准',
188
+ 'preset.builtin.ptc': 'PTC(程序化工具调用)',
189
+ 'preset.builtin.minimal': '极简',
190
+ 'preset.builtin.cordis': 'Cordis',
156
191
  'exec.mode.defaultSuffix': '(默认)',
157
192
  'exec.mode.brokenSuffix': '(不可用)',
158
193
  'exec.mode.removed': '(已移除)',
@@ -168,8 +203,10 @@ export const zh = {
168
203
  'exec.teamRunHint': '开启后:执行本任务只启动一个 Leader 会话,每个子任务由它派生为 teammate 并在该会话内并行工作(子任务自身的权限钉住不适用)。关闭则每个成员各开独立会话。',
169
204
  'exec.teamRunUnavailable': '当前部署未提供 Agent Teams 服务,无法启用。',
170
205
  'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
206
+ 'exec.goalRun': '以 dsh 内置的 /goal 开始执行任务',
207
+ 'exec.goalRunHint': '默认开启:执行时把任务目标作为持久目标交给 dsh 内置的 /goal,会话会自动续跑多轮,直到 agent 标记目标完成;看板在该目标真正结束后才结算本次执行。关闭则只执行一轮普通对话。',
171
208
  'detail.executionSettings': '执行设置',
172
- 'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
209
+ 'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;Agent 预设决定会话的组合(工具面与提示词段);权限经 /permission 命令应用到会话。留空则继承部署默认。',
173
210
  // 插件设置卡片(settings.plugin.item 席位)。
174
211
  'settings.title': '任务看板',
175
212
  'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
@@ -254,8 +291,11 @@ export const en: Record<keyof typeof zh, string> = {
254
291
  'board.updated': 'Updated',
255
292
  'board.created': 'Created',
256
293
  'board.hostError': 'Host action failed: {error}',
257
- 'board.hostError.notMounted': 'The task board Host API is not mounted: the Host may not have loaded the plugin, or another DSH instance holds the ledger. Restart the DSH service and try again',
294
+ 'board.hostError.notMounted': 'The task board Host API is not mounted: the Host did not load this plugin (not installed, disabled, or its row failed to start). Restart the DSH service and try again',
295
+ 'board.hostError.ledgerLocked': 'Another DSH process (pid {pid}) holds the task board ledger, so this instance registered no Host API: close that DSH instance, or wait for it to exit and retry. Restarting this instance cannot help (reason: {detail})',
296
+ 'board.hostError.degraded': 'The task board row degraded during start, so its Host API was not registered: {detail}',
258
297
  'board.hostError.unauthorized': 'This session is no longer signed in; reload the page and try again',
298
+ 'board.hostError.forbidden': 'The request did not pass the same-origin fence: the desktop shell or reverse proxy may not have forwarded browser credentials',
259
299
  'board.hostError.locked': 'The task board ledger is locked: {detail}',
260
300
  'board.hostError.timeout': 'The Host did not answer within {seconds}s; try again shortly',
261
301
  'board.hostError.unreachable': 'Cannot reach the local DSH service; make sure it is still running',
@@ -280,6 +320,20 @@ export const en: Record<keyof typeof zh, string> = {
280
320
  'new.aiParseFailed': 'Parsing failed: {error}',
281
321
  'board.retryHost': 'Retry Host connection',
282
322
  'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
323
+ // Create/edit dialog regions: a collapsed region still shows a one-line summary of its values.
324
+ 'new.section.parse': 'AI Parse',
325
+ 'new.section.content': 'Task Content',
326
+ 'new.section.labels': 'Tags',
327
+ 'new.section.execution': 'Execution Settings',
328
+ 'new.section.run': 'Run Mode',
329
+ 'new.section.handover': 'Handover',
330
+ 'new.section.schedule': 'Scheduled Runs',
331
+ 'new.summary.none': 'Not set',
332
+ 'new.summary.labelCount': '{count} tags',
333
+ 'new.summary.filled': 'Filled',
334
+ 'new.summary.scheduleOff': 'Off',
335
+ 'new.summary.multiRound': 'multi-round /goal',
336
+ 'new.summary.singleRound': 'single round',
283
337
  'new.title': 'Title',
284
338
  'new.titlePlaceholder': 'What should be done, in one line',
285
339
  'new.description': 'Description',
@@ -371,13 +425,31 @@ export const en: Record<keyof typeof zh, string> = {
371
425
  'detail.schedule.invalid': 'Invalid cron expression',
372
426
  'detail.schedule.notScheduled': 'Not scheduled yet',
373
427
  'detail.schedule.dueSoon': 'Due soon',
428
+ 'detail.schedule.timeZone': 'Time zone',
429
+ 'detail.schedule.timeZoneHost': 'Host time zone ({timeZone})',
430
+ 'detail.schedule.timeZoneHostUnknown': 'Host time zone (unknown)',
431
+ 'detail.schedule.timeZoneHint': 'The cron wall clock is read in this zone; daylight-saving gaps are skipped and a repeated time fires once.',
432
+ 'detail.schedule.timeZoneSearch': 'Search time zones…',
433
+ 'detail.schedule.timeZoneNone': 'No matching time zone',
434
+ 'detail.schedule.nextRunOverdue': 'Overdue by {duration}',
435
+ 'detail.schedule.duration.days': '{count}d',
436
+ 'detail.schedule.duration.hours': '{count}h',
437
+ 'detail.schedule.duration.minutes': '{count}m',
438
+ 'detail.schedule.duration.seconds': '{count}s',
374
439
  'card.scheduled': 'scheduled',
375
440
  'new.workspace': 'Workspace',
376
- 'new.mode': 'Mode',
441
+ 'new.agentPreset': 'Agent preset',
377
442
  'new.permission': 'Permission',
378
443
  'new.model': 'Model',
379
444
  'exec.workspace.recent': 'Most recent (default)',
380
- 'exec.mode.default': 'Deployment default',
445
+ 'exec.mode.inherit': 'Inherit (deployment default)',
446
+ 'exec.mode.inheritWith': 'Inherit (deployment default: {preset})',
447
+ 'exec.mode.builtinGroup': 'Built-in presets',
448
+ 'exec.mode.customGroup': 'Custom presets',
449
+ 'preset.builtin.standard': 'Standard',
450
+ 'preset.builtin.ptc': 'PTC (programmatic tool calling)',
451
+ 'preset.builtin.minimal': 'Minimal',
452
+ 'preset.builtin.cordis': 'Cordis',
381
453
  'exec.mode.defaultSuffix': ' (default)',
382
454
  'exec.mode.brokenSuffix': ' (unavailable)',
383
455
  'exec.mode.removed': ' (removed)',
@@ -393,8 +465,10 @@ export const en: Record<keyof typeof zh, string> = {
393
465
  'exec.teamRunHint': 'On: running this task starts one Lead session and spawns a teammate per subtask inside it, working in parallel (a subtask permission pin does not apply). Off: every member gets its own independent session.',
394
466
  'exec.teamRunUnavailable': 'This deployment serves no Agent Teams service, so team runs cannot be enabled.',
395
467
  'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
468
+ 'exec.goalRun': "Start the run with dsh's built-in /goal",
469
+ 'exec.goalRunHint': 'On by default: the run arms dsh built-in /goal with the task objective, so the session keeps working automatic continuation rounds until the agent marks the goal complete, and the board settles the execution only when that goal really ends. Off: one plain turn.',
396
470
  'detail.executionSettings': 'Execution Settings',
397
- 'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
471
+ 'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the agent preset composes the session (tool face and prompt sections); the permission is applied through the /permission command. Blank inherits the deployment default.',
398
472
  // Plugin settings card (the `settings.plugin.item` seat).
399
473
  'settings.title': 'Task Board',
400
474
  'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
@@ -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
+ }
@@ -17,6 +17,7 @@ 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'
21
22
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
22
23
  import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
@@ -517,8 +518,12 @@ export class BoardController {
517
518
  * @param patch - fields to change (absent fields keep their current value).
518
519
  * @returns true when applied, false when rejected (invalid cron / unknown task).
519
520
  */
520
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
521
- const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
521
+ setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
522
+ // The Host zone is the fallback a rule with no stored zone follows: the
523
+ // mirrored snapshot carries it, and a controller without a Host mirror
524
+ // (pure client tests) falls back to the process zone.
525
+ const hostTimeZone = this.hostState?.scheduler.timeZone ?? resolveHostTimeZone()
526
+ const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now(), hostTimeZone)
522
527
  if (!applied) return false
523
528
  if (this.deps.transport !== undefined) {
524
529
  void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * npm identity of this plugin. The dsh-web-all degraded ledger is keyed by the
3
+ * real plugin package name of a family row, and this plugin's browser half
4
+ * must name its OWN row there to learn why its Host API is missing (issue
5
+ * #1730): the served bundle cannot derive the name, because a family row
6
+ * mounts the package from a versioned profile path. The host half spells the
7
+ * same name in its single-mount guard (src/index.ts); the two move together.
8
+ */
9
+ export const TASK_BOARD_PACKAGE = '@linxin666/dsh-client-ui-task-board'