@linxin666/dsh-client-ui-task-board 0.1.20 → 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 +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -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/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 +47 -15
- package/lib/types/core/schedule.d.ts +11 -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 +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -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 +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- 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 +222 -24
- package/src/core/schedule.ts +77 -13
- 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 +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
4
|
+
import type { TaskBoardHostService } from './host-service.ts'
|
|
5
|
+
import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
|
|
6
|
+
import { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
|
|
7
|
+
|
|
8
|
+
const ACTION_LIMIT = 64 * 1024
|
|
9
|
+
const IMPORT_LIMIT = 2 * 1024 * 1024
|
|
10
|
+
const HEARTBEAT_MS = 15_000
|
|
11
|
+
|
|
12
|
+
/** Header replaced by an authenticated same-host reverse proxy. */
|
|
13
|
+
export const TASK_BOARD_PROXY_TOKEN_HEADER = 'x-dsh-task-board-proxy-token'
|
|
14
|
+
|
|
15
|
+
/** Optional authenticated reverse-proxy access layered over the loopback default. */
|
|
16
|
+
export interface TaskBoardRouteAccess {
|
|
17
|
+
trustedProxyHosts?: readonly string[]
|
|
18
|
+
proxyToken?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ResolvedRouteAccess {
|
|
22
|
+
trustedProxyHosts: ReadonlySet<string>
|
|
23
|
+
proxyToken?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
28
|
+
res.end(JSON.stringify(body))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseAuthority(authority: string): { canonical: string; url: URL } | undefined {
|
|
32
|
+
if (authority.trim() !== authority) return undefined
|
|
33
|
+
const match = authority.startsWith('[')
|
|
34
|
+
? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority)
|
|
35
|
+
: /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority)
|
|
36
|
+
if (match === null) return undefined
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(`http://${authority}`)
|
|
39
|
+
if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined
|
|
40
|
+
const rawPort = match[1]
|
|
41
|
+
if (rawPort !== undefined && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65_535)) return undefined
|
|
42
|
+
return { canonical: url.hostname.toLowerCase() + (rawPort === undefined ? '' : `:${rawPort}`), url }
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
|
|
49
|
+
const trustedProxyHosts = new Set<string>()
|
|
50
|
+
for (const authority of access.trustedProxyHosts ?? []) {
|
|
51
|
+
const parsed = parseAuthority(authority)
|
|
52
|
+
if (parsed === undefined || parsed.canonical !== authority.toLowerCase()) {
|
|
53
|
+
throw new Error(`task-board: trustedProxyHosts entry ${JSON.stringify(authority)} is not a canonical host[:port] authority`)
|
|
54
|
+
}
|
|
55
|
+
trustedProxyHosts.add(parsed.canonical)
|
|
56
|
+
}
|
|
57
|
+
if (trustedProxyHosts.size > 0 && (access.proxyToken === undefined || access.proxyToken === '')) {
|
|
58
|
+
throw new Error('task-board: authenticated proxy hosts require a non-empty proxy token')
|
|
59
|
+
}
|
|
60
|
+
return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
|
|
61
|
+
}
|
|
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
|
+
*/
|
|
69
|
+
function browserSameOriginMarker(req: IncomingMessage): boolean {
|
|
70
|
+
const site = req.headers['sec-fetch-site']
|
|
71
|
+
return site === 'same-origin' || typeof req.headers.origin === 'string'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sameAuthority(req: IncomingMessage, host: URL): boolean {
|
|
75
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
76
|
+
const origin = req.headers.origin
|
|
77
|
+
if (origin === undefined) return req.headers['sec-fetch-site'] === 'same-origin'
|
|
78
|
+
try {
|
|
79
|
+
return new URL(origin).host === host.host
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchesToken(candidate: string | string[] | undefined, expected: string | undefined): boolean {
|
|
86
|
+
if (typeof candidate !== 'string' || expected === undefined || candidate === '' || expected === '') return false
|
|
87
|
+
const actual = Buffer.from(candidate)
|
|
88
|
+
const wanted = Buffer.from(expected)
|
|
89
|
+
return actual.length === wanted.length && timingSafeEqual(actual, wanted)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Task-board route fence. Direct desktop access uses the repository-wide
|
|
94
|
+
* loopback socket + Host guard and additionally requires a browser same-origin
|
|
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).
|
|
98
|
+
* Authenticated proxies must be explicitly allowlisted and replace the
|
|
99
|
+
* internal token header after their own authentication step.
|
|
100
|
+
*/
|
|
101
|
+
export function isTrustedTaskBoardRequest(req: IncomingMessage, access: ResolvedRouteAccess): boolean {
|
|
102
|
+
if (!browserSameOriginMarker(req)) return false
|
|
103
|
+
if (isLoopbackRequest(req)) return true
|
|
104
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) return false
|
|
105
|
+
const host = req.headers.host
|
|
106
|
+
if (typeof host !== 'string') return false
|
|
107
|
+
const parsed = parseAuthority(host)
|
|
108
|
+
if (parsed === undefined || parsed.canonical !== host.toLowerCase()) return false
|
|
109
|
+
if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false
|
|
110
|
+
return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unknown }> {
|
|
114
|
+
const chunks: Buffer[] = []
|
|
115
|
+
let size = 0
|
|
116
|
+
for await (const chunk of req) {
|
|
117
|
+
const buffer = chunk as Buffer
|
|
118
|
+
size += buffer.length
|
|
119
|
+
if (size > IMPORT_LIMIT) throw new Error('body-too-large')
|
|
120
|
+
chunks.push(buffer)
|
|
121
|
+
}
|
|
122
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
123
|
+
return { raw, value: JSON.parse(raw) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskBoardRouteAccess = {}): WebRoute[] {
|
|
127
|
+
const resolvedAccess = resolveAccess(access)
|
|
128
|
+
const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
129
|
+
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
|
|
130
|
+
json(res, 403, { ok: false, error: 'forbidden' })
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
const state: WebRoute = {
|
|
134
|
+
kind: 'exact',
|
|
135
|
+
path: `${TASK_BOARD_API_PREFIX}/state`,
|
|
136
|
+
handler: (req, res): void => {
|
|
137
|
+
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
138
|
+
if (!guard(req, res)) return
|
|
139
|
+
json(res, 200, service.snapshot())
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
const action: WebRoute = {
|
|
143
|
+
kind: 'exact',
|
|
144
|
+
path: `${TASK_BOARD_API_PREFIX}/action`,
|
|
145
|
+
handler: async (req, res): Promise<void> => {
|
|
146
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
147
|
+
if (!guard(req, res)) return
|
|
148
|
+
if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
149
|
+
return json(res, 415, { ok: false, error: 'json-required' })
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const body = await readBody(req)
|
|
153
|
+
const parsed = parseActionEnvelope(body.value)
|
|
154
|
+
if (parsed === undefined) return json(res, 400, { ok: false, error: 'invalid-action' })
|
|
155
|
+
if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
|
|
156
|
+
return json(res, 413, { ok: false, error: 'body-too-large' })
|
|
157
|
+
}
|
|
158
|
+
json(res, 200, service.apply(parsed.requestId, parsed.action))
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
161
|
+
json(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
const events: WebRoute = {
|
|
166
|
+
kind: 'exact',
|
|
167
|
+
path: `${TASK_BOARD_API_PREFIX}/events`,
|
|
168
|
+
handler: (req, res): void => {
|
|
169
|
+
if (req.method !== 'GET') {
|
|
170
|
+
res.writeHead(405)
|
|
171
|
+
res.end()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (!guard(req, res)) return
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
177
|
+
'cache-control': 'no-cache',
|
|
178
|
+
connection: 'keep-alive',
|
|
179
|
+
})
|
|
180
|
+
const push = (): void => {
|
|
181
|
+
const payload = service.eventPayload()
|
|
182
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
183
|
+
}
|
|
184
|
+
const unsubscribe = service.subscribe(push)
|
|
185
|
+
const heartbeat = setInterval(() => { res.write(': ping\n\n') }, HEARTBEAT_MS)
|
|
186
|
+
const close = (): void => {
|
|
187
|
+
clearInterval(heartbeat)
|
|
188
|
+
unsubscribe()
|
|
189
|
+
}
|
|
190
|
+
req.once('close', close)
|
|
191
|
+
res.once('close', close)
|
|
192
|
+
push()
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
return [state, action, events]
|
|
196
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ApiProxy, RpcId } from '@deepseek-ai/dsh-host-apiproxy'
|
|
2
|
+
import type { TaskRecord } from './core/tasks.ts'
|
|
3
|
+
|
|
4
|
+
function request<T>(payload: T) {
|
|
5
|
+
return { rpcId: `task-board-${crypto.randomUUID()}` as RpcId, payload }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function failure(error: { code: string; message: string }): Error {
|
|
9
|
+
return new Error(`${error.code}: ${error.message}`)
|
|
10
|
+
}
|
|
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
|
+
|
|
18
|
+
export type ExecutionInspection =
|
|
19
|
+
| { outcome: 'pending' }
|
|
20
|
+
| { outcome: 'succeeded' }
|
|
21
|
+
| { outcome: 'failed'; error: string }
|
|
22
|
+
| { outcome: 'cancelled'; error: string }
|
|
23
|
+
|
|
24
|
+
/** A post-create launch failure that still identifies the session to the ledger. */
|
|
25
|
+
export class SessionLaunchError extends Error {
|
|
26
|
+
constructor(readonly sessionId: string, cause: unknown) {
|
|
27
|
+
super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause })
|
|
28
|
+
this.name = 'SessionLaunchError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isErrorTurnEnd(data: unknown): boolean {
|
|
33
|
+
if (typeof data !== 'object' || data === null) return false
|
|
34
|
+
const reason = (data as { reason?: unknown }).reason
|
|
35
|
+
return typeof reason === 'object' && reason !== null && (reason as { kind?: unknown }).kind === 'error'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class HostExecutionRunner {
|
|
39
|
+
constructor(private readonly api: ApiProxy) {}
|
|
40
|
+
|
|
41
|
+
async launch(task: TaskRecord): Promise<string> {
|
|
42
|
+
if (task.workspaceId !== undefined) {
|
|
43
|
+
const workspaces = await this.api.workspace.list(request({}))
|
|
44
|
+
if (!workspaces.result.ok) throw failure(workspaces.result.error)
|
|
45
|
+
if (!workspaces.result.value.items.some(item => item.workspaceId === task.workspaceId)) {
|
|
46
|
+
throw new Error(`workspace not found: ${task.workspaceId}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (task.mode !== undefined) {
|
|
50
|
+
const presets = await this.api.agentPresets.list(request({}))
|
|
51
|
+
if (!presets.result.ok) throw failure(presets.result.error)
|
|
52
|
+
const preset = presets.result.value.presets.find(item => item.id === task.mode)
|
|
53
|
+
if (preset === undefined) throw new Error(`agent preset not found: ${task.mode}`)
|
|
54
|
+
if (preset.broken !== undefined) throw new Error(`agent preset is unavailable: ${preset.broken}`)
|
|
55
|
+
}
|
|
56
|
+
const created = await this.api.sessions.create(request({
|
|
57
|
+
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId as never }),
|
|
58
|
+
...(task.mode === undefined ? {} : { agentPreset: task.mode }),
|
|
59
|
+
}))
|
|
60
|
+
if (!created.result.ok) throw failure(created.result.error)
|
|
61
|
+
const sessionId = created.result.value.sessionId
|
|
62
|
+
try {
|
|
63
|
+
const renamed = await this.api.sessions.rename(request({ sessionId, title: task.title }))
|
|
64
|
+
if (!renamed.result.ok) throw failure(renamed.result.error)
|
|
65
|
+
if (task.permission !== undefined) {
|
|
66
|
+
const command = await this.api.sessions.prompt(request({
|
|
67
|
+
sessionId,
|
|
68
|
+
mode: 'queue' as const,
|
|
69
|
+
content: [{ type: 'text' as const, text: `/permission ${task.permission}` }],
|
|
70
|
+
}))
|
|
71
|
+
if (!command.result.ok) throw failure(command.result.error)
|
|
72
|
+
if (command.result.value.command?.kind !== 'success') throw new Error('permission command was not acknowledged')
|
|
73
|
+
}
|
|
74
|
+
const prompt = await this.api.sessions.prompt(request({
|
|
75
|
+
sessionId,
|
|
76
|
+
mode: 'queue' as const,
|
|
77
|
+
content: [{ type: 'text' as const, text: task.prompt !== '' ? task.prompt : task.title }],
|
|
78
|
+
}))
|
|
79
|
+
if (!prompt.result.ok) throw failure(prompt.result.error)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new SessionLaunchError(sessionId, error)
|
|
82
|
+
}
|
|
83
|
+
return sessionId
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
|
|
87
|
+
try {
|
|
88
|
+
const response = await this.api.sessions.list(request({}))
|
|
89
|
+
return response.result.ok
|
|
90
|
+
? { known: true, count: response.result.value.items.filter(item => item.running).length, items: response.result.value.items }
|
|
91
|
+
: { known: false }
|
|
92
|
+
} catch {
|
|
93
|
+
return { known: false }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
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)
|
|
113
|
+
if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
114
|
+
if (summary.running) return { outcome: 'pending' }
|
|
115
|
+
const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
|
|
116
|
+
let beforeSeq: number | undefined
|
|
117
|
+
let reachedExecutionBoundary = false
|
|
118
|
+
for (let page = 0; page < 100; page += 1) {
|
|
119
|
+
const history = await this.api.sessions.history(request({
|
|
120
|
+
sessionId: summary.sessionId,
|
|
121
|
+
maxMessages: 100,
|
|
122
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
123
|
+
}))
|
|
124
|
+
if (!history.result.ok) return { outcome: 'pending' }
|
|
125
|
+
events.push(...history.result.value.events)
|
|
126
|
+
const oldestTime = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
|
|
127
|
+
const time = entry.event.time
|
|
128
|
+
return typeof time !== 'number' ? oldest : oldest === undefined ? time : Math.min(oldest, time)
|
|
129
|
+
}, undefined)
|
|
130
|
+
if (!history.result.value.hasMore || (oldestTime !== undefined && oldestTime <= startedAt)) {
|
|
131
|
+
reachedExecutionBoundary = true
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
const oldestSeq = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
|
|
135
|
+
const seq = entry.event.seq
|
|
136
|
+
return typeof seq !== 'number' ? oldest : oldest === undefined ? seq : Math.min(oldest, seq)
|
|
137
|
+
}, undefined)
|
|
138
|
+
if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
|
|
139
|
+
beforeSeq = oldestSeq
|
|
140
|
+
}
|
|
141
|
+
if (!reachedExecutionBoundary) return { outcome: 'pending' }
|
|
142
|
+
const turnEnd = events
|
|
143
|
+
.filter(entry => entry.event.type === 'turn/end' && (
|
|
144
|
+
startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
|
|
145
|
+
))
|
|
146
|
+
.sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
|
|
147
|
+
if (turnEnd === undefined) return { outcome: 'pending' }
|
|
148
|
+
return isErrorTurnEnd(turnEnd.event.data)
|
|
149
|
+
? { outcome: 'failed', error: 'agent turn ended with an error' }
|
|
150
|
+
: { outcome: 'succeeded' }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
2
|
+
import { nextRunAtMs } from './core/schedule.ts'
|
|
3
|
+
import type { TaskRecord } from './core/tasks.ts'
|
|
4
|
+
import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
|
|
5
|
+
import { HostExecutionRunner, SessionLaunchError, type SessionSummary } from './host-runner.ts'
|
|
6
|
+
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
7
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
|
|
8
|
+
|
|
9
|
+
const SESSION_POLL_MS = 5_000
|
|
10
|
+
const SCHEDULE_TICK_MS = 30_000
|
|
11
|
+
const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
|
|
12
|
+
|
|
13
|
+
export class TaskBoardHostService {
|
|
14
|
+
readonly ledger: HostTaskLedger
|
|
15
|
+
readonly runner: HostExecutionRunner
|
|
16
|
+
readonly power: PowerInhibitor
|
|
17
|
+
private readonly listeners = new Set<() => void>()
|
|
18
|
+
private timers: Array<ReturnType<typeof setInterval>> = []
|
|
19
|
+
private lastScheduleTick: number | undefined
|
|
20
|
+
private disposed = false
|
|
21
|
+
private pollInFlight = false
|
|
22
|
+
private tickInFlight = false
|
|
23
|
+
private active = true
|
|
24
|
+
private preventIdleSleep = false
|
|
25
|
+
private lastPowerJson = ''
|
|
26
|
+
private readonly now: () => number
|
|
27
|
+
|
|
28
|
+
constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
|
|
29
|
+
this.ledger = options.ledger ?? new HostTaskLedger()
|
|
30
|
+
this.runner = new HostExecutionRunner(api)
|
|
31
|
+
this.power = options.power ?? new PowerInhibitor()
|
|
32
|
+
this.now = options.now ?? Date.now
|
|
33
|
+
this.ledger.subscribe(() => {
|
|
34
|
+
this.syncPowerReasons()
|
|
35
|
+
this.emit()
|
|
36
|
+
})
|
|
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
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
start(): void {
|
|
49
|
+
if (this.disposed || this.timers.length > 0) return
|
|
50
|
+
this.syncPowerReasons()
|
|
51
|
+
this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
|
|
52
|
+
this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
|
|
53
|
+
this.schedulePoll()
|
|
54
|
+
this.scheduleTick(true)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setConfiguration(active: boolean, preventIdleSleep: boolean): void {
|
|
58
|
+
const resumed = !this.active && active
|
|
59
|
+
this.active = active
|
|
60
|
+
this.preventIdleSleep = preventIdleSleep
|
|
61
|
+
if (resumed) {
|
|
62
|
+
const current = this.power.snapshot()
|
|
63
|
+
this.power.updateReasons({
|
|
64
|
+
runningSessions: current.runningSessions,
|
|
65
|
+
armedSchedules: this.armedSchedules(),
|
|
66
|
+
sessionStateKnown: false,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
this.power.setEnabled(active && preventIdleSleep)
|
|
70
|
+
if (resumed) {
|
|
71
|
+
this.schedulePoll()
|
|
72
|
+
this.scheduleTick(true)
|
|
73
|
+
}
|
|
74
|
+
this.emit()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
snapshot(): TaskBoardSnapshot {
|
|
78
|
+
const state = this.ledger.state()
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: 2,
|
|
81
|
+
revision: state.revision,
|
|
82
|
+
tasks: state.tasks,
|
|
83
|
+
scheduler: state.scheduler,
|
|
84
|
+
power: this.power.snapshot(),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
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
|
+
|
|
94
|
+
subscribe(listener: () => void): () => void {
|
|
95
|
+
this.listeners.add(listener)
|
|
96
|
+
return () => { this.listeners.delete(listener) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot {
|
|
100
|
+
if (!this.active) throw new Error('task board is disabled')
|
|
101
|
+
const result = this.ledger.applyRequest(requestId, action)
|
|
102
|
+
if (result.run !== undefined) this.scheduleLaunch(result.run)
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: 2,
|
|
105
|
+
revision: result.state.revision,
|
|
106
|
+
tasks: result.state.tasks,
|
|
107
|
+
scheduler: result.state.scheduler,
|
|
108
|
+
power: this.power.snapshot(),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose(): void {
|
|
113
|
+
this.disposed = true
|
|
114
|
+
for (const timer of this.timers.splice(0)) clearInterval(timer)
|
|
115
|
+
this.power.dispose()
|
|
116
|
+
this.ledger.dispose()
|
|
117
|
+
this.listeners.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async launch(opened: OpenedRun): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
const sessionId = await this.runner.launch(opened.task)
|
|
123
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof SessionLaunchError) {
|
|
126
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
|
|
127
|
+
}
|
|
128
|
+
this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async pollSessions(): Promise<void> {
|
|
133
|
+
if (this.disposed) return
|
|
134
|
+
if (!this.active && !this.hasOpenExecutions()) return
|
|
135
|
+
const running = await this.runner.listRunning()
|
|
136
|
+
const previous = this.power.snapshot()
|
|
137
|
+
this.power.updateReasons({
|
|
138
|
+
runningSessions: running.known ? running.count : previous.runningSessions,
|
|
139
|
+
armedSchedules: this.armedSchedules(),
|
|
140
|
+
sessionStateKnown: running.known,
|
|
141
|
+
})
|
|
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)
|
|
145
|
+
}
|
|
146
|
+
|
|
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> {
|
|
149
|
+
for (const task of this.ledger.state().tasks) {
|
|
150
|
+
for (const execution of task.executions) {
|
|
151
|
+
if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
|
|
152
|
+
try {
|
|
153
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
|
|
154
|
+
if (result.outcome === 'pending') continue
|
|
155
|
+
this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
|
|
156
|
+
} catch {
|
|
157
|
+
// A transient inspection failure never settles a running execution.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async tickSchedule(first: boolean): Promise<void> {
|
|
164
|
+
if (this.disposed || !this.active) return
|
|
165
|
+
const now = this.now()
|
|
166
|
+
const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
|
|
167
|
+
this.lastScheduleTick = now
|
|
168
|
+
this.ledger.setScheduler({ lastTickAt: now })
|
|
169
|
+
if (recovered) {
|
|
170
|
+
this.ledger.skipMissed(now)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
for (const task of this.ledger.state().tasks) {
|
|
174
|
+
if (task.archivedAt !== undefined) continue
|
|
175
|
+
const schedule = task.schedule
|
|
176
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
177
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
178
|
+
const opened = this.ledger.openScheduled(task.id, next, now)
|
|
179
|
+
if (opened !== undefined) this.scheduleLaunch(opened)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private armedSchedules(): number {
|
|
184
|
+
return this.ledger.state().tasks.filter((task: TaskRecord) => task.archivedAt === undefined && task.schedule?.enabled === true).length
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private hasOpenExecutions(): boolean {
|
|
188
|
+
return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private scheduleLaunch(opened: OpenedRun): void {
|
|
192
|
+
void this.launch(opened).catch(error => {
|
|
193
|
+
console.error('[dsh-task-board] execution launch settlement failed', error)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private schedulePoll(): void {
|
|
198
|
+
if (this.pollInFlight || this.disposed) return
|
|
199
|
+
this.pollInFlight = true
|
|
200
|
+
void this.pollSessions().catch(error => {
|
|
201
|
+
console.error('[dsh-task-board] session polling failed', error)
|
|
202
|
+
}).finally(() => { this.pollInFlight = false })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private scheduleTick(first: boolean): void {
|
|
206
|
+
if (this.tickInFlight || this.disposed) return
|
|
207
|
+
this.tickInFlight = true
|
|
208
|
+
void this.tickSchedule(first).catch(error => {
|
|
209
|
+
console.error('[dsh-task-board] scheduler tick failed', error)
|
|
210
|
+
}).finally(() => { this.tickInFlight = false })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private syncPowerReasons(): void {
|
|
214
|
+
const current = this.power.snapshot()
|
|
215
|
+
this.power.updateReasons({
|
|
216
|
+
runningSessions: current.runningSessions,
|
|
217
|
+
armedSchedules: this.armedSchedules(),
|
|
218
|
+
sessionStateKnown: current.sessionStateKnown,
|
|
219
|
+
})
|
|
220
|
+
this.power.setEnabled(this.active && this.preventIdleSleep)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private emit(): void {
|
|
224
|
+
for (const listener of [...this.listeners]) listener()
|
|
225
|
+
}
|
|
226
|
+
}
|