@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.0
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 +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +589 -602
- package/lib/client.js.map +1 -1
- package/lib/index.js +2187 -6
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -2
- package/lib/types/core/tasks.d.ts +7 -5
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -0
- package/lib/types/index.d.ts +20 -10
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +22 -13
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +84 -59
- package/src/client/board/status-key.ts +11 -0
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- package/src/core/store.ts +5 -14
- package/src/core/tasks.ts +7 -7
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +58 -12
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -67
- package/src/core/scheduler.ts +0 -151
|
@@ -12,7 +12,8 @@ import { t, type TaskBoardKey } from '../locales.ts'
|
|
|
12
12
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
15
|
-
import { formatTime } from './TaskCard.tsx'
|
|
15
|
+
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
16
|
+
import { STATUS_KEY } from './status-key.ts'
|
|
16
17
|
|
|
17
18
|
/** Execution outcome → locale key. */
|
|
18
19
|
const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
|
|
@@ -21,17 +22,8 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
21
22
|
cancelled: 'detail.result.cancelled',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** Status → locale key (detail badge). */
|
|
25
|
-
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
26
|
-
backlog: 'board.status.backlog',
|
|
27
|
-
todo: 'board.status.todo',
|
|
28
|
-
running: 'board.status.running',
|
|
29
|
-
done: 'board.status.done',
|
|
30
|
-
failed: 'board.status.failed',
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
/** One execution-history row. */
|
|
34
|
-
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
26
|
+
function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
|
|
35
27
|
const result = execution.result
|
|
36
28
|
return (
|
|
37
29
|
<li className={css.executionRow} data-result={result}>
|
|
@@ -39,8 +31,8 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
39
31
|
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
40
32
|
</span>
|
|
41
33
|
<span className={css.executionTimes}>
|
|
42
|
-
{t('detail.executionStarted')} {formatTime(execution.startedAt)}
|
|
43
|
-
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt)}`}
|
|
34
|
+
{t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
|
|
35
|
+
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
|
|
44
36
|
</span>
|
|
45
37
|
{execution.sessionId !== undefined && (
|
|
46
38
|
<button
|
|
@@ -60,7 +52,7 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
60
52
|
}
|
|
61
53
|
|
|
62
54
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
63
|
-
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
55
|
+
function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
64
56
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
65
57
|
useEffect(
|
|
66
58
|
() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
|
|
@@ -83,6 +75,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
83
75
|
<select
|
|
84
76
|
className={css.select}
|
|
85
77
|
value={workspaceId}
|
|
78
|
+
disabled={pending}
|
|
86
79
|
onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
|
|
87
80
|
>
|
|
88
81
|
<option value="">{t('exec.workspace.recent')}</option>
|
|
@@ -97,6 +90,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
97
90
|
<select
|
|
98
91
|
className={css.select}
|
|
99
92
|
value={mode}
|
|
93
|
+
disabled={pending}
|
|
100
94
|
onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
|
|
101
95
|
>
|
|
102
96
|
<option value="">{t('exec.mode.default')}</option>
|
|
@@ -115,6 +109,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
115
109
|
<select
|
|
116
110
|
className={css.select}
|
|
117
111
|
value={permission}
|
|
112
|
+
disabled={pending}
|
|
118
113
|
onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
|
|
119
114
|
>
|
|
120
115
|
<option value="">{t('exec.permission.default')}</option>
|
|
@@ -128,13 +123,14 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
128
123
|
}
|
|
129
124
|
|
|
130
125
|
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
131
|
-
function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
126
|
+
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
132
127
|
const schedule = task.schedule
|
|
133
128
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
134
129
|
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
135
130
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
136
131
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
137
132
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
133
|
+
const timeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
138
134
|
|
|
139
135
|
// Keep the editor in sync when the task record changes underneath (the
|
|
140
136
|
// schedule rolls forward as runs trigger).
|
|
@@ -166,8 +162,11 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
166
162
|
return
|
|
167
163
|
}
|
|
168
164
|
setError(undefined)
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
const submitted = controller.setSchedule(task.id, {
|
|
166
|
+
enabled: next,
|
|
167
|
+
...(next && trimmed !== schedule?.cron ? { cron: trimmed } : {}),
|
|
168
|
+
})
|
|
169
|
+
if (submitted && !controller.isHostBacked()) setEnabled(next)
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
const applyPreset = (preset: string): void => {
|
|
@@ -181,8 +180,8 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
181
180
|
? t('detail.schedule.notScheduled')
|
|
182
181
|
: nextRunAt <= Date.now()
|
|
183
182
|
? t('detail.schedule.dueSoon')
|
|
184
|
-
:
|
|
185
|
-
const lastLabel = lastTriggeredAt === undefined ? '—' :
|
|
183
|
+
: formatHostTimestamp(nextRunAt, timeZone)
|
|
184
|
+
const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
|
|
186
185
|
|
|
187
186
|
return (
|
|
188
187
|
<section className={css.detailSection}>
|
|
@@ -191,6 +190,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
191
190
|
<input
|
|
192
191
|
type="checkbox"
|
|
193
192
|
checked={enabled}
|
|
193
|
+
disabled={pending}
|
|
194
194
|
onChange={event => { toggleEnabled(event.target.checked) }}
|
|
195
195
|
/>
|
|
196
196
|
<span>{t('detail.schedule.enable')}</span>
|
|
@@ -199,6 +199,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
199
199
|
<input
|
|
200
200
|
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
201
201
|
value={cron}
|
|
202
|
+
disabled={pending}
|
|
202
203
|
placeholder="0 9 * * *"
|
|
203
204
|
spellCheck={false}
|
|
204
205
|
aria-label={t('detail.schedule.cron')}
|
|
@@ -209,6 +210,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
209
210
|
<select
|
|
210
211
|
className={css.schedulePreset}
|
|
211
212
|
value=""
|
|
213
|
+
disabled={pending}
|
|
212
214
|
aria-label={t('detail.schedule.presets')}
|
|
213
215
|
onChange={event => { applyPreset(event.target.value) }}
|
|
214
216
|
>
|
|
@@ -230,19 +232,26 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
230
232
|
/** Task detail overlay. */
|
|
231
233
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
232
234
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
233
|
-
const running = task.status === 'running'
|
|
234
235
|
|
|
235
236
|
// Keep the overlay in sync if the task record changes underneath.
|
|
236
237
|
const [latest, setLatest] = useState(task)
|
|
237
238
|
useEffect(() => { setLatest(task) }, [task])
|
|
238
239
|
const current = latest
|
|
240
|
+
const snapshot = controller.getSnapshot()
|
|
241
|
+
const running = current.status === 'running'
|
|
242
|
+
const archived = current.archivedAt !== undefined
|
|
243
|
+
const pending = snapshot.pendingTaskIds.includes(current.id)
|
|
244
|
+
const transportError = snapshot.transportError
|
|
245
|
+
const timeZone = snapshot.host?.scheduler.timeZone
|
|
239
246
|
|
|
240
247
|
return (
|
|
241
248
|
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
|
|
242
249
|
<div className={css.detail} role="dialog" aria-label={t('detail.title')}>
|
|
243
250
|
<header className={css.detailHeader}>
|
|
244
251
|
<h2 className={css.detailTitle}>{current.title}</h2>
|
|
245
|
-
<span className={css.statusBadge} data-status={current.status}>
|
|
252
|
+
<span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
|
|
253
|
+
{archived ? t('board.archive') : t(STATUS_KEY[current.status])}
|
|
254
|
+
</span>
|
|
246
255
|
<button
|
|
247
256
|
type="button"
|
|
248
257
|
className={css.iconButton}
|
|
@@ -254,6 +263,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
254
263
|
</header>
|
|
255
264
|
|
|
256
265
|
<div className={css.detailBody}>
|
|
266
|
+
{transportError !== undefined && (
|
|
267
|
+
<div className={css.formError}>
|
|
268
|
+
{t('board.hostError', { error: transportError })}{' '}
|
|
269
|
+
<button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
|
|
270
|
+
{t('board.retryHost')}
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
257
274
|
<section className={css.detailSection}>
|
|
258
275
|
<h4>{t('detail.description')}</h4>
|
|
259
276
|
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
@@ -264,9 +281,12 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
264
281
|
<pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
|
|
265
282
|
</section>
|
|
266
283
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
284
|
+
{!archived && (
|
|
285
|
+
<>
|
|
286
|
+
<ExecutionSettingsSection controller={controller} task={current} pending={pending} />
|
|
287
|
+
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
270
290
|
|
|
271
291
|
<section className={css.detailSection}>
|
|
272
292
|
<h4>{t('detail.execution')}</h4>
|
|
@@ -278,6 +298,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
278
298
|
<ExecutionRow
|
|
279
299
|
key={execution.id}
|
|
280
300
|
execution={execution}
|
|
301
|
+
timeZone={timeZone}
|
|
281
302
|
onOpen={sessionId => { controller.openSession(sessionId) }}
|
|
282
303
|
/>
|
|
283
304
|
))}
|
|
@@ -285,45 +306,49 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
285
306
|
)}
|
|
286
307
|
</section>
|
|
287
308
|
|
|
288
|
-
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
309
|
+
{!archived && (
|
|
310
|
+
<section className={css.detailSection}>
|
|
311
|
+
<h4>{t('board.status')}</h4>
|
|
312
|
+
<div className={css.moveRow}>
|
|
313
|
+
{MANUAL_STATUSES.map(status => (
|
|
314
|
+
<button
|
|
315
|
+
key={status}
|
|
316
|
+
type="button"
|
|
317
|
+
className={css.ghostButton}
|
|
318
|
+
disabled={current.status === status || running || pending}
|
|
319
|
+
onClick={() => { controller.moveTask(current.id, status) }}
|
|
320
|
+
>
|
|
321
|
+
{t(`status.move.${status}` as TaskBoardKey)}
|
|
322
|
+
</button>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
</section>
|
|
326
|
+
)}
|
|
304
327
|
</div>
|
|
305
328
|
|
|
306
329
|
<footer className={css.detailFooter}>
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
{!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
|
|
331
|
+
{!archived && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
className={css.primaryButton}
|
|
335
|
+
disabled={running || pending}
|
|
336
|
+
onClick={() => {
|
|
337
|
+
void controller.rerunTask(current.id).then(() => {
|
|
338
|
+
if (controller.getSnapshot().transportError === undefined) controller.closeTask()
|
|
339
|
+
})
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
342
|
+
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
343
|
+
</button>
|
|
344
|
+
)}
|
|
345
|
+
{archived ? (
|
|
321
346
|
<button
|
|
322
347
|
type="button"
|
|
323
348
|
className={css.primaryButton}
|
|
349
|
+
disabled={pending}
|
|
324
350
|
onClick={() => {
|
|
325
351
|
controller.restoreTask(current.id)
|
|
326
|
-
controller.closeTask()
|
|
327
352
|
}}
|
|
328
353
|
>
|
|
329
354
|
{t('detail.restore')}
|
|
@@ -333,9 +358,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
333
358
|
<button
|
|
334
359
|
type="button"
|
|
335
360
|
className={css.ghostButton}
|
|
361
|
+
disabled={pending}
|
|
336
362
|
onClick={() => {
|
|
337
363
|
controller.archiveTask(current.id)
|
|
338
|
-
controller.closeTask()
|
|
339
364
|
}}
|
|
340
365
|
>
|
|
341
366
|
{t('detail.archive')}
|
|
@@ -345,13 +370,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
345
370
|
<button
|
|
346
371
|
type="button"
|
|
347
372
|
className={css.dangerButton}
|
|
373
|
+
disabled={pending}
|
|
348
374
|
onClick={() => { setConfirmDelete(true) }}
|
|
349
375
|
>
|
|
350
376
|
{t('detail.delete')}
|
|
351
377
|
</button>
|
|
352
378
|
<span className={css.detailMeta}>
|
|
353
|
-
{t('board.created')} {formatTime(current.createdAt)}
|
|
354
|
-
{
|
|
379
|
+
{t('board.created')} {formatTime(current.createdAt, timeZone)}
|
|
380
|
+
{archived && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt!, timeZone) })}`}
|
|
355
381
|
</span>
|
|
356
382
|
</footer>
|
|
357
383
|
</div>
|
|
@@ -366,7 +392,6 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
366
392
|
onConfirm={() => {
|
|
367
393
|
setConfirmDelete(false)
|
|
368
394
|
controller.deleteTask(current.id)
|
|
369
|
-
controller.closeTask()
|
|
370
395
|
}}
|
|
371
396
|
/>
|
|
372
397
|
)}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskStatus } from '../../core/tasks.ts'
|
|
2
|
+
import type { TaskBoardKey } from '../locales.ts'
|
|
3
|
+
|
|
4
|
+
/** Task status → locale key (board column titles and the detail badge). */
|
|
5
|
+
export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
6
|
+
backlog: 'board.status.backlog',
|
|
7
|
+
todo: 'board.status.todo',
|
|
8
|
+
running: 'board.status.running',
|
|
9
|
+
done: 'board.status.done',
|
|
10
|
+
failed: 'board.status.failed',
|
|
11
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { TaskRecord } from '../core/tasks.ts'
|
|
2
|
+
import {
|
|
3
|
+
TASK_BOARD_API_PREFIX,
|
|
4
|
+
type TaskBoardAction,
|
|
5
|
+
type TaskBoardActionEnvelope,
|
|
6
|
+
type TaskBoardSnapshot,
|
|
7
|
+
} from '../protocol.ts'
|
|
8
|
+
|
|
9
|
+
const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
|
|
10
|
+
const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
|
|
11
|
+
const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
13
|
+
|
|
14
|
+
function uuid(): string {
|
|
15
|
+
return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
19
|
+
const body = await response.json() as T & { error?: string }
|
|
20
|
+
if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
|
|
21
|
+
return body
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TaskBoardHostTransport {
|
|
25
|
+
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
26
|
+
state(): Promise<TaskBoardSnapshot>
|
|
27
|
+
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
28
|
+
subscribe(listener: () => void): () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
32
|
+
constructor(private readonly storage: Pick<Storage, 'getItem' | 'setItem'> | undefined = globalThis.localStorage) {}
|
|
33
|
+
|
|
34
|
+
async bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot> {
|
|
35
|
+
const initial = await this.state()
|
|
36
|
+
const ledgerId = initial.scheduler.ledgerId
|
|
37
|
+
if (legacy.length > 0 && ledgerId !== undefined && this.storage?.getItem(IMPORT_MARKER) !== ledgerId) {
|
|
38
|
+
let sourceId = this.storage?.getItem(SOURCE_KEY)
|
|
39
|
+
if (sourceId === null || sourceId === undefined || sourceId === '') {
|
|
40
|
+
sourceId = uuid()
|
|
41
|
+
this.storage?.setItem(SOURCE_KEY, sourceId)
|
|
42
|
+
}
|
|
43
|
+
let requestId = this.storage?.getItem(IMPORT_REQUEST_KEY)
|
|
44
|
+
if (requestId === null || requestId === undefined || requestId === '') {
|
|
45
|
+
requestId = uuid()
|
|
46
|
+
this.storage?.setItem(IMPORT_REQUEST_KEY, requestId)
|
|
47
|
+
}
|
|
48
|
+
const snapshot = await this.post(requestId, { kind: 'import', sourceId, tasks: [...legacy] })
|
|
49
|
+
this.storage?.setItem(IMPORT_MARKER, snapshot.scheduler.ledgerId ?? ledgerId)
|
|
50
|
+
return snapshot
|
|
51
|
+
}
|
|
52
|
+
return initial
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async state(): Promise<TaskBoardSnapshot> {
|
|
56
|
+
return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
60
|
+
return await this.post(uuid(), action)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
64
|
+
const envelope: TaskBoardActionEnvelope = { requestId, action }
|
|
65
|
+
return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'content-type': 'application/json' },
|
|
68
|
+
body: JSON.stringify(envelope),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async request(url: string, init: RequestInit): Promise<TaskBoardSnapshot> {
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timeout = globalThis.setTimeout(() => { controller.abort() }, REQUEST_TIMEOUT_MS)
|
|
75
|
+
try {
|
|
76
|
+
return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
|
|
79
|
+
throw error
|
|
80
|
+
} finally {
|
|
81
|
+
globalThis.clearTimeout(timeout)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
subscribe(listener: () => void): () => void {
|
|
86
|
+
const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
|
|
87
|
+
events.onmessage = () => { listener() }
|
|
88
|
+
const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
|
|
89
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
90
|
+
return () => {
|
|
91
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
92
|
+
events.close()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* shell fails the whole boot when a plugin apply throws, and an external
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
|
-
import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec
|
|
12
|
-
import type { ConnectionHandle
|
|
11
|
+
import type { ClientContext, ISessions, IWorkspaces, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
14
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
|
|
15
15
|
// LocaleNamespaceMap merge table.
|
|
@@ -17,14 +17,13 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
|
17
17
|
// Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
|
|
18
18
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
19
19
|
import { BoardController } from '../core/controller.ts'
|
|
20
|
-
import { ExecutionService } from '../core/execution.ts'
|
|
21
|
-
import { SchedulerService } from '../core/scheduler.ts'
|
|
22
20
|
import { LocalStorageTaskStore } from '../core/store.ts'
|
|
23
21
|
import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
|
|
24
22
|
import { mountBoard } from './board-mount.tsx'
|
|
25
23
|
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
26
24
|
import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
|
|
27
25
|
import { en, zh, type TaskBoardKey } from './locales.ts'
|
|
26
|
+
import { HttpTaskBoardHostTransport } from './host-api.ts'
|
|
28
27
|
|
|
29
28
|
/** Locale namespace this plugin owns. */
|
|
30
29
|
const NS = 'task-board'
|
|
@@ -113,64 +112,18 @@ export function apply(ctx: ClientContext): void {
|
|
|
113
112
|
let uiDisposer: (() => void) | undefined
|
|
114
113
|
const mountUi = (): void => {
|
|
115
114
|
if (uiDisposer !== undefined) return
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// Host and browser SDK declarations share the Cordis Context name. Read
|
|
116
|
+
// the browser faces explicitly so Host-side declaration merging cannot
|
|
117
|
+
// narrow these two client services during a combined package build.
|
|
118
|
+
const sessions = ctx.get('sessions') as unknown as ISessions
|
|
119
|
+
const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
|
|
118
120
|
const connection = ctx.get('connection') as ConnectionHandle
|
|
119
121
|
|
|
120
122
|
// Core wiring: real runtime faces into the framework-free services.
|
|
121
123
|
const store = new LocalStorageTaskStore()
|
|
122
|
-
const exec = new ExecutionService({
|
|
123
|
-
sessions: {
|
|
124
|
-
list: sessions.list,
|
|
125
|
-
binding: id => {
|
|
126
|
-
const binding = sessions.binding(id as SessionId)
|
|
127
|
-
if (binding === undefined) return undefined
|
|
128
|
-
const { session } = binding
|
|
129
|
-
return {
|
|
130
|
-
session: {
|
|
131
|
-
rename: title => session.rename(title),
|
|
132
|
-
prompt: (content, mode) =>
|
|
133
|
-
session.prompt(content as PromptContentPart[], mode).then(result =>
|
|
134
|
-
result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
|
|
135
|
-
command: line =>
|
|
136
|
-
session.command(line).then(result =>
|
|
137
|
-
result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
|
|
138
|
-
getSnapshot: () => session.getSnapshot(),
|
|
139
|
-
subscribe: fn => session.subscribe(fn),
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
|
|
144
|
-
},
|
|
145
|
-
workspaces: {
|
|
146
|
-
list: workspaces.list,
|
|
147
|
-
connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
|
|
148
|
-
},
|
|
149
|
-
presets: {
|
|
150
|
-
select: async (sessionId, agentPreset) => {
|
|
151
|
-
try {
|
|
152
|
-
const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
|
|
153
|
-
return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
|
|
154
|
-
} catch (error) {
|
|
155
|
-
return { ok: false as const, error }
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
history: {
|
|
160
|
-
loadTail: async sessionId => {
|
|
161
|
-
const response = await connection.api.sessions.history({
|
|
162
|
-
sessionId: sessionId as SessionId,
|
|
163
|
-
maxMessages: 20,
|
|
164
|
-
})
|
|
165
|
-
return response.result.ok
|
|
166
|
-
? { events: response.result.value.events.map(entry => entry.event) }
|
|
167
|
-
: undefined
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
})
|
|
171
124
|
const controller = new BoardController({
|
|
172
125
|
store,
|
|
173
|
-
|
|
126
|
+
transport: new HttpTaskBoardHostTransport(),
|
|
174
127
|
sessions: {
|
|
175
128
|
list: sessions.list,
|
|
176
129
|
open: id => sessions.open(id as SessionId),
|
|
@@ -178,27 +131,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
178
131
|
})
|
|
179
132
|
controller.start()
|
|
180
133
|
|
|
181
|
-
// Scheduled runs: a browser-side heartbeat that triggers due tasks through
|
|
182
|
-
// the same run path as the manual Run button. The first tick is gated on
|
|
183
|
-
// the session list baseline so a page-load catch-up never fires into a
|
|
184
|
-
// not-yet-ready runtime; tab visibility recovery ticks immediately.
|
|
185
|
-
const scheduler = new SchedulerService({
|
|
186
|
-
tasks: () => controller.getSnapshot().tasks,
|
|
187
|
-
// Re-read the persisted ledger before every fire decision: a task
|
|
188
|
-
// deleted in another tab must never fire from a stale in-memory copy.
|
|
189
|
-
refresh: () => controller.reloadFromStore(),
|
|
190
|
-
now: () => Date.now(),
|
|
191
|
-
runTask: id => controller.runTask(id),
|
|
192
|
-
applySchedule: (id, nextRunAt, lastTriggeredAt) =>
|
|
193
|
-
controller.applyScheduleNextRun(id, nextRunAt, lastTriggeredAt),
|
|
194
|
-
ready: () => sessions.list.getSnapshot().phase === 'ready',
|
|
195
|
-
environment: {
|
|
196
|
-
addEventListener: (type, listener) => document.addEventListener(type, listener),
|
|
197
|
-
removeEventListener: (type, listener) => document.removeEventListener(type, listener),
|
|
198
|
-
},
|
|
199
|
-
})
|
|
200
|
-
scheduler.start()
|
|
201
|
-
|
|
202
134
|
const disposers: Array<() => void> = []
|
|
203
135
|
|
|
204
136
|
// Execution-target option feeds: the workspace list drives the workspace
|
|
@@ -248,7 +180,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
248
180
|
|
|
249
181
|
uiDisposer = () => {
|
|
250
182
|
for (const dispose of disposers.splice(0)) dispose()
|
|
251
|
-
scheduler.dispose()
|
|
252
183
|
controller.dispose()
|
|
253
184
|
uiDisposer = undefined
|
|
254
185
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -24,9 +24,13 @@ export const zh = {
|
|
|
24
24
|
'board.status.running': '进行中',
|
|
25
25
|
'board.status.done': '已完成',
|
|
26
26
|
'board.status.failed': '已失败',
|
|
27
|
-
|
|
27
|
+
'board.runs': '次执行',
|
|
28
|
+
'board.pending': '正在提交',
|
|
28
29
|
'board.updated': '更新于',
|
|
29
30
|
'board.created': '创建于',
|
|
31
|
+
'board.hostError': 'Host 操作失败:{error}',
|
|
32
|
+
'board.retryHost': '重试连接 Host',
|
|
33
|
+
'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
|
|
30
34
|
'new.title': '标题',
|
|
31
35
|
'new.titlePlaceholder': '一句话描述要做什么',
|
|
32
36
|
'new.description': '描述',
|
|
@@ -96,11 +100,17 @@ export const zh = {
|
|
|
96
100
|
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
97
101
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
98
102
|
'settings.title': '任务看板',
|
|
99
|
-
'settings.description': '
|
|
103
|
+
'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
|
|
100
104
|
'settings.enabled': '启用任务看板',
|
|
101
105
|
'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图。',
|
|
102
106
|
'settings.announceToAgent': '向 agent 播报任务看板',
|
|
103
107
|
'settings.announceToAgentHint': '开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。',
|
|
108
|
+
'settings.preventIdleSleep': '阻止系统空闲睡眠',
|
|
109
|
+
'settings.preventIdleSleepHint': '默认关闭。开启后,只要存在运行中的 DSH 会话、已启用的定时任务或会话状态尚未确认,Host 就阻止整机因空闲睡眠;屏幕仍可自动关闭。',
|
|
110
|
+
'settings.powerStatus': '平台:{platform};保护状态:{phase};运行会话:{running};已启用计划:{schedules}',
|
|
111
|
+
'settings.powerBoundary': '这可能增加电池消耗。合盖、手动睡眠、休眠、关机、低电量或企业策略不在保证范围内,也不会唤醒已经睡眠的机器。',
|
|
112
|
+
'settings.powerUnknown': '未知',
|
|
113
|
+
'settings.powerError': '最近一次电源保护错误:{error}',
|
|
104
114
|
'settings.inherit': '继承',
|
|
105
115
|
'settings.on': '开',
|
|
106
116
|
'settings.off': '关',
|
|
@@ -137,9 +147,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
137
147
|
'board.status.running': 'In Progress',
|
|
138
148
|
'board.status.done': 'Done',
|
|
139
149
|
'board.status.failed': 'Failed',
|
|
140
|
-
|
|
150
|
+
'board.runs': 'runs',
|
|
151
|
+
'board.pending': 'Submitting',
|
|
141
152
|
'board.updated': 'Updated',
|
|
142
153
|
'board.created': 'Created',
|
|
154
|
+
'board.hostError': 'Host action failed: {error}',
|
|
155
|
+
'board.retryHost': 'Retry Host connection',
|
|
156
|
+
'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
|
|
143
157
|
'new.title': 'Title',
|
|
144
158
|
'new.titlePlaceholder': 'What should be done, in one line',
|
|
145
159
|
'new.description': 'Description',
|
|
@@ -209,11 +223,17 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
209
223
|
'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.',
|
|
210
224
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
211
225
|
'settings.title': 'Task Board',
|
|
212
|
-
'settings.description': '
|
|
226
|
+
'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
|
|
213
227
|
'settings.enabled': 'Enable the task board',
|
|
214
228
|
'settings.enabledHint': 'When off, the sidebar entry and board view are hidden.',
|
|
215
229
|
'settings.announceToAgent': 'Announce the task board to agents',
|
|
216
230
|
'settings.announceToAgentHint': 'On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.',
|
|
231
|
+
'settings.preventIdleSleep': 'Prevent idle system sleep',
|
|
232
|
+
'settings.preventIdleSleepHint': 'Off by default. When enabled, the Host prevents idle system sleep while any DSH session runs, any schedule is enabled, or session state is not yet known. The display may still turn off.',
|
|
233
|
+
'settings.powerStatus': 'Platform: {platform}; protection: {phase}; running sessions: {running}; enabled schedules: {schedules}',
|
|
234
|
+
'settings.powerBoundary': 'This may use more battery. Lid close, manual sleep, hibernation, shutdown, low-battery actions, and enterprise policy are outside the guarantee; an already sleeping computer is not woken.',
|
|
235
|
+
'settings.powerUnknown': 'unknown',
|
|
236
|
+
'settings.powerError': 'Latest power-protection error: {error}',
|
|
217
237
|
'settings.inherit': 'Inherit',
|
|
218
238
|
'settings.on': 'On',
|
|
219
239
|
'settings.off': 'Off',
|