@linxin666/dsh-client-ui-task-board 0.3.21 → 0.3.23
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 +5 -0
- package/README.zh.md +5 -0
- package/lib/client.js +723 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +2629 -158
- package/lib/types/client/board/EditTaskModal.d.ts +7 -1
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskForm.d.ts +18 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +36 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/tasks.d.ts +60 -0
- package/lib/types/core/use-cases/task-update.d.ts +8 -1
- package/lib/types/host-ai.d.ts +51 -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 +1 -1
- package/src/client/board/EditTaskModal.tsx +52 -3
- package/src/client/board/NewTaskModal.tsx +100 -5
- package/src/client/board/TaskBoard.tsx +150 -6
- package/src/client/board/TaskCard.tsx +17 -1
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +85 -0
- package/src/client/board.module.css +180 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +72 -0
- package/src/core/controller.ts +57 -1
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +112 -0
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/host-runner.ts +24 -1
- package/src/index.ts +26 -1
- package/src/protocol.ts +45 -3
package/src/core/tasks.ts
CHANGED
|
@@ -94,6 +94,105 @@ export interface TaskFreeze extends FreezeSnapshot {
|
|
|
94
94
|
frozenBy?: string
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* One task label (issue #1521). The name is the badge and the tag-filter key;
|
|
99
|
+
* the optional prompt line rides the execution prompt, so a label may be
|
|
100
|
+
* display-only (no prefix) or carry an instruction (business-line routing,
|
|
101
|
+
* output directory, house style) that every run of the task inherits.
|
|
102
|
+
*/
|
|
103
|
+
export interface TaskTag {
|
|
104
|
+
/** Display name; trimmed, non-empty, unique within the task. */
|
|
105
|
+
name: string
|
|
106
|
+
/**
|
|
107
|
+
* Prompt line injected ahead of the execution prompt. Absent (or blank
|
|
108
|
+
* after trimming) keeps the tag display-only: it never touches the prompt,
|
|
109
|
+
* so a label with no prefix has zero execution side effects.
|
|
110
|
+
*/
|
|
111
|
+
promptPrefix?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Maximum number of tags carried by one task. */
|
|
115
|
+
export const TASK_TAG_LIMIT = 8
|
|
116
|
+
/** Maximum length of a tag name. */
|
|
117
|
+
export const TAG_NAME_MAX_LENGTH = 32
|
|
118
|
+
/** Maximum length of a tag's injected prompt line. */
|
|
119
|
+
export const TAG_PROMPT_MAX_LENGTH = 200
|
|
120
|
+
|
|
121
|
+
/** Whether an unknown value is a well-formed tag (strict: the wire gate). */
|
|
122
|
+
export function isTaskTag(value: unknown): value is TaskTag {
|
|
123
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
124
|
+
const tag = value as Record<string, unknown>
|
|
125
|
+
if (Object.keys(tag).some(key => key !== 'name' && key !== 'promptPrefix')) return false
|
|
126
|
+
if (typeof tag.name !== 'string') return false
|
|
127
|
+
const name = tag.name.trim()
|
|
128
|
+
if (name === '' || name.length > TAG_NAME_MAX_LENGTH) return false
|
|
129
|
+
if (tag.promptPrefix !== undefined && typeof tag.promptPrefix !== 'string') return false
|
|
130
|
+
return tag.promptPrefix === undefined || (tag.promptPrefix as string).trim().length <= TAG_PROMPT_MAX_LENGTH
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether an unknown value is a well-formed tag list (strict: the wire gate).
|
|
135
|
+
* An empty list is rejected — clearing tags is expressed by omitting the field
|
|
136
|
+
* (create) or by an explicit null (update), never by an empty array.
|
|
137
|
+
*/
|
|
138
|
+
export function isTaskTagList(value: unknown): value is TaskTag[] {
|
|
139
|
+
return Array.isArray(value) && value.length > 0 && value.length <= TASK_TAG_LIMIT && value.every(isTaskTag)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Repair a persisted tag list: keep the well-formed entries, trim, drop
|
|
144
|
+
* blanks and repeats, cap the count, and collapse a blank prompt line to
|
|
145
|
+
* "display-only". Returns undefined when nothing usable remains, so the caller
|
|
146
|
+
* clears the field instead of storing an empty array.
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeTags(value: unknown): TaskTag[] | undefined {
|
|
149
|
+
if (!Array.isArray(value)) return undefined
|
|
150
|
+
const tags: TaskTag[] = []
|
|
151
|
+
const seen = new Set<string>()
|
|
152
|
+
for (const entry of value) {
|
|
153
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
|
|
154
|
+
const row = entry as Record<string, unknown>
|
|
155
|
+
if (typeof row.name !== 'string') continue
|
|
156
|
+
const name = row.name.trim()
|
|
157
|
+
if (name === '' || name.length > TAG_NAME_MAX_LENGTH || seen.has(name)) continue
|
|
158
|
+
const raw = typeof row.promptPrefix === 'string' ? row.promptPrefix.trim() : ''
|
|
159
|
+
const promptPrefix = raw === '' ? undefined : raw.slice(0, TAG_PROMPT_MAX_LENGTH)
|
|
160
|
+
seen.add(name)
|
|
161
|
+
tags.push(promptPrefix === undefined ? { name } : { name, promptPrefix })
|
|
162
|
+
if (tags.length >= TASK_TAG_LIMIT) break
|
|
163
|
+
}
|
|
164
|
+
return tags.length === 0 ? undefined : tags
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Stable palette slot (0..5) for a tag name. The same label always lands on the
|
|
169
|
+
* same tone, so a badge needs no stored colour and two tasks sharing a label
|
|
170
|
+
* cannot disagree about it.
|
|
171
|
+
*/
|
|
172
|
+
export function tagTone(name: string): number {
|
|
173
|
+
let hash = 0
|
|
174
|
+
for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
|
|
175
|
+
return hash % 6
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Union of the labels already carried by `tasks`, first occurrence wins (the
|
|
180
|
+
* oldest task's hint is the one offered). Feeds the editor's name datalist and
|
|
181
|
+
* the board's tag filter, so a label defined once can be reused everywhere.
|
|
182
|
+
*/
|
|
183
|
+
export function collectKnownTags(tasks: readonly TaskRecord[]): TaskTag[] {
|
|
184
|
+
const known: TaskTag[] = []
|
|
185
|
+
const seen = new Set<string>()
|
|
186
|
+
for (const task of tasks) {
|
|
187
|
+
for (const tag of task.tags ?? []) {
|
|
188
|
+
if (seen.has(tag.name)) continue
|
|
189
|
+
seen.add(tag.name)
|
|
190
|
+
known.push(tag)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return known
|
|
194
|
+
}
|
|
195
|
+
|
|
97
196
|
/** One task on the board. */
|
|
98
197
|
export interface TaskRecord {
|
|
99
198
|
/** Stable task id (uuid). */
|
|
@@ -158,6 +257,12 @@ export interface TaskRecord {
|
|
|
158
257
|
* bundle's triplet overrides the legacy pin fields at execution time.
|
|
159
258
|
*/
|
|
160
259
|
handover?: TaskHandover
|
|
260
|
+
/**
|
|
261
|
+
* Task labels (issue #1521): optional, additive, and absent on every task
|
|
262
|
+
* created before the field existed. A tag whose `promptPrefix` is set is
|
|
263
|
+
* prepended to the execution prompt; a bare name is display and filter only.
|
|
264
|
+
*/
|
|
265
|
+
tags?: TaskTag[]
|
|
161
266
|
/**
|
|
162
267
|
* Human confirmation stamp for an above-default effective permission
|
|
163
268
|
* (ms epoch). Absent while the binding awaits confirmation; any permission
|
|
@@ -223,6 +328,11 @@ export interface NewTaskInput {
|
|
|
223
328
|
* sanitized by the protocol gate) attached at creation.
|
|
224
329
|
*/
|
|
225
330
|
handover?: TaskHandoverInput
|
|
331
|
+
/**
|
|
332
|
+
* Optional task labels. A tag with a non-blank `promptPrefix` is injected
|
|
333
|
+
* ahead of the execution prompt; a bare name changes nothing at run time.
|
|
334
|
+
*/
|
|
335
|
+
tags?: TaskTag[]
|
|
226
336
|
}
|
|
227
337
|
|
|
228
338
|
/** The five kanban columns, in display order. */
|
|
@@ -281,6 +391,7 @@ export function freezeOf(
|
|
|
281
391
|
|
|
282
392
|
/** Create a task from user input. */
|
|
283
393
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
394
|
+
const tags = normalizeTags(input.tags)
|
|
284
395
|
return {
|
|
285
396
|
id,
|
|
286
397
|
title: input.title.trim(),
|
|
@@ -297,6 +408,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
297
408
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
298
409
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
299
410
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
411
|
+
...(tags === undefined ? {} : { tags }),
|
|
300
412
|
}
|
|
301
413
|
}
|
|
302
414
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* back to the runtime default); an unknown permission string is ignored so
|
|
9
9
|
* stale UI can never persist a value the execution service rejects.
|
|
10
10
|
*/
|
|
11
|
-
import { freezeOf, isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
11
|
+
import { freezeOf, isTaskPermission, normalizeTags, normalizeTargetId, type TaskRecord, type TaskPermission, type TaskTag } from '../tasks.ts'
|
|
12
12
|
import type { FreezeSnapshot } from '../freeze-snapshot.ts'
|
|
13
13
|
import type { TaskHandoverInput } from '../handover.ts'
|
|
14
14
|
|
|
@@ -20,6 +20,13 @@ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' |
|
|
|
20
20
|
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
21
|
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
22
|
handover?: TaskHandoverInput | null
|
|
23
|
+
/**
|
|
24
|
+
* Replaces the task's labels (issue #1521). Unlike the content fields, tags
|
|
25
|
+
* stay editable after the first run: they classify the task and shape the
|
|
26
|
+
* NEXT execution prompt, they are not the record of what already ran. An
|
|
27
|
+
* explicit null (or a list that normalizes to nothing) clears them.
|
|
28
|
+
*/
|
|
29
|
+
tags?: TaskTag[] | null
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** The fields that edit the task's content (what the user reads and what the
|
|
@@ -68,7 +75,7 @@ export function applyUpdateTask(
|
|
|
68
75
|
): readonly TaskRecord[] {
|
|
69
76
|
return tasks.map(task => {
|
|
70
77
|
if (task.id !== id) return task
|
|
71
|
-
const { freeze: freezePatch, handover: handoverPatch, ...rest } = patch
|
|
78
|
+
const { freeze: freezePatch, handover: handoverPatch, tags: tagsPatch, ...rest } = patch
|
|
72
79
|
const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
|
|
73
80
|
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
74
81
|
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
@@ -86,6 +93,9 @@ export function applyUpdateTask(
|
|
|
86
93
|
next.freeze = freezePatch == null ? undefined : freezeOf(freezePatch, now)
|
|
87
94
|
// Handover follows the same null-clears convention, restamping bundledAt.
|
|
88
95
|
next.handover = handoverPatch == null ? undefined : { ...handoverPatch, bundledAt: now }
|
|
96
|
+
// Tags use the same convention: a present array replaces the set (after
|
|
97
|
+
// repair), an explicit null or a fully-invalid list clears it.
|
|
98
|
+
if ('tags' in patch) next.tags = tagsPatch == null ? undefined : normalizeTags(tagsPatch)
|
|
89
99
|
// The permission confirmation binds the exact permission value: a change
|
|
90
100
|
// of the pinned permission or of the handover bundle re-arms the gate
|
|
91
101
|
// (blocking confirm-then-swap escalation). handover was destructured out
|
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
|
@@ -216,6 +216,16 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
216
216
|
*/
|
|
217
217
|
const LEGACY_START_TOLERANCE_MS = 2000
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* How long an unreadable lock must sit untouched before it may be reclaimed.
|
|
221
|
+
* The owner writes and fsyncs its record immediately after creating the file
|
|
222
|
+
* with O_EXCL, so a lock that cannot be parsed may still be mid-write by a
|
|
223
|
+
* live owner; only one that has been unreadable for longer than any write can
|
|
224
|
+
* take is treated as an unclean-shutdown leftover (issue #1528: a 0-byte lock
|
|
225
|
+
* kept the Host half from mounting until it was deleted by hand).
|
|
226
|
+
*/
|
|
227
|
+
const UNREADABLE_LOCK_GRACE_MS = 60_000
|
|
228
|
+
|
|
219
229
|
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
220
230
|
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
221
231
|
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
@@ -842,9 +852,21 @@ export class HostTaskLedger {
|
|
|
842
852
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
843
853
|
ownerExact = owner.probe === 'exact'
|
|
844
854
|
} catch {
|
|
845
|
-
// A power-loss mid-write can leave
|
|
846
|
-
//
|
|
847
|
-
|
|
855
|
+
// A power-loss mid-write can leave an empty or truncated lock. Such a
|
|
856
|
+
// lock still fails closed while it is fresh (a live owner may be
|
|
857
|
+
// mid-write); once it is older than the grace window nothing can be
|
|
858
|
+
// writing it, so the leftover is reclaimed instead of blocking every
|
|
859
|
+
// later start until someone deletes it by hand (issue #1528).
|
|
860
|
+
const age = (() => {
|
|
861
|
+
try { return this.now() - statSync(this.lockFile).mtimeMs } catch { return Number.POSITIVE_INFINITY }
|
|
862
|
+
})()
|
|
863
|
+
if (age < UNREADABLE_LOCK_GRACE_MS) {
|
|
864
|
+
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`)
|
|
865
|
+
}
|
|
866
|
+
try { unlinkSync(this.lockFile) } catch (unlinkError) {
|
|
867
|
+
if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
|
|
868
|
+
}
|
|
869
|
+
continue
|
|
848
870
|
}
|
|
849
871
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
850
872
|
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/host-runner.ts
CHANGED
|
@@ -110,9 +110,15 @@ function escapeProvenanceDelimiter(value: string): string {
|
|
|
110
110
|
export function promptText(task: TaskRecord): string {
|
|
111
111
|
const body = task.prompt !== '' ? task.prompt : task.title
|
|
112
112
|
const handover = task.handover
|
|
113
|
-
const
|
|
113
|
+
const handoverPreamble = handover === undefined || handover.references.length === 0
|
|
114
114
|
? undefined
|
|
115
115
|
: `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
|
|
116
|
+
// Tag prompts come first: they are the run's standing context (business line,
|
|
117
|
+
// output location), the handover preamble is a per-card note, and the task
|
|
118
|
+
// body is the instruction itself.
|
|
119
|
+
const tagPreamble = tagPromptPreamble(task)
|
|
120
|
+
const preambles = [tagPreamble, handoverPreamble].filter((part): part is string => part !== undefined)
|
|
121
|
+
const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
|
|
116
122
|
const freeze = task.freeze
|
|
117
123
|
if (freeze === undefined) {
|
|
118
124
|
return preamble === undefined ? body : `${preamble}\n\n${body}`
|
|
@@ -122,6 +128,23 @@ export function promptText(task: TaskRecord): string {
|
|
|
122
128
|
return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Build the tag section of the execution prompt (issue #1521). Only tags with
|
|
133
|
+
* a non-blank `promptPrefix` contribute; a task whose tags are all bare names
|
|
134
|
+
* (or which has no tags at all) yields undefined and the prompt is byte-for-byte
|
|
135
|
+
* what it was before the feature.
|
|
136
|
+
*/
|
|
137
|
+
function tagPromptPreamble(task: TaskRecord): string | undefined {
|
|
138
|
+
const lines: string[] = []
|
|
139
|
+
for (const tag of task.tags ?? []) {
|
|
140
|
+
const prefix = tag.promptPrefix?.trim()
|
|
141
|
+
if (prefix === undefined || prefix === '') continue
|
|
142
|
+
lines.push(`- [${tag.name}] ${escapeProvenanceDelimiter(prefix)}`)
|
|
143
|
+
}
|
|
144
|
+
if (lines.length === 0) return undefined
|
|
145
|
+
return `标签提示(任务看板标签,每次执行前注入):\n${lines.join('\n')}`
|
|
146
|
+
}
|
|
147
|
+
|
|
125
148
|
function isErrorTurnEnd(data: unknown): boolean {
|
|
126
149
|
if (typeof data !== 'object' || data === null) return false
|
|
127
150
|
const reason = (data as { reason?: unknown }).reason
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
|
|
2
|
-
import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
2
|
+
import { isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
3
3
|
import { parseLedger } from './core/store.ts'
|
|
4
4
|
import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
|
|
5
5
|
import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
|
|
@@ -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 }
|
|
@@ -101,6 +135,10 @@ function optionalFiniteNumber(value: unknown): boolean {
|
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
138
|
+
// Tags are validated (not repaired) on the import path: an imported task
|
|
139
|
+
// must carry a well-formed list or none at all, so a hand-edited export
|
|
140
|
+
// cannot smuggle a malformed tag past the gate.
|
|
141
|
+
if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
|
|
104
142
|
if (value.schedule !== undefined) {
|
|
105
143
|
const schedule = record(value.schedule)
|
|
106
144
|
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
@@ -161,6 +199,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
161
199
|
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
162
200
|
...(task.freeze === undefined ? {} : { freeze: task.freeze }),
|
|
163
201
|
...(task.handover === undefined ? {} : { handover: task.handover }),
|
|
202
|
+
...(task.tags === undefined ? {} : { tags: task.tags }),
|
|
164
203
|
// 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
|
|
165
204
|
// 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
|
|
166
205
|
}
|
|
@@ -194,11 +233,12 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
194
233
|
|
|
195
234
|
function createInput(value: unknown): value is NewTaskInput {
|
|
196
235
|
const input = record(value)
|
|
197
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
236
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
198
237
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
199
238
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
200
239
|
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
201
240
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
241
|
+
if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
|
|
202
242
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
203
243
|
if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
|
|
204
244
|
if (input.schedule !== undefined) {
|
|
@@ -211,13 +251,15 @@ function createInput(value: unknown): value is NewTaskInput {
|
|
|
211
251
|
|
|
212
252
|
function updatePatch(value: unknown): boolean {
|
|
213
253
|
const patch = record(value)
|
|
214
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
254
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
215
255
|
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
216
256
|
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
217
257
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
218
258
|
if (!optionalString(patch[key])) return false
|
|
219
259
|
}
|
|
220
260
|
if (patch.permission !== undefined && !isTaskPermission(patch.permission)) return false
|
|
261
|
+
// null clears the label set; a present array must be a well-formed list.
|
|
262
|
+
if (patch.tags !== undefined && patch.tags !== null && !isTaskTagList(patch.tags)) return false
|
|
221
263
|
// null clears the snapshot; an object must pass the freeze gate.
|
|
222
264
|
if (patch.freeze !== undefined && patch.freeze !== null && freezePayload(patch.freeze) === undefined) return false
|
|
223
265
|
// Same convention for the handover bundle.
|