@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.1

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.
@@ -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 }