@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.
- 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 +158 -28
- 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 -1
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/TaskBoardSettingsCard.tsx +10 -10
- package/src/client/board/TaskBoard.tsx +3 -3
- package/src/client/board/TaskCard.tsx +1 -0
- 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/schedule.ts +57 -10
- package/src/host-ledger.ts +64 -3
- 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-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 }
|