@linxin666/dsh-client-ui-task-board 0.1.20 → 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 +574 -581
- package/lib/client.js.map +1 -1
- package/lib/index.js +2144 -5
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- 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 +6 -6
- 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 +17 -9
- package/lib/types/loopback.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 +21 -4
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- 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 +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- 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 +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
|
@@ -12,7 +12,7 @@ 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
16
|
import { STATUS_KEY } from './status-key.ts'
|
|
17
17
|
|
|
18
18
|
/** Execution outcome → locale key. */
|
|
@@ -23,7 +23,7 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** One execution-history row. */
|
|
26
|
-
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
26
|
+
function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
|
|
27
27
|
const result = execution.result
|
|
28
28
|
return (
|
|
29
29
|
<li className={css.executionRow} data-result={result}>
|
|
@@ -31,8 +31,8 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
31
31
|
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
32
32
|
</span>
|
|
33
33
|
<span className={css.executionTimes}>
|
|
34
|
-
{t('detail.executionStarted')} {formatTime(execution.startedAt)}
|
|
35
|
-
{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)}`}
|
|
36
36
|
</span>
|
|
37
37
|
{execution.sessionId !== undefined && (
|
|
38
38
|
<button
|
|
@@ -52,7 +52,7 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
55
|
-
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
55
|
+
function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
56
56
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
57
57
|
useEffect(
|
|
58
58
|
() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
|
|
@@ -75,6 +75,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
75
75
|
<select
|
|
76
76
|
className={css.select}
|
|
77
77
|
value={workspaceId}
|
|
78
|
+
disabled={pending}
|
|
78
79
|
onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
|
|
79
80
|
>
|
|
80
81
|
<option value="">{t('exec.workspace.recent')}</option>
|
|
@@ -89,6 +90,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
89
90
|
<select
|
|
90
91
|
className={css.select}
|
|
91
92
|
value={mode}
|
|
93
|
+
disabled={pending}
|
|
92
94
|
onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
|
|
93
95
|
>
|
|
94
96
|
<option value="">{t('exec.mode.default')}</option>
|
|
@@ -107,6 +109,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
107
109
|
<select
|
|
108
110
|
className={css.select}
|
|
109
111
|
value={permission}
|
|
112
|
+
disabled={pending}
|
|
110
113
|
onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
|
|
111
114
|
>
|
|
112
115
|
<option value="">{t('exec.permission.default')}</option>
|
|
@@ -120,13 +123,14 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
123
|
-
function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
126
|
+
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
124
127
|
const schedule = task.schedule
|
|
125
128
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
126
129
|
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
127
130
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
128
131
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
129
132
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
133
|
+
const timeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
130
134
|
|
|
131
135
|
// Keep the editor in sync when the task record changes underneath (the
|
|
132
136
|
// schedule rolls forward as runs trigger).
|
|
@@ -158,8 +162,11 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
158
162
|
return
|
|
159
163
|
}
|
|
160
164
|
setError(undefined)
|
|
161
|
-
|
|
162
|
-
|
|
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)
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
const applyPreset = (preset: string): void => {
|
|
@@ -173,8 +180,8 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
173
180
|
? t('detail.schedule.notScheduled')
|
|
174
181
|
: nextRunAt <= Date.now()
|
|
175
182
|
? t('detail.schedule.dueSoon')
|
|
176
|
-
:
|
|
177
|
-
const lastLabel = lastTriggeredAt === undefined ? '—' :
|
|
183
|
+
: formatHostTimestamp(nextRunAt, timeZone)
|
|
184
|
+
const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
|
|
178
185
|
|
|
179
186
|
return (
|
|
180
187
|
<section className={css.detailSection}>
|
|
@@ -183,6 +190,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
183
190
|
<input
|
|
184
191
|
type="checkbox"
|
|
185
192
|
checked={enabled}
|
|
193
|
+
disabled={pending}
|
|
186
194
|
onChange={event => { toggleEnabled(event.target.checked) }}
|
|
187
195
|
/>
|
|
188
196
|
<span>{t('detail.schedule.enable')}</span>
|
|
@@ -191,6 +199,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
191
199
|
<input
|
|
192
200
|
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
193
201
|
value={cron}
|
|
202
|
+
disabled={pending}
|
|
194
203
|
placeholder="0 9 * * *"
|
|
195
204
|
spellCheck={false}
|
|
196
205
|
aria-label={t('detail.schedule.cron')}
|
|
@@ -201,6 +210,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
201
210
|
<select
|
|
202
211
|
className={css.schedulePreset}
|
|
203
212
|
value=""
|
|
213
|
+
disabled={pending}
|
|
204
214
|
aria-label={t('detail.schedule.presets')}
|
|
205
215
|
onChange={event => { applyPreset(event.target.value) }}
|
|
206
216
|
>
|
|
@@ -222,19 +232,26 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
222
232
|
/** Task detail overlay. */
|
|
223
233
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
224
234
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
225
|
-
const running = task.status === 'running'
|
|
226
235
|
|
|
227
236
|
// Keep the overlay in sync if the task record changes underneath.
|
|
228
237
|
const [latest, setLatest] = useState(task)
|
|
229
238
|
useEffect(() => { setLatest(task) }, [task])
|
|
230
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
|
|
231
246
|
|
|
232
247
|
return (
|
|
233
248
|
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
|
|
234
249
|
<div className={css.detail} role="dialog" aria-label={t('detail.title')}>
|
|
235
250
|
<header className={css.detailHeader}>
|
|
236
251
|
<h2 className={css.detailTitle}>{current.title}</h2>
|
|
237
|
-
<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>
|
|
238
255
|
<button
|
|
239
256
|
type="button"
|
|
240
257
|
className={css.iconButton}
|
|
@@ -246,6 +263,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
246
263
|
</header>
|
|
247
264
|
|
|
248
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
|
+
)}
|
|
249
274
|
<section className={css.detailSection}>
|
|
250
275
|
<h4>{t('detail.description')}</h4>
|
|
251
276
|
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
@@ -256,9 +281,12 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
256
281
|
<pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
|
|
257
282
|
</section>
|
|
258
283
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
284
|
+
{!archived && (
|
|
285
|
+
<>
|
|
286
|
+
<ExecutionSettingsSection controller={controller} task={current} pending={pending} />
|
|
287
|
+
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
262
290
|
|
|
263
291
|
<section className={css.detailSection}>
|
|
264
292
|
<h4>{t('detail.execution')}</h4>
|
|
@@ -270,6 +298,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
270
298
|
<ExecutionRow
|
|
271
299
|
key={execution.id}
|
|
272
300
|
execution={execution}
|
|
301
|
+
timeZone={timeZone}
|
|
273
302
|
onOpen={sessionId => { controller.openSession(sessionId) }}
|
|
274
303
|
/>
|
|
275
304
|
))}
|
|
@@ -277,45 +306,49 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
277
306
|
)}
|
|
278
307
|
</section>
|
|
279
308
|
|
|
280
|
-
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
)}
|
|
296
327
|
</div>
|
|
297
328
|
|
|
298
329
|
<footer className={css.detailFooter}>
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 ? (
|
|
313
346
|
<button
|
|
314
347
|
type="button"
|
|
315
348
|
className={css.primaryButton}
|
|
349
|
+
disabled={pending}
|
|
316
350
|
onClick={() => {
|
|
317
351
|
controller.restoreTask(current.id)
|
|
318
|
-
controller.closeTask()
|
|
319
352
|
}}
|
|
320
353
|
>
|
|
321
354
|
{t('detail.restore')}
|
|
@@ -325,9 +358,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
325
358
|
<button
|
|
326
359
|
type="button"
|
|
327
360
|
className={css.ghostButton}
|
|
361
|
+
disabled={pending}
|
|
328
362
|
onClick={() => {
|
|
329
363
|
controller.archiveTask(current.id)
|
|
330
|
-
controller.closeTask()
|
|
331
364
|
}}
|
|
332
365
|
>
|
|
333
366
|
{t('detail.archive')}
|
|
@@ -337,13 +370,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
337
370
|
<button
|
|
338
371
|
type="button"
|
|
339
372
|
className={css.dangerButton}
|
|
373
|
+
disabled={pending}
|
|
340
374
|
onClick={() => { setConfirmDelete(true) }}
|
|
341
375
|
>
|
|
342
376
|
{t('detail.delete')}
|
|
343
377
|
</button>
|
|
344
378
|
<span className={css.detailMeta}>
|
|
345
|
-
{t('board.created')} {formatTime(current.createdAt)}
|
|
346
|
-
{
|
|
379
|
+
{t('board.created')} {formatTime(current.createdAt, timeZone)}
|
|
380
|
+
{archived && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt!, timeZone) })}`}
|
|
347
381
|
</span>
|
|
348
382
|
</footer>
|
|
349
383
|
</div>
|
|
@@ -358,7 +392,6 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
358
392
|
onConfirm={() => {
|
|
359
393
|
setConfirmDelete(false)
|
|
360
394
|
controller.deleteTask(current.id)
|
|
361
|
-
controller.closeTask()
|
|
362
395
|
}}
|
|
363
396
|
/>
|
|
364
397
|
)}
|
|
@@ -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',
|