@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.
Files changed (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +349 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +161 -31
  7. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
  9. package/lib/types/client/host-api.d.ts +3 -3
  10. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  11. package/lib/types/client/sidebar-entry.d.ts +8 -10
  12. package/lib/types/core/controller.d.ts +9 -2
  13. package/lib/types/core/schedule.d.ts +7 -0
  14. package/lib/types/host-ledger.d.ts +10 -0
  15. package/lib/types/host-routes.d.ts +3 -1
  16. package/lib/types/host-runner.d.ts +12 -1
  17. package/lib/types/host-service.d.ts +5 -1
  18. package/lib/types/protocol.d.ts +6 -0
  19. package/package.json +1 -2
  20. package/src/client/PluginSettingsCard.tsx +219 -18
  21. package/src/client/TaskBoardSettingsCard.tsx +10 -10
  22. package/src/client/board/TaskBoard.tsx +5 -5
  23. package/src/client/board/TaskCard.tsx +1 -0
  24. package/src/client/board/TaskDetail.tsx +1 -1
  25. package/src/client/board-mount.tsx +1 -0
  26. package/src/client/host-api.ts +12 -3
  27. package/src/client/settings-card.module.css +90 -1
  28. package/src/client/sidebar-entry-core.ts +208 -0
  29. package/src/client/sidebar-entry.ts +24 -144
  30. package/src/core/controller.ts +20 -3
  31. package/src/core/execution.ts +1 -2
  32. package/src/core/schedule.ts +57 -10
  33. package/src/host-ledger.ts +67 -6
  34. package/src/host-routes.ts +11 -3
  35. package/src/host-runner.ts +24 -6
  36. package/src/host-service.ts +24 -7
  37. package/src/protocol.ts +7 -0
@@ -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[], now: number): TaskRecord[] {
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
- state(): LedgerState {
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, tasks: cloneTasks(this.document.tasks), scheduler: { ...scheduler } }
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, now)
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, this.now())
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
- ...(typeof parsed.scheduler?.lastTickAt === 'number' ? { lastTickAt: parsed.scheduler.lastTickAt } : {}),
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 })
@@ -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, so a bare local curl cannot exercise the agent control plane.
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 snapshot = service.snapshot()
174
- res.write(`data: ${JSON.stringify({ revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power })}\n\n`)
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)
@@ -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
- async inspect(sessionId: string, startedAt = 0): Promise<ExecutionInspection> {
92
- const sessions = await this.api.sessions.list(request({}))
93
- if (!sessions.result.ok) return { outcome: 'pending' }
94
- const summary = sessions.result.value.items.find(item => item.sessionId === sessionId)
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 } }> = []
@@ -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(() => { this.emit() })
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
- if (running.known) await this.reconcileExecutions()
128
- this.emit()
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
- private async reconcileExecutions(): Promise<void> {
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 }