@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.24
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 +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +726 -71
- package/lib/client.js.map +1 -1
- package/lib/index.js +2619 -175
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +8 -3
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +26 -0
- package/lib/types/client/plugin-card-seat.d.ts +96 -0
- package/lib/types/client/sidebar-entry-core.d.ts +32 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-ledger.d.ts +15 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +2 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +119 -5
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +17 -11
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +18 -17
- package/src/client/locales.ts +52 -0
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/sidebar-entry-core.ts +96 -12
- package/src/core/controller.ts +57 -1
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +84 -14
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
package/src/core/controller.ts
CHANGED
|
@@ -18,7 +18,13 @@ import { applyCreateTask } from './use-cases/task-create.ts'
|
|
|
18
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
19
19
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
20
20
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
TaskBoardAction,
|
|
23
|
+
TaskBoardEventPayload,
|
|
24
|
+
TaskBoardParseDraft,
|
|
25
|
+
TaskBoardParseRequest,
|
|
26
|
+
TaskBoardSnapshot,
|
|
27
|
+
} from '../protocol.ts'
|
|
22
28
|
|
|
23
29
|
export interface TaskBoardTransport {
|
|
24
30
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
@@ -29,6 +35,11 @@ export interface TaskBoardTransport {
|
|
|
29
35
|
*/
|
|
30
36
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
31
37
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
38
|
+
/**
|
|
39
|
+
* One-shot model parse of pasted text (issue #1540). Optional: a deployment
|
|
40
|
+
* that cannot parse simply omits it, and the form hides the section.
|
|
41
|
+
*/
|
|
42
|
+
parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/** The sessions face the controller needs for navigation awareness. */
|
|
@@ -57,6 +68,13 @@ export interface ControllerDeps {
|
|
|
57
68
|
transport?: TaskBoardTransport
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Register one host directory as a DSH project (workspace). Wired by the
|
|
73
|
+
* browser apply() to the runtime's workspace controller; without it the board
|
|
74
|
+
* hides its "new project" action instead of offering a dead control (#1536).
|
|
75
|
+
*/
|
|
76
|
+
export type WorkspaceCreator = (path: string) => Promise<{ workspaceId: string }>
|
|
77
|
+
|
|
60
78
|
/** One workspace option the execution-target pickers offer. */
|
|
61
79
|
export interface ExecutionWorkspaceOption {
|
|
62
80
|
workspaceId: string
|
|
@@ -99,6 +117,10 @@ export interface ControllerSnapshot {
|
|
|
99
117
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
100
118
|
executionOptions: ExecutionOptionsSnapshot
|
|
101
119
|
pendingTaskIds: readonly string[]
|
|
120
|
+
/** Whether the board may offer "register a new project" (issue #1536). */
|
|
121
|
+
canCreateWorkspace?: boolean
|
|
122
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
123
|
+
canParseTask?: boolean
|
|
102
124
|
transportError?: string
|
|
103
125
|
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
|
|
104
126
|
}
|
|
@@ -139,6 +161,7 @@ export class BoardController {
|
|
|
139
161
|
private archiveView = false
|
|
140
162
|
private selectedTaskId: string | undefined
|
|
141
163
|
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [], models: [] }
|
|
164
|
+
private workspaceCreator: WorkspaceCreator | undefined
|
|
142
165
|
private listeners = new Set<() => void>()
|
|
143
166
|
private disposers: Array<() => void> = []
|
|
144
167
|
private readonly now: () => number
|
|
@@ -193,6 +216,8 @@ export class BoardController {
|
|
|
193
216
|
selectedTaskId: this.selectedTaskId,
|
|
194
217
|
executionOptions: this.executionOptions,
|
|
195
218
|
pendingTaskIds: [...this.pendingTaskIds],
|
|
219
|
+
...(this.workspaceCreator === undefined ? {} : { canCreateWorkspace: true }),
|
|
220
|
+
...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
|
|
196
221
|
...(this.transportError === undefined ? {} : { transportError: this.transportError }),
|
|
197
222
|
...(this.hostState === undefined ? {} : { host: this.hostState }),
|
|
198
223
|
}
|
|
@@ -306,6 +331,37 @@ export class BoardController {
|
|
|
306
331
|
this.notify()
|
|
307
332
|
}
|
|
308
333
|
|
|
334
|
+
/** Wire (or clear) the runtime's project registration face (issue #1536). */
|
|
335
|
+
setWorkspaceCreator(creator: WorkspaceCreator | undefined): void {
|
|
336
|
+
this.workspaceCreator = creator
|
|
337
|
+
this.notify()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Register an existing host directory as a DSH project, exactly as the GUI's
|
|
342
|
+
* own "add project" does; the runtime's failure message surfaces unchanged.
|
|
343
|
+
*/
|
|
344
|
+
async createWorkspace(path: string): Promise<{ workspaceId: string }> {
|
|
345
|
+
if (this.workspaceCreator === undefined) throw new Error('workspace creation is unavailable')
|
|
346
|
+
return await this.workspaceCreator(path)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
350
|
+
canParseTask(): boolean {
|
|
351
|
+
return typeof this.deps.transport?.parseDraft === 'function'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Parse pasted text into task fields through the Host. The transport already
|
|
356
|
+
* phrases every failure for the user, so its message surfaces unchanged.
|
|
357
|
+
*/
|
|
358
|
+
async parseTaskDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
|
|
359
|
+
const transport = this.deps.transport
|
|
360
|
+
const parse = transport?.parseDraft
|
|
361
|
+
if (transport === undefined || parse === undefined) throw new Error('task parsing is unavailable')
|
|
362
|
+
return await parse.call(transport, request, signal)
|
|
363
|
+
}
|
|
364
|
+
|
|
309
365
|
moveTask(id: string, status: TaskStatus): void {
|
|
310
366
|
if (this.deps.transport !== undefined) {
|
|
311
367
|
void this.commitRemote({ kind: 'move', taskId: id, status }, id)
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
4
|
* unit-testable without a gateway.
|
|
5
5
|
*/
|
|
6
|
-
import type { TaskRecord } from './tasks.ts'
|
|
6
|
+
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Pick the session a new execution of this task may continue in, or undefined
|
|
10
10
|
* to mint a fresh conversation. Reuse requires ALL of:
|
|
11
11
|
* - the task opted in (`reuseSession === true`);
|
|
12
|
-
* - the task's newest execution carries a session id
|
|
13
|
-
*
|
|
12
|
+
* - the task's newest SETTLED execution carries a session id (issue #1587: the
|
|
13
|
+
* launch path calls this after `startExecution` appended this run's own open
|
|
14
|
+
* record, so reading the array tail always found an unsettled row and reuse
|
|
15
|
+
* never happened);
|
|
14
16
|
* - the roster is known and that session is present and idle.
|
|
15
17
|
*
|
|
16
18
|
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
@@ -25,7 +27,14 @@ export function reusableSessionId(
|
|
|
25
27
|
idleSessionIds: ReadonlySet<string> | undefined,
|
|
26
28
|
): string | undefined {
|
|
27
29
|
if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
let last: ExecutionRecord | undefined
|
|
31
|
+
for (let index = task.executions.length - 1; index >= 0; index -= 1) {
|
|
32
|
+
const candidate = task.executions[index]
|
|
33
|
+
if (candidate.sessionId !== undefined && candidate.endedAt !== undefined) {
|
|
34
|
+
last = candidate
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (last === undefined || last.sessionId === undefined) return undefined
|
|
30
39
|
return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
|
|
31
40
|
}
|
package/src/host-ai.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot model parse behind the board's "parse pasted text" action
|
|
3
|
+
* (issue #1540).
|
|
4
|
+
*
|
|
5
|
+
* The browser half never talks to a model: it posts the pasted text and the
|
|
6
|
+
* route the user picked to the Host, which calls the injected `llm` service
|
|
7
|
+
* once and answers with the three draft fields the new-task form accepts. The
|
|
8
|
+
* reply is untrusted text, so extraction is defensive: code fences are
|
|
9
|
+
* stripped, the first JSON object wins, and an unusable reply falls back to
|
|
10
|
+
* the user's own words instead of an empty form.
|
|
11
|
+
*/
|
|
12
|
+
import { createUserMessage, type GenerateOptions, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts'
|
|
14
|
+
|
|
15
|
+
/** How long one parse may take before the Host gives up on the model. */
|
|
16
|
+
export const TASK_PARSE_TIMEOUT_MS = 45_000
|
|
17
|
+
/** Largest pasted text one parse accepts (bytes). */
|
|
18
|
+
export const TASK_PARSE_MAX_INPUT = 16 * 1024
|
|
19
|
+
/** Longest title the form is willing to receive from a model. */
|
|
20
|
+
const TASK_PARSE_TITLE_LIMIT = 120
|
|
21
|
+
|
|
22
|
+
export type TaskParseFailureCode = 'no-model' | 'model-error' | 'parse-failed' | 'timeout'
|
|
23
|
+
|
|
24
|
+
/** Typed parse failure; the route maps the code onto a status and a body. */
|
|
25
|
+
export class TaskParseError extends Error {
|
|
26
|
+
constructor(readonly code: TaskParseFailureCode, message: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'TaskParseError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SYSTEM_PROMPT = [
|
|
33
|
+
'You turn one pasted note into a task for a kanban board.',
|
|
34
|
+
'Reply with a single JSON object and nothing else, with exactly these keys:',
|
|
35
|
+
'{"title": string, "description": string, "prompt": string}',
|
|
36
|
+
'- title: one line, at most 80 characters, in the language of the note.',
|
|
37
|
+
'- description: what the task is and the context the note carries, as plain text.',
|
|
38
|
+
'- prompt: the instruction an agent should execute, keeping every fact that matters.',
|
|
39
|
+
].join('\n')
|
|
40
|
+
|
|
41
|
+
/** Split a qualified `provider/model` route the same way the runner does. */
|
|
42
|
+
export function splitModelRoute(qualified: string | undefined): { provider: string; model: string } | undefined {
|
|
43
|
+
const raw = qualified?.trim() ?? ''
|
|
44
|
+
if (raw === '') return undefined
|
|
45
|
+
const slash = raw.indexOf('/')
|
|
46
|
+
if (slash <= 0 || slash === raw.length - 1) return undefined
|
|
47
|
+
const provider = raw.slice(0, slash).trim()
|
|
48
|
+
const model = raw.slice(slash + 1).trim()
|
|
49
|
+
if (provider === '' || model === '') return undefined
|
|
50
|
+
return { provider, model }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Trim one model-provided field to a single-line, bounded string. */
|
|
54
|
+
function field(value: unknown, limit?: number): string {
|
|
55
|
+
if (typeof value !== 'string') return ''
|
|
56
|
+
const text = value.trim()
|
|
57
|
+
return limit === undefined || text.length <= limit ? text : text.slice(0, limit).trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First non-empty line of the pasted text, used when the model omits a title. */
|
|
61
|
+
function firstLine(source: string): string {
|
|
62
|
+
const line = source.split(/\r?\n/).map(entry => entry.trim()).find(entry => entry !== '') ?? ''
|
|
63
|
+
return line.length <= TASK_PARSE_TITLE_LIMIT ? line : line.slice(0, TASK_PARSE_TITLE_LIMIT).trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the draft out of a model reply. Fences and surrounding prose are
|
|
68
|
+
* tolerated; anything unusable returns undefined so the caller can decide
|
|
69
|
+
* between a typed failure and a fallback.
|
|
70
|
+
* @param reply - raw model text.
|
|
71
|
+
*/
|
|
72
|
+
export function extractTaskParseReply(reply: string): Partial<TaskBoardParseDraft> | undefined {
|
|
73
|
+
const withoutFences = reply.replace(/```[a-zA-Z]*\s*/g, '')
|
|
74
|
+
const start = withoutFences.indexOf('{')
|
|
75
|
+
const end = withoutFences.lastIndexOf('}')
|
|
76
|
+
if (start === -1 || end <= start) return undefined
|
|
77
|
+
let payload: unknown
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(withoutFences.slice(start, end + 1))
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload !== 'object' || payload === null) return undefined
|
|
84
|
+
const record = payload as { title?: unknown; description?: unknown; prompt?: string }
|
|
85
|
+
const draft: Partial<TaskBoardParseDraft> = {
|
|
86
|
+
title: field(record.title, TASK_PARSE_TITLE_LIMIT),
|
|
87
|
+
description: field(record.description),
|
|
88
|
+
prompt: field(record.prompt),
|
|
89
|
+
}
|
|
90
|
+
return draft.title === '' && draft.description === '' && draft.prompt === '' ? undefined : draft
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fill the three form fields, falling back to the pasted text so a weak model
|
|
95
|
+
* reply never loses what the user actually wrote.
|
|
96
|
+
* @param reply - raw model text.
|
|
97
|
+
* @param source - the text the user pasted.
|
|
98
|
+
*/
|
|
99
|
+
export function draftFromReply(reply: string, source: string): TaskBoardParseDraft {
|
|
100
|
+
const parsed = extractTaskParseReply(reply)
|
|
101
|
+
return {
|
|
102
|
+
title: parsed?.title !== undefined && parsed.title !== '' ? parsed.title : firstLine(source),
|
|
103
|
+
description: parsed?.description ?? '',
|
|
104
|
+
prompt: parsed?.prompt !== undefined && parsed.prompt !== '' ? parsed.prompt : source.trim(),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse one pasted text through the injected `llm` service.
|
|
110
|
+
* @param llm - the Host's llm service.
|
|
111
|
+
* @param request - pasted text plus the qualified model route.
|
|
112
|
+
* @param signal - caller cancellation (client disconnect); combined with the timeout.
|
|
113
|
+
* @returns the draft fields.
|
|
114
|
+
* @throws TaskParseError with a code the route maps onto a status.
|
|
115
|
+
*/
|
|
116
|
+
export async function parseTaskDraft(
|
|
117
|
+
llm: LlmRuntime,
|
|
118
|
+
request: TaskBoardParseRequest,
|
|
119
|
+
signal?: AbortSignal,
|
|
120
|
+
): Promise<TaskBoardParseDraft> {
|
|
121
|
+
const text = request.text.trim()
|
|
122
|
+
if (text === '') throw new TaskParseError('parse-failed', 'there is nothing to parse')
|
|
123
|
+
const route = splitModelRoute(request.model)
|
|
124
|
+
if (route === undefined) throw new TaskParseError('no-model', 'no model route was selected for parsing')
|
|
125
|
+
const timeout = new AbortController()
|
|
126
|
+
const timer = setTimeout(() => { timeout.abort() }, TASK_PARSE_TIMEOUT_MS)
|
|
127
|
+
const abortFromCaller = (): void => { timeout.abort() }
|
|
128
|
+
signal?.addEventListener('abort', abortFromCaller, { once: true })
|
|
129
|
+
try {
|
|
130
|
+
const options: GenerateOptions = {
|
|
131
|
+
provider: route.provider,
|
|
132
|
+
model: route.model,
|
|
133
|
+
system: SYSTEM_PROMPT,
|
|
134
|
+
messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
|
|
135
|
+
signal: timeout.signal,
|
|
136
|
+
}
|
|
137
|
+
let reply = ''
|
|
138
|
+
for await (const chunk of llm.stream(options)) {
|
|
139
|
+
if (chunk.type === 'text-delta') reply += chunk.text
|
|
140
|
+
}
|
|
141
|
+
return draftFromReply(reply, text)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof TaskParseError) throw error
|
|
144
|
+
if (timeout.signal.aborted) {
|
|
145
|
+
throw new TaskParseError('timeout', `the model did not answer within ${TASK_PARSE_TIMEOUT_MS / 1_000}s`)
|
|
146
|
+
}
|
|
147
|
+
throw new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
signal?.removeEventListener('abort', abortFromCaller)
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -134,6 +134,11 @@ export function processIsAlive(pid: number): boolean {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
137
|
+
/**
|
|
138
|
+
* The CIM fallback pays a WMI cold start, so it gets a wider budget than the
|
|
139
|
+
* direct `Get-Process` read it backs up.
|
|
140
|
+
*/
|
|
141
|
+
const CIM_PROBE_TIMEOUT_MS = 8000
|
|
137
142
|
|
|
138
143
|
let ownStartTime: number | undefined
|
|
139
144
|
let ownStartTimeResolved = false
|
|
@@ -161,6 +166,59 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/** Runs one PowerShell script and returns its trimmed stdout. */
|
|
170
|
+
export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined
|
|
171
|
+
|
|
172
|
+
/** Default probe: one hidden, profile-free PowerShell process per script. */
|
|
173
|
+
const runPowerShellProbe: PowerShellProbe = (script, timeoutMs) => {
|
|
174
|
+
const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
175
|
+
timeout: timeoutMs,
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
})
|
|
178
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
179
|
+
return probe.stdout.toString('utf8').trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The epoch-millisecond reading a probe printed, or undefined when unusable. */
|
|
183
|
+
function parseProbeEpochMs(raw: string | undefined): number | undefined {
|
|
184
|
+
if (raw === undefined || raw.trim() === '') return undefined
|
|
185
|
+
const started = Number(raw.trim())
|
|
186
|
+
return Number.isFinite(started) ? started : undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start time through Get-Process: precise, but empty for protected processes. */
|
|
190
|
+
function getProcessStartScript(pid: number): string {
|
|
191
|
+
return '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid)
|
|
192
|
+
+ ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Start time through Win32_Process: readable for System/svchost too. */
|
|
196
|
+
function cimStartScript(pid: number): string {
|
|
197
|
+
return '$p=Get-CimInstance Win32_Process -Filter "ProcessId=' + String(pid)
|
|
198
|
+
+ '" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Windows start time (Unix epoch ms) of a live process.
|
|
203
|
+
*
|
|
204
|
+
* `Get-Process` is the precise first choice, but an unprivileged caller cannot
|
|
205
|
+
* read `.StartTime` for a protected process (System, svchost): the property is
|
|
206
|
+
* empty, so the probe returns nothing. Without a fallback, a crash leftover
|
|
207
|
+
* lock whose PID was reused by such a process could never be proven stale and
|
|
208
|
+
* blocked every startup until the lock was deleted by hand (issue #1629).
|
|
209
|
+
* Win32_Process through CIM reports the same CreationDate for those processes
|
|
210
|
+
* at the same millisecond precision, so it is the second identity source. The
|
|
211
|
+
* probe is injectable so the fallback chain is testable off Windows.
|
|
212
|
+
*/
|
|
213
|
+
export function win32StartTimeMs(pid: number, probe: PowerShellProbe = runPowerShellProbe): number | undefined {
|
|
214
|
+
// The pid is interpolated into a PowerShell script, so it must be a plain
|
|
215
|
+
// positive integer before any probe runs.
|
|
216
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined
|
|
217
|
+
const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS))
|
|
218
|
+
if (direct !== undefined) return direct
|
|
219
|
+
return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS))
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
/**
|
|
165
223
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
166
224
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -170,17 +228,7 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
170
228
|
*/
|
|
171
229
|
function processStartTimeMs(pid: number): number | undefined {
|
|
172
230
|
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
173
|
-
if (process.platform === 'win32')
|
|
174
|
-
const probe = spawnSync(
|
|
175
|
-
'powershell',
|
|
176
|
-
['-NoProfile', '-NonInteractive', '-Command',
|
|
177
|
-
'[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
|
|
178
|
-
{ timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
|
|
179
|
-
)
|
|
180
|
-
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
181
|
-
const started = Number(probe.stdout.toString('utf8').trim())
|
|
182
|
-
return Number.isFinite(started) ? started : undefined
|
|
183
|
-
}
|
|
231
|
+
if (process.platform === 'win32') return win32StartTimeMs(pid)
|
|
184
232
|
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
185
233
|
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
186
234
|
const env = { ...process.env, LC_ALL: 'C' }
|
|
@@ -216,6 +264,16 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
216
264
|
*/
|
|
217
265
|
const LEGACY_START_TOLERANCE_MS = 2000
|
|
218
266
|
|
|
267
|
+
/**
|
|
268
|
+
* How long an unreadable lock must sit untouched before it may be reclaimed.
|
|
269
|
+
* The owner writes and fsyncs its record immediately after creating the file
|
|
270
|
+
* with O_EXCL, so a lock that cannot be parsed may still be mid-write by a
|
|
271
|
+
* live owner; only one that has been unreadable for longer than any write can
|
|
272
|
+
* take is treated as an unclean-shutdown leftover (issue #1528: a 0-byte lock
|
|
273
|
+
* kept the Host half from mounting until it was deleted by hand).
|
|
274
|
+
*/
|
|
275
|
+
const UNREADABLE_LOCK_GRACE_MS = 60_000
|
|
276
|
+
|
|
219
277
|
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
220
278
|
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
221
279
|
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
@@ -842,9 +900,21 @@ export class HostTaskLedger {
|
|
|
842
900
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
843
901
|
ownerExact = owner.probe === 'exact'
|
|
844
902
|
} catch {
|
|
845
|
-
// A power-loss mid-write can leave
|
|
846
|
-
//
|
|
847
|
-
|
|
903
|
+
// A power-loss mid-write can leave an empty or truncated lock. Such a
|
|
904
|
+
// lock still fails closed while it is fresh (a live owner may be
|
|
905
|
+
// mid-write); once it is older than the grace window nothing can be
|
|
906
|
+
// writing it, so the leftover is reclaimed instead of blocking every
|
|
907
|
+
// later start until someone deletes it by hand (issue #1528).
|
|
908
|
+
const age = (() => {
|
|
909
|
+
try { return this.now() - statSync(this.lockFile).mtimeMs } catch { return Number.POSITIVE_INFINITY }
|
|
910
|
+
})()
|
|
911
|
+
if (age < UNREADABLE_LOCK_GRACE_MS) {
|
|
912
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
|
|
913
|
+
}
|
|
914
|
+
try { unlinkSync(this.lockFile) } catch (unlinkError) {
|
|
915
|
+
if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
|
|
916
|
+
}
|
|
917
|
+
continue
|
|
848
918
|
}
|
|
849
919
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
850
920
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
package/src/host-routes.ts
CHANGED
|
@@ -4,7 +4,14 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
|
4
4
|
import type { TaskBoardHostService } from './host-service.ts'
|
|
5
5
|
import { writeJson } from './http.ts'
|
|
6
6
|
import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { TaskParseError, TASK_PARSE_MAX_INPUT } from './host-ai.ts'
|
|
8
|
+
import {
|
|
9
|
+
parseActionEnvelope,
|
|
10
|
+
parseTaskParseRequest,
|
|
11
|
+
TASK_BOARD_API_PREFIX,
|
|
12
|
+
type TaskBoardParseDraft,
|
|
13
|
+
type TaskBoardParseRequest,
|
|
14
|
+
} from './protocol.ts'
|
|
8
15
|
|
|
9
16
|
const ACTION_LIMIT = 64 * 1024
|
|
10
17
|
const IMPORT_LIMIT = 2 * 1024 * 1024
|
|
@@ -19,6 +26,16 @@ export interface TaskBoardRouteAccess {
|
|
|
19
26
|
proxyToken?: string
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Host faces the routes need beyond the ledger service. The parse face is
|
|
31
|
+
* resolved lazily by the caller, so a deployment without an llm service still
|
|
32
|
+
* registers the route and answers with a typed "no model" failure instead of
|
|
33
|
+
* leaving the panel with an unmounted path (issue #1540).
|
|
34
|
+
*/
|
|
35
|
+
export interface TaskBoardRouteOptions {
|
|
36
|
+
parseTask?: (request: TaskBoardParseRequest, signal: AbortSignal) => Promise<TaskBoardParseDraft>
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
interface ResolvedRouteAccess {
|
|
23
40
|
trustedProxyHosts: ReadonlySet<string>
|
|
24
41
|
proxyToken?: string
|
|
@@ -119,7 +136,11 @@ async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unk
|
|
|
119
136
|
return { raw, value: JSON.parse(raw) }
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
export function makeTaskBoardRoutes(
|
|
139
|
+
export function makeTaskBoardRoutes(
|
|
140
|
+
service: TaskBoardHostService,
|
|
141
|
+
access: TaskBoardRouteAccess = {},
|
|
142
|
+
options: TaskBoardRouteOptions = {},
|
|
143
|
+
): WebRoute[] {
|
|
123
144
|
const resolvedAccess = resolveAccess(access)
|
|
124
145
|
const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
125
146
|
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
|
|
@@ -188,5 +209,49 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
|
|
|
188
209
|
push()
|
|
189
210
|
},
|
|
190
211
|
}
|
|
191
|
-
|
|
212
|
+
const parse: WebRoute = {
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: `${TASK_BOARD_API_PREFIX}/parse`,
|
|
215
|
+
handler: async (req, res): Promise<void> => {
|
|
216
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
|
|
217
|
+
if (!guard(req, res)) return
|
|
218
|
+
if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
219
|
+
return writeJson(res, 415, { ok: false, error: 'json-required' }, { 'cache-control': 'no-store' })
|
|
220
|
+
}
|
|
221
|
+
let body: { raw: string; value: unknown }
|
|
222
|
+
try {
|
|
223
|
+
body = await readBody(req)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
226
|
+
return writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
|
|
227
|
+
}
|
|
228
|
+
const request = parseTaskParseRequest(body.value)
|
|
229
|
+
if (request === undefined) return writeJson(res, 400, { ok: false, error: 'invalid-parse-request' }, { 'cache-control': 'no-store' })
|
|
230
|
+
if (Buffer.byteLength(request.text, 'utf8') > TASK_PARSE_MAX_INPUT) {
|
|
231
|
+
return writeJson(res, 413, { ok: false, error: 'text-too-large' }, { 'cache-control': 'no-store' })
|
|
232
|
+
}
|
|
233
|
+
const parseTask = options.parseTask
|
|
234
|
+
if (parseTask === undefined) {
|
|
235
|
+
return writeJson(res, 503, { ok: false, code: 'no-model', error: 'task-board parsing is unavailable' }, { 'cache-control': 'no-store' })
|
|
236
|
+
}
|
|
237
|
+
// The model call outlives a closed tab: stop it when the response goes
|
|
238
|
+
// away instead of holding the provider request open for the full timeout.
|
|
239
|
+
const controller = new AbortController()
|
|
240
|
+
const onClose = (): void => { controller.abort() }
|
|
241
|
+
res.once('close', onClose)
|
|
242
|
+
try {
|
|
243
|
+
const draft = await parseTask(request, controller.signal)
|
|
244
|
+
writeJson(res, 200, { ok: true, draft }, { 'cache-control': 'no-store' })
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const failure = error instanceof TaskParseError
|
|
247
|
+
? error
|
|
248
|
+
: new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
|
|
249
|
+
const status = failure.code === 'no-model' ? 503 : failure.code === 'timeout' ? 504 : 502
|
|
250
|
+
writeJson(res, status, { ok: false, code: failure.code, error: failure.message }, { 'cache-control': 'no-store' })
|
|
251
|
+
} finally {
|
|
252
|
+
res.off('close', onClose)
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
return [state, action, events, parse]
|
|
192
257
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-api-gateway'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-workspace'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
+
import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
18
19
|
import { TaskBoardHostService } from './host-service.ts'
|
|
20
|
+
import { parseTaskDraft, TaskParseError } from './host-ai.ts'
|
|
19
21
|
import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
|
|
20
22
|
import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
|
|
21
23
|
import { makeTaskBoardRoutes } from './host-routes.ts'
|
|
@@ -88,6 +90,22 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
|
|
|
88
90
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
89
91
|
const DEFAULT_ANNOUNCE = false
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Read the optional `llm` service. The board deliberately does not inject it:
|
|
95
|
+
* a deployment without a model must still mount the board, and the parse route
|
|
96
|
+
* answers a typed failure instead of the plugin failing to load (issue #1540).
|
|
97
|
+
* @param ctx - the plugin context.
|
|
98
|
+
* @returns the llm service, or undefined when this deployment serves none.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
|
|
101
|
+
try {
|
|
102
|
+
const llm = ctx.get('llm') as LlmRuntime | undefined
|
|
103
|
+
return llm !== undefined && typeof (llm as { stream?: unknown }).stream === 'function' ? llm : undefined
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
/**
|
|
92
110
|
* Register the board's announcement section, gated on the composition entry's
|
|
93
111
|
* `announceToAgent` (and the live settings value once the web settings
|
|
@@ -115,7 +133,14 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
115
133
|
ctx.effect(() => {
|
|
116
134
|
const disposers: Array<() => void> = []
|
|
117
135
|
try {
|
|
118
|
-
|
|
136
|
+
const routes = makeTaskBoardRoutes(host, resolveProxyAccess(config), {
|
|
137
|
+
parseTask: async (request, signal) => {
|
|
138
|
+
const llm = resolveLlmRuntime(ctx)
|
|
139
|
+
if (llm === undefined) throw new TaskParseError('no-model', 'this deployment serves no llm service')
|
|
140
|
+
return await parseTaskDraft(llm, request, signal)
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
for (const route of routes) disposers.push(ctx.webServer.register(route))
|
|
119
144
|
} catch (error) {
|
|
120
145
|
for (const dispose of disposers) dispose()
|
|
121
146
|
host.dispose()
|
package/src/protocol.ts
CHANGED
|
@@ -49,6 +49,40 @@ export interface TaskBoardEventPayload {
|
|
|
49
49
|
power: TaskBoardPowerSnapshot
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Request body of `POST {TASK_BOARD_API_PREFIX}/parse` (issue #1540): the text
|
|
54
|
+
* the user pasted plus the `provider/model` route that should parse it.
|
|
55
|
+
*/
|
|
56
|
+
export interface TaskBoardParseRequest {
|
|
57
|
+
text: string
|
|
58
|
+
/** Qualified route from the model picker; absent when no route was chosen. */
|
|
59
|
+
model?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Draft fields a parse returns; the user reviews them before creating the task. */
|
|
63
|
+
export interface TaskBoardParseDraft {
|
|
64
|
+
title: string
|
|
65
|
+
description: string
|
|
66
|
+
prompt: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strict parse of a `/parse` request body; undefined rejects the request. */
|
|
70
|
+
export function parseTaskParseRequest(value: unknown): TaskBoardParseRequest | undefined {
|
|
71
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
72
|
+
const record = value as { text?: unknown; model?: unknown }
|
|
73
|
+
if (typeof record.text !== 'string' || record.text.trim() === '') return undefined
|
|
74
|
+
if (record.model !== undefined && typeof record.model !== 'string') return undefined
|
|
75
|
+
const model = typeof record.model === 'string' ? record.model.trim() : ''
|
|
76
|
+
return { text: record.text, ...(model === '' ? {} : { model }) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a decoded reply carries the three draft strings the form accepts. */
|
|
80
|
+
export function isTaskParseDraft(value: unknown): value is TaskBoardParseDraft {
|
|
81
|
+
if (typeof value !== 'object' || value === null) return false
|
|
82
|
+
const record = value as { title?: unknown; description?: unknown; prompt?: unknown }
|
|
83
|
+
return typeof record.title === 'string' && typeof record.description === 'string' && typeof record.prompt === 'string'
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
export type TaskBoardAction =
|
|
53
87
|
| { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
|
|
54
88
|
| { kind: 'create'; id: string; input: NewTaskInput }
|