@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.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -10
- package/README.zh.md +17 -10
- package/lib/client.js +1350 -552
- package/lib/client.js.map +1 -1
- package/lib/index.js +655 -134
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/core/controller.d.ts +1 -0
- package/lib/types/core/degraded-reason.d.ts +46 -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 +26 -2
- 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-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +56 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +26 -0
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +25 -25
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +114 -5
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +82 -8
- package/src/client/schedule-zone.ts +135 -0
- package/src/core/controller.ts +7 -2
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +203 -23
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +94 -17
- package/src/protocol.ts +27 -8
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* execution's session transcript.
|
|
6
6
|
*/
|
|
7
7
|
import { useEffect, useState } from 'react'
|
|
8
|
-
import type { BoardController } from '../../core/controller.ts'
|
|
8
|
+
import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
10
|
import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
11
11
|
import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
|
|
@@ -13,11 +13,13 @@ import { requiresPermissionConfirmation } from '../../core/handover.ts'
|
|
|
13
13
|
import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
|
|
14
14
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
15
15
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
16
|
+
import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
|
|
16
17
|
import css from '../board.module.css'
|
|
17
18
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
18
19
|
import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
|
|
19
20
|
import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
|
|
20
21
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
22
|
+
import { inheritPresetLabel, presetLabel } from './preset-label.ts'
|
|
21
23
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
22
24
|
import { STATUS_KEY } from './status-key.ts'
|
|
23
25
|
|
|
@@ -63,20 +65,16 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
66
|
-
function ExecutionSettingsSection({ controller, task, pending
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
setTeamRunAvailable(snapshot.host?.teamRunAvailable === true)
|
|
77
|
-
}),
|
|
78
|
-
[controller],
|
|
79
|
-
)
|
|
68
|
+
function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
|
|
69
|
+
controller: BoardController
|
|
70
|
+
task: TaskRecord
|
|
71
|
+
pending: boolean
|
|
72
|
+
/** Picker option sets, owned by the detail overlay's snapshot. */
|
|
73
|
+
executionOptions: ControllerSnapshot['executionOptions']
|
|
74
|
+
/** Whether this deployment serves the Agent Teams service. */
|
|
75
|
+
teamRunAvailable: boolean
|
|
76
|
+
}) {
|
|
77
|
+
const options = executionOptions
|
|
80
78
|
const workspaceId = task.workspaceId ?? ''
|
|
81
79
|
const mode = task.mode ?? ''
|
|
82
80
|
const permission = task.permission ?? ''
|
|
@@ -107,18 +105,18 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
|
|
|
107
105
|
</select>
|
|
108
106
|
</label>
|
|
109
107
|
<label className={css.field}>
|
|
110
|
-
<span className={css.fieldLabel}>{t('new.
|
|
108
|
+
<span className={css.fieldLabel}>{t('new.agentPreset')}</span>
|
|
111
109
|
<select
|
|
112
110
|
className={css.select}
|
|
113
111
|
value={mode}
|
|
114
112
|
disabled={pending}
|
|
115
113
|
onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
|
|
116
114
|
>
|
|
117
|
-
<option value="">{
|
|
115
|
+
<option value="">{inheritPresetLabel(options.presets)}</option>
|
|
118
116
|
{!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
|
|
119
117
|
{options.presets.map(preset => (
|
|
120
118
|
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
121
|
-
{preset
|
|
119
|
+
{presetLabel(preset)}
|
|
122
120
|
{preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
|
|
123
121
|
{preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
|
|
124
122
|
</option>
|
|
@@ -164,6 +162,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
|
|
|
164
162
|
<span>{t('exec.reuseSession')}</span>
|
|
165
163
|
</label>
|
|
166
164
|
<p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
|
|
165
|
+
<label className={css.scheduleToggle}>
|
|
166
|
+
<input
|
|
167
|
+
type="checkbox"
|
|
168
|
+
checked={task.goalRun !== false}
|
|
169
|
+
disabled={pending}
|
|
170
|
+
onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
|
|
171
|
+
/>
|
|
172
|
+
<span>{t('exec.goalRun')}</span>
|
|
173
|
+
</label>
|
|
174
|
+
<p className={css.detailText}>{t('exec.goalRunHint')}</p>
|
|
167
175
|
<label className={css.scheduleToggle}>
|
|
168
176
|
<input
|
|
169
177
|
type="checkbox"
|
|
@@ -178,7 +186,7 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
|
|
|
178
186
|
)
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
189
|
+
/** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
|
|
182
190
|
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
183
191
|
const schedule = task.schedule
|
|
184
192
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
@@ -186,7 +194,12 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
186
194
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
187
195
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
188
196
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
189
|
-
const
|
|
197
|
+
const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
198
|
+
// The rule's own zone; '' means "follow the Host zone" (no stored zone).
|
|
199
|
+
const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
|
|
200
|
+
// One clock reading per render pass, so the absolute and relative halves of
|
|
201
|
+
// the label always describe the same instant.
|
|
202
|
+
const now = Date.now()
|
|
190
203
|
|
|
191
204
|
// Keep the editor in sync when the task record changes underneath (the
|
|
192
205
|
// schedule rolls forward as runs trigger).
|
|
@@ -195,8 +208,9 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
195
208
|
setEnabled(schedule?.enabled ?? false)
|
|
196
209
|
setNextRunAt(schedule?.nextRunAt)
|
|
197
210
|
setLastTriggeredAt(schedule?.lastTriggeredAt)
|
|
211
|
+
setZone(schedule?.timeZone ?? '')
|
|
198
212
|
setError(undefined)
|
|
199
|
-
}, [task.id, schedule?.enabled, schedule?.cron, schedule?.nextRunAt, schedule?.lastTriggeredAt])
|
|
213
|
+
}, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
|
|
200
214
|
|
|
201
215
|
/** Validate + persist the current cron text (Enter or blur). */
|
|
202
216
|
const saveCron = (value: string): void => {
|
|
@@ -210,6 +224,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
210
224
|
controller.setSchedule(task.id, { cron: trimmed })
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Change the rule's zone. `''` clears the stored zone, which is how a user
|
|
229
|
+
* returns a rule to following the Host zone.
|
|
230
|
+
*/
|
|
231
|
+
const changeZone = (value: string): void => {
|
|
232
|
+
setZone(value)
|
|
233
|
+
setError(undefined)
|
|
234
|
+
controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
|
|
235
|
+
}
|
|
236
|
+
|
|
213
237
|
/** Arm/disarm the schedule (arming first persists the edited cron). */
|
|
214
238
|
const toggleEnabled = (next: boolean): void => {
|
|
215
239
|
const trimmed = cron.trim()
|
|
@@ -232,12 +256,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
232
256
|
controller.setSchedule(task.id, { cron: preset })
|
|
233
257
|
}
|
|
234
258
|
|
|
259
|
+
// The zone the rule's wall clock is actually read in: its own when set,
|
|
260
|
+
// otherwise the Host zone. The preview must use the same one the Host will.
|
|
261
|
+
const effectiveZone = zone === '' ? hostTimeZone : zone
|
|
235
262
|
const nextLabel = !enabled || nextRunAt === undefined
|
|
236
263
|
? t('detail.schedule.notScheduled')
|
|
237
|
-
: nextRunAt <=
|
|
264
|
+
: nextRunAt <= now
|
|
238
265
|
? t('detail.schedule.dueSoon')
|
|
239
|
-
:
|
|
240
|
-
const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt,
|
|
266
|
+
: nextRunLabel(nextRunAt, effectiveZone, formatHostTimestamp, now)
|
|
267
|
+
const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, effectiveZone)
|
|
268
|
+
// The rule's own stored zone rides the list even when this runtime's
|
|
269
|
+
// inventory does not carry it, so opening the editor cannot rewrite it.
|
|
270
|
+
const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
|
|
241
271
|
|
|
242
272
|
return (
|
|
243
273
|
<section className={css.detailSection}>
|
|
@@ -276,6 +306,23 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
276
306
|
))}
|
|
277
307
|
</select>
|
|
278
308
|
</div>
|
|
309
|
+
{/* A list is used instead of a free-text field so only zones the Host can
|
|
310
|
+
also resolve are offered; the Host zone entry clears the stored value. */}
|
|
311
|
+
<label className={css.scheduleZone}>
|
|
312
|
+
<span>{t('detail.schedule.timeZone')}</span>
|
|
313
|
+
<select
|
|
314
|
+
className={css.schedulePreset}
|
|
315
|
+
value={zone}
|
|
316
|
+
disabled={pending}
|
|
317
|
+
aria-label={t('detail.schedule.timeZone')}
|
|
318
|
+
title={t('detail.schedule.timeZoneHint')}
|
|
319
|
+
onChange={event => { changeZone(event.target.value) }}
|
|
320
|
+
>
|
|
321
|
+
{zones.map(choice => (
|
|
322
|
+
<option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
|
|
323
|
+
))}
|
|
324
|
+
</select>
|
|
325
|
+
</label>
|
|
279
326
|
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
280
327
|
<p className={css.scheduleMeta}>
|
|
281
328
|
{t('detail.schedule.nextRun')} {nextLabel}
|
|
@@ -290,17 +337,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
290
337
|
* and the actions that grow or prune the tree. Every gate here mirrors the
|
|
291
338
|
* Host lineage gate for affordance only; the Host re-checks the action.
|
|
292
339
|
*/
|
|
293
|
-
function SubtaskSection({ controller, task, pending, archived }: {
|
|
340
|
+
function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
294
341
|
controller: BoardController
|
|
295
342
|
task: TaskRecord
|
|
296
343
|
pending: boolean
|
|
297
344
|
archived: boolean
|
|
345
|
+
/** Detail-overlay snapshot; the overlay already re-renders on every notify. */
|
|
346
|
+
snapshot: ControllerSnapshot
|
|
298
347
|
}) {
|
|
299
|
-
const [snapshot, setSnapshot] = useState(controller.getSnapshot())
|
|
300
|
-
useEffect(
|
|
301
|
-
() => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
|
|
302
|
-
[controller],
|
|
303
|
-
)
|
|
304
348
|
const [showAdd, setShowAdd] = useState(false)
|
|
305
349
|
const [showLink, setShowLink] = useState(false)
|
|
306
350
|
const tasks = snapshot.tasks
|
|
@@ -440,7 +484,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
440
484
|
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
441
485
|
</section>
|
|
442
486
|
|
|
443
|
-
<SubtaskSection controller={controller} task={current} pending={pending} archived={archived} />
|
|
487
|
+
<SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
|
|
444
488
|
|
|
445
489
|
{current.tags !== undefined && current.tags.length > 0 && (
|
|
446
490
|
<section className={css.detailSection} data-dsh-part="tags">
|
|
@@ -484,7 +528,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
484
528
|
<h4>{t('detail.handover')}</h4>
|
|
485
529
|
<p className={css.detailText}>
|
|
486
530
|
{t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
|
|
487
|
-
{' · '}{t('new.
|
|
531
|
+
{' · '}{t('new.agentPreset')}: {current.handover.mode ?? t('exec.mode.inherit')}
|
|
488
532
|
{' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
|
|
489
533
|
</p>
|
|
490
534
|
<p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
|
|
@@ -518,7 +562,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
518
562
|
|
|
519
563
|
{!archived && (
|
|
520
564
|
<>
|
|
521
|
-
<ExecutionSettingsSection
|
|
565
|
+
<ExecutionSettingsSection
|
|
566
|
+
controller={controller}
|
|
567
|
+
task={current}
|
|
568
|
+
pending={pending}
|
|
569
|
+
executionOptions={snapshot.executionOptions}
|
|
570
|
+
teamRunAvailable={snapshot.host?.teamRunAvailable === true}
|
|
571
|
+
/>
|
|
522
572
|
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
523
573
|
</>
|
|
524
574
|
)}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* NewTaskModal and the EditTaskModal. State stays in the owning modal; these
|
|
5
5
|
* are controlled components.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { useState, type ReactNode } from 'react'
|
|
8
8
|
import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
|
|
9
9
|
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
@@ -45,7 +45,7 @@ export function ModalShell({
|
|
|
45
45
|
>
|
|
46
46
|
<h2 className={css.modalTitle}>{title}</h2>
|
|
47
47
|
|
|
48
|
-
{children}
|
|
48
|
+
<div className={css.modalBody}>{children}</div>
|
|
49
49
|
|
|
50
50
|
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
51
51
|
|
|
@@ -72,6 +72,51 @@ export function ModalShell({
|
|
|
72
72
|
)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* One collapsible region of a task form (create/edit dialog UX): the form
|
|
77
|
+
* groups its configuration into a few regions instead of stacking every field
|
|
78
|
+
* in one long column, so a normal-sized dialog needs no scrollbar. A collapsed
|
|
79
|
+
* region keeps a one-line summary of the values it holds, so nothing is hidden
|
|
80
|
+
* without a trace; a region holding a blocking error is forced open by its
|
|
81
|
+
* owner (forceOpen) rather than reporting the error out of sight.
|
|
82
|
+
*/
|
|
83
|
+
export function CollapsibleSection({
|
|
84
|
+
title,
|
|
85
|
+
summary,
|
|
86
|
+
defaultOpen = false,
|
|
87
|
+
forceOpen = false,
|
|
88
|
+
children,
|
|
89
|
+
}: {
|
|
90
|
+
title: string
|
|
91
|
+
/** One-line value preview rendered only while the region is collapsed. */
|
|
92
|
+
summary?: string | undefined
|
|
93
|
+
/** Whether the region starts expanded (the owner's default layout). */
|
|
94
|
+
defaultOpen?: boolean
|
|
95
|
+
/** Open regardless of the stored state: an error inside must stay visible. */
|
|
96
|
+
forceOpen?: boolean
|
|
97
|
+
children: ReactNode
|
|
98
|
+
}) {
|
|
99
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
100
|
+
const expanded = forceOpen || open
|
|
101
|
+
return (
|
|
102
|
+
<section className={css.formSection} data-dsh-part="form-section" data-open={expanded ? 'true' : undefined}>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={css.formSectionHeader}
|
|
106
|
+
aria-expanded={expanded}
|
|
107
|
+
onClick={() => { setOpen(value => !value) }}
|
|
108
|
+
>
|
|
109
|
+
<span className={css.formSectionChevron} aria-hidden="true" />
|
|
110
|
+
<span className={css.formSectionTitle}>{title}</span>
|
|
111
|
+
{!expanded && summary !== undefined && summary !== '' && (
|
|
112
|
+
<span className={css.formSectionSummary}>{summary}</span>
|
|
113
|
+
)}
|
|
114
|
+
</button>
|
|
115
|
+
{expanded && <div className={css.formSectionBody}>{children}</div>}
|
|
116
|
+
</section>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
75
120
|
/** Title + description + prompt fields shared by the new and edit task forms. */
|
|
76
121
|
export function TaskContentFields({
|
|
77
122
|
title,
|
|
@@ -106,7 +151,7 @@ export function TaskContentFields({
|
|
|
106
151
|
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
107
152
|
<textarea
|
|
108
153
|
className={css.input}
|
|
109
|
-
rows={
|
|
154
|
+
rows={2}
|
|
110
155
|
value={description}
|
|
111
156
|
placeholder={t('new.descriptionPlaceholder')}
|
|
112
157
|
onChange={event => onDescriptionChange(event.target.value)}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only labels for the agent-preset roster, shared by the task form and
|
|
3
|
+
* the detail view.
|
|
4
|
+
*
|
|
5
|
+
* The deployment's four built-in presets (the `standard` / `ptc` / `minimal` /
|
|
6
|
+
* `cordis` rows the shipped web-app bundle declares) carry no display name in
|
|
7
|
+
* the registry roster, so a picker would list bare ids beside the user presets.
|
|
8
|
+
* The map below only supplies names for those four; any other id — a renamed
|
|
9
|
+
* built-in, a community preset, a user preset — falls back to the roster name
|
|
10
|
+
* and then to the id, so this table can never hide or mislabel a preset.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExecutionPresetOption } from '../../core/controller.ts'
|
|
13
|
+
import { t, type TaskBoardKey } from '../locales.ts'
|
|
14
|
+
|
|
15
|
+
/** Built-in preset id -> display-name key (display sugar only). */
|
|
16
|
+
const BUILTIN_PRESET_LABELS: Record<string, TaskBoardKey> = {
|
|
17
|
+
standard: 'preset.builtin.standard',
|
|
18
|
+
ptc: 'preset.builtin.ptc',
|
|
19
|
+
minimal: 'preset.builtin.minimal',
|
|
20
|
+
cordis: 'preset.builtin.cordis',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether one roster row is a shipped built-in preset. */
|
|
24
|
+
export function isBuiltinPreset(id: string): boolean {
|
|
25
|
+
return BUILTIN_PRESET_LABELS[id] !== undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Display label of one preset row: built-in label, roster name, or raw id. */
|
|
29
|
+
export function presetLabel(preset: Pick<ExecutionPresetOption, 'id' | 'name'>): string {
|
|
30
|
+
const builtin = BUILTIN_PRESET_LABELS[preset.id]
|
|
31
|
+
if (builtin !== undefined && (preset.name ?? '').trim() === '') return t(builtin)
|
|
32
|
+
return preset.name ?? preset.id
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Label of the empty ("inherit") choice: names the preset a run actually lands
|
|
37
|
+
* on when the card pins nothing, which is the deployment default the roster
|
|
38
|
+
* marks with `isDefault`.
|
|
39
|
+
*/
|
|
40
|
+
export function inheritPresetLabel(presets: readonly ExecutionPresetOption[]): string {
|
|
41
|
+
const fallback = presets.find(preset => preset.isDefault)
|
|
42
|
+
if (fallback === undefined) return t('exec.mode.inherit')
|
|
43
|
+
return t('exec.mode.inheritWith', { preset: presetLabel(fallback) })
|
|
44
|
+
}
|
|
@@ -415,10 +415,10 @@
|
|
|
415
415
|
.modal {
|
|
416
416
|
display: flex;
|
|
417
417
|
flex-direction: column;
|
|
418
|
-
gap:
|
|
418
|
+
gap: 10px;
|
|
419
419
|
width: min(520px, calc(100vw - 48px));
|
|
420
420
|
max-height: calc(100vh - 96px);
|
|
421
|
-
overflow
|
|
421
|
+
overflow: hidden;
|
|
422
422
|
padding: 18px;
|
|
423
423
|
background: var(--dsw-alias-bg-base);
|
|
424
424
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
@@ -449,6 +449,95 @@
|
|
|
449
449
|
margin-top: 4px;
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
/* --- form sections (create/edit dialog) --------------------------------------
|
|
453
|
+
* The task form groups its configuration into collapsible regions so the
|
|
454
|
+
* dialog fits without a scrollbar: only the content region starts open, and
|
|
455
|
+
* every collapsed header carries a one-line summary of what it holds. The
|
|
456
|
+
* body keeps an overflow fallback for tiny viewports. The chevron is drawn
|
|
457
|
+
* with borders (no glyph), so no font or emoji can change it.
|
|
458
|
+
* ----------------------------------------------------------------------------- */
|
|
459
|
+
|
|
460
|
+
.modalBody {
|
|
461
|
+
display: flex;
|
|
462
|
+
flex-direction: column;
|
|
463
|
+
gap: 8px;
|
|
464
|
+
min-height: 0;
|
|
465
|
+
overflow-y: auto;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* Every direct child keeps its natural height: a column flex item shrinks by
|
|
469
|
+
default, and a shrunk region would clip its own fields behind its
|
|
470
|
+
`overflow: hidden` instead of letting the body scroll. */
|
|
471
|
+
.modalBody > * {
|
|
472
|
+
flex: none;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.formSection {
|
|
476
|
+
display: flex;
|
|
477
|
+
flex-direction: column;
|
|
478
|
+
border: 1px solid var(--dsw-alias-separator-primary);
|
|
479
|
+
border-radius: 10px;
|
|
480
|
+
overflow: hidden;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.formSectionHeader {
|
|
484
|
+
display: flex;
|
|
485
|
+
align-items: center;
|
|
486
|
+
gap: 8px;
|
|
487
|
+
width: 100%;
|
|
488
|
+
padding: 7px 10px;
|
|
489
|
+
font-family: inherit;
|
|
490
|
+
font-size: 12.5px;
|
|
491
|
+
font-weight: 600;
|
|
492
|
+
color: var(--dsw-alias-label-primary);
|
|
493
|
+
text-align: left;
|
|
494
|
+
background: transparent;
|
|
495
|
+
border: 0;
|
|
496
|
+
cursor: pointer;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.formSectionHeader:hover {
|
|
500
|
+
background: var(--dsw-alias-bg-mask-1);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.formSectionChevron {
|
|
504
|
+
flex: none;
|
|
505
|
+
width: 0;
|
|
506
|
+
height: 0;
|
|
507
|
+
margin-left: 1px;
|
|
508
|
+
border-top: 4px solid transparent;
|
|
509
|
+
border-bottom: 4px solid transparent;
|
|
510
|
+
border-left: 5px solid var(--dsw-alias-label-tertiary);
|
|
511
|
+
transition: transform 120ms ease;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.formSection[data-open='true'] .formSectionChevron {
|
|
515
|
+
transform: rotate(90deg);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.formSectionTitle {
|
|
519
|
+
flex: none;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.formSectionSummary {
|
|
523
|
+
flex: 1 1 auto;
|
|
524
|
+
min-width: 0;
|
|
525
|
+
overflow: hidden;
|
|
526
|
+
font-weight: 400;
|
|
527
|
+
color: var(--dsw-alias-label-tertiary);
|
|
528
|
+
text-align: right;
|
|
529
|
+
text-overflow: ellipsis;
|
|
530
|
+
white-space: nowrap;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.formSectionBody {
|
|
534
|
+
display: flex;
|
|
535
|
+
flex-direction: column;
|
|
536
|
+
gap: 8px;
|
|
537
|
+
padding: 8px 10px 10px;
|
|
538
|
+
border-top: 1px solid var(--dsw-alias-separator-primary);
|
|
539
|
+
}
|
|
540
|
+
|
|
452
541
|
.field {
|
|
453
542
|
display: flex;
|
|
454
543
|
flex-direction: column;
|
|
@@ -635,6 +724,21 @@
|
|
|
635
724
|
outline: none;
|
|
636
725
|
}
|
|
637
726
|
|
|
727
|
+
/* Zone row: the label and the picker share the cron row's geometry so the two
|
|
728
|
+
controls read as one editor. */
|
|
729
|
+
.scheduleZone {
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: center;
|
|
732
|
+
gap: 8px;
|
|
733
|
+
font-size: 12.5px;
|
|
734
|
+
color: var(--dsw-alias-label-secondary);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.scheduleZone select {
|
|
738
|
+
flex: 1;
|
|
739
|
+
min-width: 0;
|
|
740
|
+
}
|
|
741
|
+
|
|
638
742
|
.scheduleMeta {
|
|
639
743
|
margin: 0;
|
|
640
744
|
font-size: 12px;
|
|
@@ -942,7 +1046,8 @@
|
|
|
942
1046
|
.input:focus-visible,
|
|
943
1047
|
.select:focus-visible,
|
|
944
1048
|
.schedulePreset:focus-visible,
|
|
945
|
-
.scheduleToggle input:focus-visible
|
|
1049
|
+
.scheduleToggle input:focus-visible,
|
|
1050
|
+
.formSectionHeader:focus-visible {
|
|
946
1051
|
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
947
1052
|
outline-offset: 2px;
|
|
948
1053
|
}
|
|
@@ -957,7 +1062,9 @@
|
|
|
957
1062
|
.input,
|
|
958
1063
|
.select,
|
|
959
1064
|
.schedulePreset,
|
|
960
|
-
.scheduleToggle input
|
|
1065
|
+
.scheduleToggle input,
|
|
1066
|
+
.formSectionHeader,
|
|
1067
|
+
.formSectionChevron {
|
|
961
1068
|
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, outline-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
|
962
1069
|
}
|
|
963
1070
|
|
|
@@ -1017,7 +1124,9 @@
|
|
|
1017
1124
|
.input,
|
|
1018
1125
|
.select,
|
|
1019
1126
|
.schedulePreset,
|
|
1020
|
-
.scheduleToggle input
|
|
1127
|
+
.scheduleToggle input,
|
|
1128
|
+
.formSectionHeader,
|
|
1129
|
+
.formSectionChevron {
|
|
1021
1130
|
transition: none;
|
|
1022
1131
|
}
|
|
1023
1132
|
|
package/src/client/host-api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { degradedRowOf, ledgerOwnerPid } from '../core/degraded-reason.ts'
|
|
2
|
+
import { TASK_BOARD_PACKAGE } from '../core/package-name.ts'
|
|
1
3
|
import type { TaskRecord } from '../core/tasks.ts'
|
|
2
4
|
import { t } from './locales.ts'
|
|
3
5
|
import {
|
|
@@ -37,7 +39,20 @@ const TASK_PARSE_TIMEOUT_SECONDS = 45
|
|
|
37
39
|
* "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
|
|
38
40
|
* Host half looks like today.
|
|
39
41
|
*/
|
|
40
|
-
export type HostApiFailure =
|
|
42
|
+
export type HostApiFailure =
|
|
43
|
+
/** The board's own routes are not registered and nothing explains why. */
|
|
44
|
+
| 'not-mounted'
|
|
45
|
+
/** Another live DSH process owns the ledger, so this instance registered no board routes (#1730). */
|
|
46
|
+
| 'ledger-locked'
|
|
47
|
+
/** This row degraded during start for some other reason the family health route reports (#1730). */
|
|
48
|
+
| 'degraded'
|
|
49
|
+
| 'unauthorized'
|
|
50
|
+
| 'forbidden'
|
|
51
|
+
| 'locked'
|
|
52
|
+
| 'rejected'
|
|
53
|
+
| 'timeout'
|
|
54
|
+
| 'unreachable'
|
|
55
|
+
| 'unexpected'
|
|
41
56
|
|
|
42
57
|
/** Transport failure carrying a stable class next to its user-facing message. */
|
|
43
58
|
export class HostApiError extends Error {
|
|
@@ -47,6 +62,86 @@ export class HostApiError extends Error {
|
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Same-origin family health route served by the dsh-web-all shell: the
|
|
67
|
+
* degraded ledger, in which a family row that failed during apply appears WITH
|
|
68
|
+
* its reason. DOCUMENT-RELATIVE for the same reason as the board's own routes
|
|
69
|
+
* (issue #1707). A 404 here means the family shell is absent or older than the
|
|
70
|
+
* route, which is not itself a failure of the board.
|
|
71
|
+
*/
|
|
72
|
+
const FAMILY_DEGRADED_ROUTE = 'api/dsh-web-all/degraded'
|
|
73
|
+
|
|
74
|
+
/** Health-route ceiling: an unresponsive family shell must not hold the board's first paint. */
|
|
75
|
+
const DEGRADED_LOOKUP_TIMEOUT_MS = 2_000
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The failure text for a row this browser knows failed during start.
|
|
79
|
+
*
|
|
80
|
+
* A ledger lock is the one class with an owner the user can act on, so its
|
|
81
|
+
* reason is unpacked into the pid plus what to do about it; every other
|
|
82
|
+
* degraded row is reported with the shell's own reason verbatim. The raw
|
|
83
|
+
* reason rides along either way, because it is the only text that names the
|
|
84
|
+
* lock file or the recovery step when the pid is not the whole story.
|
|
85
|
+
* @param reason - the shell's one-line failure reason (never empty).
|
|
86
|
+
*/
|
|
87
|
+
function degradedFailureMessage(reason: string): { failure: 'ledger-locked' | 'degraded'; message: string } {
|
|
88
|
+
const pid = ledgerOwnerPid(reason)
|
|
89
|
+
if (pid === undefined) return { failure: 'degraded', message: t('board.hostError.degraded', { detail: reason }) }
|
|
90
|
+
return { failure: 'ledger-locked', message: t('board.hostError.ledgerLocked', { pid: String(pid), detail: reason }) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The degraded record the family shell holds for THIS board, or undefined when
|
|
95
|
+
* the row is healthy, the shell is absent, or the answer is unusable. The
|
|
96
|
+
* lookup is best-effort by contract: any uncertainty keeps the caller's own
|
|
97
|
+
* failure text instead of replacing it with a weaker one.
|
|
98
|
+
* @param fetchImpl - fetch implementation (injected for tests).
|
|
99
|
+
* @returns the ledger's reason for this row, or undefined when it names none.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchDegradedReason(fetchImpl: typeof fetch): Promise<string | undefined> {
|
|
102
|
+
const controller = new AbortController()
|
|
103
|
+
const timer = globalThis.setTimeout(() => { controller.abort() }, DEGRADED_LOOKUP_TIMEOUT_MS)
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetchImpl(FAMILY_DEGRADED_ROUTE, { signal: controller.signal, headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
106
|
+
if (!response.ok) return undefined
|
|
107
|
+
return degradedRowOf(await response.json(), TASK_BOARD_PACKAGE)?.reason
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
} finally {
|
|
111
|
+
globalThis.clearTimeout(timer)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Name the real reason this board's routes are missing (issue #1730).
|
|
117
|
+
*
|
|
118
|
+
* The core webserver answers an unmounted route with a bare 404, which cannot
|
|
119
|
+
* distinguish "this deployment never loaded the plugin" from "this row
|
|
120
|
+
* degraded during start" — a second DSH process holding `ledger-v2.lock` is
|
|
121
|
+
* the case that matters, because the panel's advice ("restart the service")
|
|
122
|
+
* is then false: the lock belongs to the other process, and restarting cannot
|
|
123
|
+
* win it. The family health route knows the difference, so the 404 is
|
|
124
|
+
* disambiguated there before anything is shown.
|
|
125
|
+
* @param fallback - copy for the plain "routes are not there" failure, which
|
|
126
|
+
* is route-specific (the board's state route and the AI-parse route phrase
|
|
127
|
+
* their own absence); it is also what is shown when the ledger names nothing.
|
|
128
|
+
* @param fetchImpl - fetch implementation (injected for tests).
|
|
129
|
+
* @returns the HostApiError to throw for a missing board route.
|
|
130
|
+
*/
|
|
131
|
+
export async function missingRouteFailure(
|
|
132
|
+
fallback: string = t('board.hostError.notMounted'),
|
|
133
|
+
fetchImpl: typeof fetch = fetch,
|
|
134
|
+
): Promise<HostApiError> {
|
|
135
|
+
const absent = (): HostApiError => new HostApiError('not-mounted', fallback, 404)
|
|
136
|
+
const reason = await fetchDegradedReason(fetchImpl)
|
|
137
|
+
// A shell that predates the reason field (or recorded none) tells us the row
|
|
138
|
+
// is degraded but not why; the route's own wording is more honest than an
|
|
139
|
+
// empty "because".
|
|
140
|
+
if (reason === undefined || reason === '') return absent()
|
|
141
|
+
const described = degradedFailureMessage(reason)
|
|
142
|
+
return new HostApiError(described.failure, described.message, 404)
|
|
143
|
+
}
|
|
144
|
+
|
|
50
145
|
function uuid(): string {
|
|
51
146
|
return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
52
147
|
}
|
|
@@ -79,14 +174,18 @@ async function readJson<T>(response: Response): Promise<T> {
|
|
|
79
174
|
// The Host answered with a reason of its own. A ledger lock is the one
|
|
80
175
|
// class worth naming in the panel's words; the authentication fence and
|
|
81
176
|
// every other action rejection are reported as they arrive.
|
|
82
|
-
if (hostError === 'forbidden') throw new HostApiError('
|
|
177
|
+
if (hostError === 'forbidden') throw new HostApiError('forbidden', t('board.hostError.forbidden'), response.status)
|
|
83
178
|
if (response.status === 503 || /lock/i.test(hostError)) {
|
|
84
179
|
throw new HostApiError('locked', t('board.hostError.locked', { detail: hostError }), response.status)
|
|
85
180
|
}
|
|
86
181
|
throw new HostApiError('rejected', hostError, response.status)
|
|
87
182
|
}
|
|
88
|
-
|
|
89
|
-
|
|
183
|
+
// A bare 404 is the ONLY signal that this board's routes are absent; the
|
|
184
|
+
// family health route is what turns it into the real reason when this row
|
|
185
|
+
// degraded during start (issue #1730).
|
|
186
|
+
if (response.status === 404) throw await missingRouteFailure()
|
|
187
|
+
if (response.status === 403) throw new HostApiError('forbidden', t('board.hostError.forbidden'), 403)
|
|
188
|
+
if (response.status === 401) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), 401)
|
|
90
189
|
throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
|
|
91
190
|
}
|
|
92
191
|
|
|
@@ -193,8 +292,12 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
193
292
|
if (record !== undefined && isTaskParseDraft(record.draft)) return record.draft
|
|
194
293
|
throw new HostApiError('unexpected', t('new.aiParseFailed', { error: t('board.hostError.unexpected', { status: String(response.status) }) }), response.status)
|
|
195
294
|
}
|
|
196
|
-
|
|
197
|
-
|
|
295
|
+
// The parse route lives beside the board's own routes, so a 404 means the
|
|
296
|
+
// same thing here: absent routes, with the same health-ledger detail and
|
|
297
|
+
// this route's own wording as the fallback.
|
|
298
|
+
if (response.status === 404) throw await missingRouteFailure(t('new.aiParseUnavailable'))
|
|
299
|
+
if (response.status === 403) throw new HostApiError('forbidden', t('board.hostError.forbidden'), 403)
|
|
300
|
+
if (response.status === 401) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), 401)
|
|
198
301
|
const code = typeof record?.code === 'string' ? record.code : undefined
|
|
199
302
|
if (code === 'no-model') throw new HostApiError('rejected', t('new.aiParseNoModel'), response.status)
|
|
200
303
|
if (code === 'timeout') throw new HostApiError('timeout', t('new.aiParseTimeout', { seconds: String(TASK_PARSE_TIMEOUT_SECONDS) }), response.status)
|