@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.0
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 +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +589 -602
- package/lib/client.js.map +1 -1
- package/lib/index.js +2187 -6
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -2
- package/lib/types/core/tasks.d.ts +7 -5
- 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 +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -0
- package/lib/types/index.d.ts +20 -10
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +22 -13
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +84 -59
- package/src/client/board/status-key.ts +11 -0
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- package/src/core/store.ts +5 -14
- package/src/core/tasks.ts +7 -7
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +58 -12
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -67
- package/src/core/scheduler.ts +0 -151
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
export type ExecutionInspection =
|
|
13
|
+
| { outcome: 'pending' }
|
|
14
|
+
| { outcome: 'succeeded' }
|
|
15
|
+
| { outcome: 'failed'; error: string }
|
|
16
|
+
| { outcome: 'cancelled'; error: string }
|
|
17
|
+
|
|
18
|
+
/** A post-create launch failure that still identifies the session to the ledger. */
|
|
19
|
+
export class SessionLaunchError extends Error {
|
|
20
|
+
constructor(readonly sessionId: string, cause: unknown) {
|
|
21
|
+
super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause })
|
|
22
|
+
this.name = 'SessionLaunchError'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isErrorTurnEnd(data: unknown): boolean {
|
|
27
|
+
if (typeof data !== 'object' || data === null) return false
|
|
28
|
+
const reason = (data as { reason?: unknown }).reason
|
|
29
|
+
return typeof reason === 'object' && reason !== null && (reason as { kind?: unknown }).kind === 'error'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class HostExecutionRunner {
|
|
33
|
+
constructor(private readonly api: ApiProxy) {}
|
|
34
|
+
|
|
35
|
+
async launch(task: TaskRecord): Promise<string> {
|
|
36
|
+
if (task.workspaceId !== undefined) {
|
|
37
|
+
const workspaces = await this.api.workspace.list(request({}))
|
|
38
|
+
if (!workspaces.result.ok) throw failure(workspaces.result.error)
|
|
39
|
+
if (!workspaces.result.value.items.some(item => item.workspaceId === task.workspaceId)) {
|
|
40
|
+
throw new Error(`workspace not found: ${task.workspaceId}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (task.mode !== undefined) {
|
|
44
|
+
const presets = await this.api.agentPresets.list(request({}))
|
|
45
|
+
if (!presets.result.ok) throw failure(presets.result.error)
|
|
46
|
+
const preset = presets.result.value.presets.find(item => item.id === task.mode)
|
|
47
|
+
if (preset === undefined) throw new Error(`agent preset not found: ${task.mode}`)
|
|
48
|
+
if (preset.broken !== undefined) throw new Error(`agent preset is unavailable: ${preset.broken}`)
|
|
49
|
+
}
|
|
50
|
+
const created = await this.api.sessions.create(request({
|
|
51
|
+
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId as never }),
|
|
52
|
+
...(task.mode === undefined ? {} : { agentPreset: task.mode }),
|
|
53
|
+
}))
|
|
54
|
+
if (!created.result.ok) throw failure(created.result.error)
|
|
55
|
+
const sessionId = created.result.value.sessionId
|
|
56
|
+
try {
|
|
57
|
+
const renamed = await this.api.sessions.rename(request({ sessionId, title: task.title }))
|
|
58
|
+
if (!renamed.result.ok) throw failure(renamed.result.error)
|
|
59
|
+
if (task.permission !== undefined) {
|
|
60
|
+
const command = await this.api.sessions.prompt(request({
|
|
61
|
+
sessionId,
|
|
62
|
+
mode: 'queue' as const,
|
|
63
|
+
content: [{ type: 'text' as const, text: `/permission ${task.permission}` }],
|
|
64
|
+
}))
|
|
65
|
+
if (!command.result.ok) throw failure(command.result.error)
|
|
66
|
+
if (command.result.value.command?.kind !== 'success') throw new Error('permission command was not acknowledged')
|
|
67
|
+
}
|
|
68
|
+
const prompt = await this.api.sessions.prompt(request({
|
|
69
|
+
sessionId,
|
|
70
|
+
mode: 'queue' as const,
|
|
71
|
+
content: [{ type: 'text' as const, text: task.prompt !== '' ? task.prompt : task.title }],
|
|
72
|
+
}))
|
|
73
|
+
if (!prompt.result.ok) throw failure(prompt.result.error)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new SessionLaunchError(sessionId, error)
|
|
76
|
+
}
|
|
77
|
+
return sessionId
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async listRunning(): Promise<{ known: true; count: number } | { known: false }> {
|
|
81
|
+
try {
|
|
82
|
+
const response = await this.api.sessions.list(request({}))
|
|
83
|
+
return response.result.ok
|
|
84
|
+
? { known: true, count: response.result.value.items.filter(item => item.running).length }
|
|
85
|
+
: { known: false }
|
|
86
|
+
} catch {
|
|
87
|
+
return { known: false }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
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)
|
|
95
|
+
if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
96
|
+
if (summary.running) return { outcome: 'pending' }
|
|
97
|
+
const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
|
|
98
|
+
let beforeSeq: number | undefined
|
|
99
|
+
let reachedExecutionBoundary = false
|
|
100
|
+
for (let page = 0; page < 100; page += 1) {
|
|
101
|
+
const history = await this.api.sessions.history(request({
|
|
102
|
+
sessionId: summary.sessionId,
|
|
103
|
+
maxMessages: 100,
|
|
104
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
105
|
+
}))
|
|
106
|
+
if (!history.result.ok) return { outcome: 'pending' }
|
|
107
|
+
events.push(...history.result.value.events)
|
|
108
|
+
const oldestTime = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
|
|
109
|
+
const time = entry.event.time
|
|
110
|
+
return typeof time !== 'number' ? oldest : oldest === undefined ? time : Math.min(oldest, time)
|
|
111
|
+
}, undefined)
|
|
112
|
+
if (!history.result.value.hasMore || (oldestTime !== undefined && oldestTime <= startedAt)) {
|
|
113
|
+
reachedExecutionBoundary = true
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
const oldestSeq = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
|
|
117
|
+
const seq = entry.event.seq
|
|
118
|
+
return typeof seq !== 'number' ? oldest : oldest === undefined ? seq : Math.min(oldest, seq)
|
|
119
|
+
}, undefined)
|
|
120
|
+
if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
|
|
121
|
+
beforeSeq = oldestSeq
|
|
122
|
+
}
|
|
123
|
+
if (!reachedExecutionBoundary) return { outcome: 'pending' }
|
|
124
|
+
const turnEnd = events
|
|
125
|
+
.filter(entry => entry.event.type === 'turn/end' && (
|
|
126
|
+
startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
|
|
127
|
+
))
|
|
128
|
+
.sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
|
|
129
|
+
if (turnEnd === undefined) return { outcome: 'pending' }
|
|
130
|
+
return isErrorTurnEnd(turnEnd.event.data)
|
|
131
|
+
? { outcome: 'failed', error: 'agent turn ended with an error' }
|
|
132
|
+
: { outcome: 'succeeded' }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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 } from './host-runner.ts'
|
|
6
|
+
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
7
|
+
import type { TaskBoardAction, 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 readonly now: () => number
|
|
26
|
+
|
|
27
|
+
constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
|
|
28
|
+
this.ledger = options.ledger ?? new HostTaskLedger()
|
|
29
|
+
this.runner = new HostExecutionRunner(api)
|
|
30
|
+
this.power = options.power ?? new PowerInhibitor()
|
|
31
|
+
this.now = options.now ?? Date.now
|
|
32
|
+
this.ledger.subscribe(() => {
|
|
33
|
+
this.syncPowerReasons()
|
|
34
|
+
this.emit()
|
|
35
|
+
})
|
|
36
|
+
this.power.subscribe(() => { this.emit() })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
start(): void {
|
|
40
|
+
if (this.disposed || this.timers.length > 0) return
|
|
41
|
+
this.syncPowerReasons()
|
|
42
|
+
this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
|
|
43
|
+
this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
|
|
44
|
+
this.schedulePoll()
|
|
45
|
+
this.scheduleTick(true)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setConfiguration(active: boolean, preventIdleSleep: boolean): void {
|
|
49
|
+
const resumed = !this.active && active
|
|
50
|
+
this.active = active
|
|
51
|
+
this.preventIdleSleep = preventIdleSleep
|
|
52
|
+
if (resumed) {
|
|
53
|
+
const current = this.power.snapshot()
|
|
54
|
+
this.power.updateReasons({
|
|
55
|
+
runningSessions: current.runningSessions,
|
|
56
|
+
armedSchedules: this.armedSchedules(),
|
|
57
|
+
sessionStateKnown: false,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
this.power.setEnabled(active && preventIdleSleep)
|
|
61
|
+
if (resumed) {
|
|
62
|
+
this.schedulePoll()
|
|
63
|
+
this.scheduleTick(true)
|
|
64
|
+
}
|
|
65
|
+
this.emit()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
snapshot(): TaskBoardSnapshot {
|
|
69
|
+
const state = this.ledger.state()
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: 2,
|
|
72
|
+
revision: state.revision,
|
|
73
|
+
tasks: state.tasks,
|
|
74
|
+
scheduler: state.scheduler,
|
|
75
|
+
power: this.power.snapshot(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subscribe(listener: () => void): () => void {
|
|
80
|
+
this.listeners.add(listener)
|
|
81
|
+
return () => { this.listeners.delete(listener) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot {
|
|
85
|
+
if (!this.active) throw new Error('task board is disabled')
|
|
86
|
+
const result = this.ledger.applyRequest(requestId, action)
|
|
87
|
+
if (result.run !== undefined) this.scheduleLaunch(result.run)
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 2,
|
|
90
|
+
revision: result.state.revision,
|
|
91
|
+
tasks: result.state.tasks,
|
|
92
|
+
scheduler: result.state.scheduler,
|
|
93
|
+
power: this.power.snapshot(),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
dispose(): void {
|
|
98
|
+
this.disposed = true
|
|
99
|
+
for (const timer of this.timers.splice(0)) clearInterval(timer)
|
|
100
|
+
this.power.dispose()
|
|
101
|
+
this.ledger.dispose()
|
|
102
|
+
this.listeners.clear()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async launch(opened: OpenedRun): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const sessionId = await this.runner.launch(opened.task)
|
|
108
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof SessionLaunchError) {
|
|
111
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
|
|
112
|
+
}
|
|
113
|
+
this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async pollSessions(): Promise<void> {
|
|
118
|
+
if (this.disposed) return
|
|
119
|
+
if (!this.active && !this.hasOpenExecutions()) return
|
|
120
|
+
const running = await this.runner.listRunning()
|
|
121
|
+
const previous = this.power.snapshot()
|
|
122
|
+
this.power.updateReasons({
|
|
123
|
+
runningSessions: running.known ? running.count : previous.runningSessions,
|
|
124
|
+
armedSchedules: this.armedSchedules(),
|
|
125
|
+
sessionStateKnown: running.known,
|
|
126
|
+
})
|
|
127
|
+
if (running.known) await this.reconcileExecutions()
|
|
128
|
+
this.emit()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async reconcileExecutions(): Promise<void> {
|
|
132
|
+
for (const task of this.ledger.state().tasks) {
|
|
133
|
+
for (const execution of task.executions) {
|
|
134
|
+
if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
|
|
135
|
+
try {
|
|
136
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt)
|
|
137
|
+
if (result.outcome === 'pending') continue
|
|
138
|
+
this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
|
|
139
|
+
} catch {
|
|
140
|
+
// A transient inspection failure never settles a running execution.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async tickSchedule(first: boolean): Promise<void> {
|
|
147
|
+
if (this.disposed || !this.active) return
|
|
148
|
+
const now = this.now()
|
|
149
|
+
const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
|
|
150
|
+
this.lastScheduleTick = now
|
|
151
|
+
this.ledger.setScheduler({ lastTickAt: now })
|
|
152
|
+
if (recovered) {
|
|
153
|
+
this.ledger.skipMissed(now)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
for (const task of this.ledger.state().tasks) {
|
|
157
|
+
if (task.archivedAt !== undefined) continue
|
|
158
|
+
const schedule = task.schedule
|
|
159
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
160
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
161
|
+
const opened = this.ledger.openScheduled(task.id, next, now)
|
|
162
|
+
if (opened !== undefined) this.scheduleLaunch(opened)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private armedSchedules(): number {
|
|
167
|
+
return this.ledger.state().tasks.filter((task: TaskRecord) => task.archivedAt === undefined && task.schedule?.enabled === true).length
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private hasOpenExecutions(): boolean {
|
|
171
|
+
return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private scheduleLaunch(opened: OpenedRun): void {
|
|
175
|
+
void this.launch(opened).catch(error => {
|
|
176
|
+
console.error('[dsh-task-board] execution launch settlement failed', error)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private schedulePoll(): void {
|
|
181
|
+
if (this.pollInFlight || this.disposed) return
|
|
182
|
+
this.pollInFlight = true
|
|
183
|
+
void this.pollSessions().catch(error => {
|
|
184
|
+
console.error('[dsh-task-board] session polling failed', error)
|
|
185
|
+
}).finally(() => { this.pollInFlight = false })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private scheduleTick(first: boolean): void {
|
|
189
|
+
if (this.tickInFlight || this.disposed) return
|
|
190
|
+
this.tickInFlight = true
|
|
191
|
+
void this.tickSchedule(first).catch(error => {
|
|
192
|
+
console.error('[dsh-task-board] scheduler tick failed', error)
|
|
193
|
+
}).finally(() => { this.tickInFlight = false })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private syncPowerReasons(): void {
|
|
197
|
+
const current = this.power.snapshot()
|
|
198
|
+
this.power.updateReasons({
|
|
199
|
+
runningSessions: current.runningSessions,
|
|
200
|
+
armedSchedules: this.armedSchedules(),
|
|
201
|
+
sessionStateKnown: current.sessionStateKnown,
|
|
202
|
+
})
|
|
203
|
+
this.power.setEnabled(this.active && this.preventIdleSleep)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private emit(): void {
|
|
207
|
+
for (const listener of [...this.listeners]) listener()
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host loader entry for the task-board plugin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The section registers while this plugin is in the host composition (mount /
|
|
8
|
-
* DSH restart) and disappears when the plugin leaves it (unmount / restart),
|
|
9
|
-
* so agents always know the board exists and how to cooperate with it. The
|
|
10
|
-
* announcement can be turned off through the web settings plugin-configuration
|
|
11
|
-
* surface (`announceToAgent`); the section then disappears live.
|
|
4
|
+
* The Host owns the v2 ledger, action API, cron scheduler, session runner,
|
|
5
|
+
* execution reconciliation, and optional idle-sleep inhibitor. The browser is
|
|
6
|
+
* a same-origin asynchronous view over that service.
|
|
12
7
|
*/
|
|
13
8
|
|
|
14
9
|
import type { Context } from '@deepseek-ai/cordis'
|
|
15
10
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
11
|
import z from 'schemastery'
|
|
17
12
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-host-apiproxy'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
15
|
+
import { TaskBoardHostService } from './host-service.ts'
|
|
16
|
+
import { makeTaskBoardRoutes } from './host-routes.ts'
|
|
17
|
+
import { mountOnce } from './mount-once.ts'
|
|
18
18
|
|
|
19
19
|
/** Order of the announcement section within the tool-guidance band. */
|
|
20
20
|
const SECTION_ORDER = 200
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** Default environment variable holding the authenticated proxy token. */
|
|
23
|
+
export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
|
|
24
|
+
|
|
25
|
+
export const inject = ['systemPrompt', 'apiProxy', 'webServer']
|
|
23
26
|
|
|
24
27
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
25
|
-
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all
|
|
28
|
+
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Settings namespace of the board's announcement capability — the section the
|
|
@@ -41,13 +44,35 @@ export interface Config {
|
|
|
41
44
|
announceToAgent?: boolean
|
|
42
45
|
/** Master switch for the plugin (browser half + host announcement). */
|
|
43
46
|
enabled?: boolean
|
|
47
|
+
/** Prevent idle system sleep while sessions run or schedules are armed. */
|
|
48
|
+
preventIdleSleep?: boolean
|
|
49
|
+
/** Canonical reverse-proxy Host authorities admitted with a server-side token. */
|
|
50
|
+
trustedProxyHosts?: string[]
|
|
51
|
+
/** Environment variable whose value the authenticated proxy injects upstream. */
|
|
52
|
+
proxyTokenEnv?: string
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
export const Config: z<Config> = z.object({
|
|
47
56
|
announceToAgent: z.boolean().default(true),
|
|
48
57
|
enabled: z.boolean().default(true),
|
|
58
|
+
preventIdleSleep: z.boolean().default(false),
|
|
59
|
+
trustedProxyHosts: z.array(z.string()).default([]),
|
|
60
|
+
proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
|
|
49
61
|
})
|
|
50
62
|
|
|
63
|
+
/** Resolve proxy access without ever placing the token value in plugin config. */
|
|
64
|
+
export function resolveProxyAccess(config: Config | undefined, env: NodeJS.ProcessEnv = process.env): { trustedProxyHosts: string[]; proxyToken?: string } {
|
|
65
|
+
const trustedProxyHosts = config?.trustedProxyHosts ?? []
|
|
66
|
+
if (trustedProxyHosts.length === 0) return { trustedProxyHosts }
|
|
67
|
+
const proxyTokenEnv = config?.proxyTokenEnv ?? DEFAULT_PROXY_TOKEN_ENV
|
|
68
|
+
if (proxyTokenEnv.trim() === '') throw new Error('task-board: proxyTokenEnv must not be empty')
|
|
69
|
+
const proxyToken = env[proxyTokenEnv]
|
|
70
|
+
if (proxyToken === undefined || proxyToken === '') {
|
|
71
|
+
throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`)
|
|
72
|
+
}
|
|
73
|
+
return { trustedProxyHosts, proxyToken }
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
52
77
|
const DEFAULT_ANNOUNCE = true
|
|
53
78
|
|
|
@@ -59,7 +84,26 @@ const DEFAULT_ANNOUNCE = true
|
|
|
59
84
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
60
85
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
61
86
|
*/
|
|
62
|
-
export
|
|
87
|
+
export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
|
|
88
|
+
|
|
89
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
90
|
+
const host = new TaskBoardHostService(ctx.apiProxy)
|
|
91
|
+
host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false)
|
|
92
|
+
host.start()
|
|
93
|
+
ctx.effect(() => {
|
|
94
|
+
const disposers: Array<() => void> = []
|
|
95
|
+
try {
|
|
96
|
+
for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route))
|
|
97
|
+
} catch (error) {
|
|
98
|
+
for (const dispose of disposers) dispose()
|
|
99
|
+
host.dispose()
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
return () => {
|
|
103
|
+
for (const dispose of disposers) dispose()
|
|
104
|
+
host.dispose()
|
|
105
|
+
}
|
|
106
|
+
}, 'task-board: host ledger, scheduler, and routes')
|
|
63
107
|
// The live source the announcement reads: the settings section once the web
|
|
64
108
|
// settings surface is served, the composition entry otherwise
|
|
65
109
|
// (installSettingsSection swaps it when the namespace registers).
|
|
@@ -74,7 +118,9 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
74
118
|
disposeSection()
|
|
75
119
|
disposeSection = undefined
|
|
76
120
|
}
|
|
77
|
-
|
|
121
|
+
const active = current().enabled ?? true
|
|
122
|
+
host.setConfiguration(active, current().preventIdleSleep ?? false)
|
|
123
|
+
if (!active) return
|
|
78
124
|
if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return
|
|
79
125
|
disposeSection = ctx.systemPrompt.section({
|
|
80
126
|
name: 'plugin:task-board',
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|