@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.2
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +349 -73
- package/lib/client.js.map +1 -1
- package/lib/index.js +161 -31
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +3 -3
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +9 -2
- package/lib/types/core/schedule.d.ts +7 -0
- package/lib/types/host-ledger.d.ts +10 -0
- package/lib/types/host-routes.d.ts +3 -1
- package/lib/types/host-runner.d.ts +12 -1
- package/lib/types/host-service.d.ts +5 -1
- package/lib/types/protocol.d.ts +6 -0
- package/package.json +1 -2
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/TaskBoardSettingsCard.tsx +10 -10
- package/src/client/board/TaskBoard.tsx +5 -5
- package/src/client/board/TaskCard.tsx +1 -0
- package/src/client/board/TaskDetail.tsx +1 -1
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +12 -3
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +20 -3
- package/src/core/execution.ts +1 -2
- package/src/core/schedule.ts +57 -10
- package/src/host-ledger.ts +67 -6
- package/src/host-routes.ts +11 -3
- package/src/host-runner.ts +24 -6
- package/src/host-service.ts +24 -7
- package/src/protocol.ts +7 -0
package/src/host-ledger.ts
CHANGED
|
@@ -86,7 +86,7 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
86
86
|
return { ...newer, executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt) }
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function parseHostTasks(values: readonly unknown[]
|
|
89
|
+
function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
90
90
|
const rawById = new Map<string, Record<string, unknown>>()
|
|
91
91
|
for (const value of values) {
|
|
92
92
|
if (typeof value !== 'object' || value === null) continue
|
|
@@ -120,11 +120,14 @@ export class HostTaskLedger {
|
|
|
120
120
|
private lockFd: number | undefined
|
|
121
121
|
readonly file: string
|
|
122
122
|
readonly lockFile: string
|
|
123
|
+
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
124
|
+
readonly schedulerFile: string
|
|
123
125
|
|
|
124
126
|
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now) {
|
|
125
127
|
mkdirSync(dir, { recursive: true })
|
|
126
128
|
this.file = join(dir, 'ledger-v2.json')
|
|
127
129
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
130
|
+
this.schedulerFile = join(dir, 'scheduler-v2.json')
|
|
128
131
|
this.lockFd = this.acquireLock()
|
|
129
132
|
try {
|
|
130
133
|
this.document = this.load(dir)
|
|
@@ -142,9 +145,15 @@ export class HostTaskLedger {
|
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
149
|
+
summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } {
|
|
146
150
|
const { importedSources: _imports, ...scheduler } = this.document.scheduler
|
|
147
|
-
return { revision: this.document.revision,
|
|
151
|
+
return { revision: this.document.revision, scheduler: { ...scheduler } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state(): LedgerState {
|
|
155
|
+
const { revision, scheduler } = this.summary()
|
|
156
|
+
return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
subscribe(listener: () => void): () => void {
|
|
@@ -215,6 +224,14 @@ export class HostTaskLedger {
|
|
|
215
224
|
|
|
216
225
|
setScheduler(patch: Partial<TaskBoardSchedulerSnapshot>): void {
|
|
217
226
|
this.document.scheduler = { ...this.document.scheduler, ...patch }
|
|
227
|
+
// The 30 s heartbeat only moves lastTickAt; rewriting the whole ledger
|
|
228
|
+
// for it made idle idle cost O(ledger bytes) every tick. Persist it to a
|
|
229
|
+
// tiny sidecar instead; any other patch still goes through the full
|
|
230
|
+
// atomic commit.
|
|
231
|
+
if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) {
|
|
232
|
+
this.writeSchedulerSidecar()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
218
235
|
this.commit(false)
|
|
219
236
|
}
|
|
220
237
|
|
|
@@ -245,7 +262,7 @@ export class HostTaskLedger {
|
|
|
245
262
|
const invalidScheduleIds = action.tasks
|
|
246
263
|
.filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron))
|
|
247
264
|
.map(task => task.id)
|
|
248
|
-
const incoming = parseHostTasks(action.tasks
|
|
265
|
+
const incoming = parseHostTasks(action.tasks)
|
|
249
266
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
250
267
|
for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
|
|
251
268
|
this.document.tasks = [...merged.values()]
|
|
@@ -364,7 +381,7 @@ export class HostTaskLedger {
|
|
|
364
381
|
try {
|
|
365
382
|
const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
|
|
366
383
|
if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
|
|
367
|
-
const tasks = parseHostTasks(parsed.tasks
|
|
384
|
+
const tasks = parseHostTasks(parsed.tasks)
|
|
368
385
|
const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
|
|
369
386
|
if (typeof value !== 'object' || value === null) return []
|
|
370
387
|
const row = value as { id?: unknown; schedule?: unknown }
|
|
@@ -374,6 +391,13 @@ export class HostTaskLedger {
|
|
|
374
391
|
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
375
392
|
: []
|
|
376
393
|
})
|
|
394
|
+
const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
|
|
395
|
+
const sidecarLastTickAt = this.readSchedulerSidecar()
|
|
396
|
+
// A sidecar write can be newer than the last full commit (crash between
|
|
397
|
+
// the two); lastTickAt only ever moves forward, so take the greater.
|
|
398
|
+
const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
|
|
399
|
+
? documentLastTickAt
|
|
400
|
+
: sidecarLastTickAt
|
|
377
401
|
return {
|
|
378
402
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
379
403
|
revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
|
|
@@ -381,7 +405,7 @@ export class HostTaskLedger {
|
|
|
381
405
|
scheduler: {
|
|
382
406
|
timeZone: timeZone(),
|
|
383
407
|
ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
384
|
-
...(
|
|
408
|
+
...(lastTickAt === undefined ? {} : { lastTickAt }),
|
|
385
409
|
...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
|
|
386
410
|
...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
|
|
387
411
|
...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
|
|
@@ -416,6 +440,43 @@ export class HostTaskLedger {
|
|
|
416
440
|
}))
|
|
417
441
|
}
|
|
418
442
|
|
|
443
|
+
private readSchedulerSidecar(): number | undefined {
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(readFileSync(this.schedulerFile, 'utf8')) as { lastTickAt?: unknown }
|
|
446
|
+
return typeof parsed.lastTickAt === 'number' && Number.isFinite(parsed.lastTickAt) ? parsed.lastTickAt : undefined
|
|
447
|
+
} catch {
|
|
448
|
+
return undefined
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
|
|
453
|
+
private writeSchedulerSidecar(): void {
|
|
454
|
+
const payload = JSON.stringify({ lastTickAt: this.document.scheduler.lastTickAt })
|
|
455
|
+
mkdirSync(dirname(this.schedulerFile), { recursive: true })
|
|
456
|
+
const tmp = `${this.schedulerFile}.tmp-${process.pid}`
|
|
457
|
+
let fd: number | undefined
|
|
458
|
+
try {
|
|
459
|
+
fd = openSync(tmp, 'w', 0o600)
|
|
460
|
+
writeFileSync(fd, payload, { encoding: 'utf8' })
|
|
461
|
+
fsyncSync(fd)
|
|
462
|
+
closeSync(fd)
|
|
463
|
+
fd = undefined
|
|
464
|
+
try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ }
|
|
465
|
+
renameSync(tmp, this.schedulerFile)
|
|
466
|
+
try {
|
|
467
|
+
const dirFd = openSync(dirname(this.schedulerFile), 'r')
|
|
468
|
+
try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
|
|
469
|
+
} catch {
|
|
470
|
+
// Windows does not permit fsync on a directory handle; rename remains atomic.
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (fd !== undefined) closeSync(fd)
|
|
474
|
+
try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ }
|
|
475
|
+
throw error
|
|
476
|
+
}
|
|
477
|
+
this.notify()
|
|
478
|
+
}
|
|
479
|
+
|
|
419
480
|
private commit(bumpRevision = true): void {
|
|
420
481
|
if (bumpRevision) this.document.revision += 1
|
|
421
482
|
mkdirSync(dirname(this.file), { recursive: true })
|
package/src/host-routes.ts
CHANGED
|
@@ -60,6 +60,12 @@ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
|
|
|
60
60
|
return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
65
|
+
* header and is refused, but a curl with a forged Origin passes this too.
|
|
66
|
+
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
67
|
+
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
68
|
+
*/
|
|
63
69
|
function browserSameOriginMarker(req: IncomingMessage): boolean {
|
|
64
70
|
const site = req.headers['sec-fetch-site']
|
|
65
71
|
return site === 'same-origin' || typeof req.headers.origin === 'string'
|
|
@@ -86,7 +92,9 @@ function matchesToken(candidate: string | string[] | undefined, expected: string
|
|
|
86
92
|
/**
|
|
87
93
|
* Task-board route fence. Direct desktop access uses the repository-wide
|
|
88
94
|
* loopback socket + Host guard and additionally requires a browser same-origin
|
|
89
|
-
* marker
|
|
95
|
+
* marker: a bare local curl without any browser signal cannot exercise the
|
|
96
|
+
* agent control plane (a forged Origin does pass the marker — it is a
|
|
97
|
+
* tripwire, the socket/Host/origin-equality checks carry the authority).
|
|
90
98
|
* Authenticated proxies must be explicitly allowlisted and replace the
|
|
91
99
|
* internal token header after their own authentication step.
|
|
92
100
|
*/
|
|
@@ -170,8 +178,8 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
|
|
|
170
178
|
connection: 'keep-alive',
|
|
171
179
|
})
|
|
172
180
|
const push = (): void => {
|
|
173
|
-
const
|
|
174
|
-
res.write(`data: ${JSON.stringify(
|
|
181
|
+
const payload = service.eventPayload()
|
|
182
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
175
183
|
}
|
|
176
184
|
const unsubscribe = service.subscribe(push)
|
|
177
185
|
const heartbeat = setInterval(() => { res.write(': ping\n\n') }, HEARTBEAT_MS)
|
package/src/host-runner.ts
CHANGED
|
@@ -9,6 +9,12 @@ function failure(error: { code: string; message: string }): Error {
|
|
|
9
9
|
return new Error(`${error.code}: ${error.message}`)
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** One session-list row, extracted from the sessions.list RPC result. */
|
|
13
|
+
export type SessionSummary = Extract<
|
|
14
|
+
Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'],
|
|
15
|
+
{ ok: true }
|
|
16
|
+
>['value']['items'][number]
|
|
17
|
+
|
|
12
18
|
export type ExecutionInspection =
|
|
13
19
|
| { outcome: 'pending' }
|
|
14
20
|
| { outcome: 'succeeded' }
|
|
@@ -77,21 +83,33 @@ export class HostExecutionRunner {
|
|
|
77
83
|
return sessionId
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
async listRunning(): Promise<{ known: true; count: number } | { known: false }> {
|
|
86
|
+
async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
|
|
81
87
|
try {
|
|
82
88
|
const response = await this.api.sessions.list(request({}))
|
|
83
89
|
return response.result.ok
|
|
84
|
-
? { known: true, count: response.result.value.items.filter(item => item.running).length }
|
|
90
|
+
? { known: true, count: response.result.value.items.filter(item => item.running).length, items: response.result.value.items }
|
|
85
91
|
: { known: false }
|
|
86
92
|
} catch {
|
|
87
93
|
return { known: false }
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Resolve one execution's outcome. The caller may pass the session list it
|
|
99
|
+
* already fetched this poll tick; otherwise inspect lists sessions itself.
|
|
100
|
+
* Sharing the list keeps a poll with E open executions at one list RPC
|
|
101
|
+
* instead of 1 + E.
|
|
102
|
+
*/
|
|
103
|
+
async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection> {
|
|
104
|
+
let items: readonly SessionSummary[]
|
|
105
|
+
if (sessions !== undefined) {
|
|
106
|
+
items = sessions
|
|
107
|
+
} else {
|
|
108
|
+
const response = await this.api.sessions.list(request({}))
|
|
109
|
+
if (!response.result.ok) return { outcome: 'pending' }
|
|
110
|
+
items = response.result.value.items
|
|
111
|
+
}
|
|
112
|
+
const summary = items.find(item => item.sessionId === sessionId)
|
|
95
113
|
if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
96
114
|
if (summary.running) return { outcome: 'pending' }
|
|
97
115
|
const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
|
package/src/host-service.ts
CHANGED
|
@@ -2,9 +2,9 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
|
2
2
|
import { nextRunAtMs } from './core/schedule.ts'
|
|
3
3
|
import type { TaskRecord } from './core/tasks.ts'
|
|
4
4
|
import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
|
|
5
|
-
import { HostExecutionRunner, SessionLaunchError } from './host-runner.ts'
|
|
5
|
+
import { HostExecutionRunner, SessionLaunchError, type SessionSummary } from './host-runner.ts'
|
|
6
6
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
7
|
-
import type { TaskBoardAction, TaskBoardSnapshot } from './protocol.ts'
|
|
7
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
|
|
8
8
|
|
|
9
9
|
const SESSION_POLL_MS = 5_000
|
|
10
10
|
const SCHEDULE_TICK_MS = 30_000
|
|
@@ -22,6 +22,7 @@ export class TaskBoardHostService {
|
|
|
22
22
|
private tickInFlight = false
|
|
23
23
|
private active = true
|
|
24
24
|
private preventIdleSleep = false
|
|
25
|
+
private lastPowerJson = ''
|
|
25
26
|
private readonly now: () => number
|
|
26
27
|
|
|
27
28
|
constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
|
|
@@ -33,7 +34,15 @@ export class TaskBoardHostService {
|
|
|
33
34
|
this.syncPowerReasons()
|
|
34
35
|
this.emit()
|
|
35
36
|
})
|
|
36
|
-
this.power.subscribe(() => {
|
|
37
|
+
this.power.subscribe(() => {
|
|
38
|
+
// updateReasons emits on every poll tick even when nothing changed;
|
|
39
|
+
// gate on the actual snapshot so the 5 s heartbeat does not push an
|
|
40
|
+
// empty SSE frame per tab forever.
|
|
41
|
+
const json = JSON.stringify(this.power.snapshot())
|
|
42
|
+
if (json === this.lastPowerJson) return
|
|
43
|
+
this.lastPowerJson = json
|
|
44
|
+
this.emit()
|
|
45
|
+
})
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
start(): void {
|
|
@@ -76,6 +85,12 @@ export class TaskBoardHostService {
|
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
|
|
89
|
+
eventPayload(): TaskBoardEventPayload {
|
|
90
|
+
const { revision, scheduler } = this.ledger.summary()
|
|
91
|
+
return { revision, scheduler, power: this.power.snapshot() }
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
subscribe(listener: () => void): () => void {
|
|
80
95
|
this.listeners.add(listener)
|
|
81
96
|
return () => { this.listeners.delete(listener) }
|
|
@@ -124,16 +139,18 @@ export class TaskBoardHostService {
|
|
|
124
139
|
armedSchedules: this.armedSchedules(),
|
|
125
140
|
sessionStateKnown: running.known,
|
|
126
141
|
})
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
// No unconditional emit here: real changes already emit through the
|
|
143
|
+
// ledger subscription (settles) and the gated power listener above.
|
|
144
|
+
if (running.known) await this.reconcileExecutions(running.items)
|
|
129
145
|
}
|
|
130
146
|
|
|
131
|
-
|
|
147
|
+
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
148
|
+
private async reconcileExecutions(sessions: readonly SessionSummary[]): Promise<void> {
|
|
132
149
|
for (const task of this.ledger.state().tasks) {
|
|
133
150
|
for (const execution of task.executions) {
|
|
134
151
|
if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
|
|
135
152
|
try {
|
|
136
|
-
const result = await this.runner.inspect(execution.sessionId, execution.startedAt)
|
|
153
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
|
|
137
154
|
if (result.outcome === 'pending') continue
|
|
138
155
|
this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
|
|
139
156
|
} catch {
|
package/src/protocol.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface TaskBoardSnapshot {
|
|
|
33
33
|
power: TaskBoardPowerSnapshot
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** SSE event frame: revision/scheduler/power only, never the task list. */
|
|
37
|
+
export interface TaskBoardEventPayload {
|
|
38
|
+
revision: number
|
|
39
|
+
scheduler: TaskBoardSchedulerSnapshot
|
|
40
|
+
power: TaskBoardPowerSnapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export type TaskBoardAction =
|
|
37
44
|
| { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
|
|
38
45
|
| { kind: 'create'; id: string; input: NewTaskInput }
|