@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.
Files changed (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -0
  3. package/README.zh.md +5 -0
  4. package/lib/client.js +723 -29
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2629 -158
  7. package/lib/types/client/board/EditTaskModal.d.ts +7 -1
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  10. package/lib/types/client/board/TaskForm.d.ts +18 -0
  11. package/lib/types/client/host-api.d.ts +23 -1
  12. package/lib/types/client/locales.d.ts +36 -0
  13. package/lib/types/core/controller.d.ts +35 -1
  14. package/lib/types/core/tasks.d.ts +60 -0
  15. package/lib/types/core/use-cases/task-update.d.ts +8 -1
  16. package/lib/types/host-ai.d.ts +51 -0
  17. package/lib/types/host-routes.d.ts +11 -1
  18. package/lib/types/index.d.ts +9 -0
  19. package/lib/types/protocol.d.ts +19 -0
  20. package/package.json +1 -1
  21. package/src/client/board/EditTaskModal.tsx +52 -3
  22. package/src/client/board/NewTaskModal.tsx +100 -5
  23. package/src/client/board/TaskBoard.tsx +150 -6
  24. package/src/client/board/TaskCard.tsx +17 -1
  25. package/src/client/board/TaskDetail.tsx +38 -2
  26. package/src/client/board/TaskForm.tsx +85 -0
  27. package/src/client/board.module.css +180 -0
  28. package/src/client/host-api.ts +124 -5
  29. package/src/client/index.ts +7 -0
  30. package/src/client/locales.ts +72 -0
  31. package/src/core/controller.ts +57 -1
  32. package/src/core/store.ts +5 -1
  33. package/src/core/tasks.ts +112 -0
  34. package/src/core/use-cases/task-update.ts +12 -2
  35. package/src/host-ai.ts +152 -0
  36. package/src/host-ledger.ts +25 -3
  37. package/src/host-routes.ts +68 -3
  38. package/src/host-runner.ts +24 -1
  39. package/src/index.ts +26 -1
  40. 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
+ }
@@ -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 a truncated lock; the same event
846
- // killed the writer, so fail closed but explain the recovery.
847
- 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`)
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)
@@ -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 { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
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(service: TaskBoardHostService, access: TaskBoardRouteAccess = {}): WebRoute[] {
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
- return [state, action, events]
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
  }
@@ -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 preamble = handover === undefined || handover.references.length === 0
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
- for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route))
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.