@linxin666/dsh-client-ui-task-board 0.3.20 → 0.3.22
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 +467 -67
- package/lib/client.js.map +1 -1
- package/lib/index.js +97 -13
- package/lib/types/client/board/EditTaskModal.d.ts +1 -1
- package/lib/types/client/board/TaskBoard.d.ts +8 -1
- package/lib/types/client/board/TaskForm.d.ts +18 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/core/controller.d.ts +2 -3
- package/lib/types/core/tasks.d.ts +66 -1
- package/lib/types/core/use-cases/task-archive.d.ts +6 -6
- package/lib/types/core/use-cases/task-update.d.ts +8 -1
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +16 -3
- package/src/client/board/NewTaskModal.tsx +9 -2
- package/src/client/board/TaskBoard.tsx +61 -6
- package/src/client/board/TaskCard.tsx +17 -1
- package/src/client/board/TaskDetail.tsx +4 -2
- package/src/client/board/TaskForm.tsx +92 -0
- package/src/client/board.module.css +119 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +52 -20
- package/src/client/locales.ts +22 -0
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/core/controller.ts +2 -3
- package/src/core/schedule.ts +4 -1
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +119 -2
- package/src/core/use-cases/task-archive.ts +6 -6
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-runner.ts +24 -1
- package/src/protocol.ts +11 -3
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
* (shared/client/sidebar-entry-core.ts, synced copy).
|
|
21
21
|
*/
|
|
22
22
|
import { createRoot, type Root } from 'react-dom/client'
|
|
23
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
23
24
|
|
|
24
25
|
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
25
26
|
export interface CenterPanelMountOptions {
|
|
26
|
-
/** Render the panel
|
|
27
|
+
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
27
28
|
render: (root: Root) => void
|
|
28
29
|
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
29
30
|
viewDatasetKey: string
|
|
@@ -79,31 +80,38 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
|
|
|
79
80
|
} catch { /* locale service absent: the panel follows its next natural re-render */ }
|
|
80
81
|
|
|
81
82
|
const ensure = (): void => {
|
|
82
|
-
if (container !== undefined) {
|
|
83
|
-
if (container.isConnected) return
|
|
83
|
+
if (container !== undefined && !container.isConnected) {
|
|
84
84
|
// The conversation pane was replaced; drop the stale tree and remount.
|
|
85
85
|
root?.unmount()
|
|
86
86
|
root = undefined
|
|
87
87
|
container.remove()
|
|
88
88
|
container = undefined
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
if (container === undefined) {
|
|
91
|
+
const column = conversationColumn()
|
|
92
|
+
if (column === undefined) return
|
|
93
|
+
container = document.createElement('div')
|
|
94
|
+
container.dataset[options.viewDatasetKey] = ''
|
|
95
|
+
container.dataset.dshPlugin = options.pluginName
|
|
96
|
+
container.className = options.viewClassName
|
|
97
|
+
column.appendChild(container)
|
|
98
|
+
}
|
|
99
|
+
// Keep a visited tree mounted so drafts and terminal sessions survive
|
|
100
|
+
// close/reopen; an unused panel needs neither a React root nor effects.
|
|
101
|
+
if (root !== undefined || !options.isOpen()) return
|
|
97
102
|
root = createRoot(container)
|
|
98
103
|
options.render(root)
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
// The frame mounts after boot settlement; watch for the column's arrival.
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
// The observation is the page-wide hub (shared/client/body-mutations.ts):
|
|
108
|
+
// every family panel used to hold its own document.body subtree observer, so
|
|
109
|
+
// the per-mutation cost grew with the number of installed plugins.
|
|
110
|
+
const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
|
|
104
111
|
|
|
105
112
|
const applyActive = (): void => {
|
|
106
113
|
if (options.isOpen()) {
|
|
114
|
+
ensure()
|
|
107
115
|
// Single-occupant center column: opening this panel must evict the
|
|
108
116
|
// sibling panel, both its html attribute and its controller state,
|
|
109
117
|
// otherwise the two panels' visibility rules fight and the second
|
|
@@ -135,7 +143,7 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
|
|
|
135
143
|
return () => {
|
|
136
144
|
document.removeEventListener('click', onClickSidebarRow, true)
|
|
137
145
|
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
138
|
-
|
|
146
|
+
unsubscribeBody()
|
|
139
147
|
unsubscribe()
|
|
140
148
|
unsubscribeLocale?.()
|
|
141
149
|
document.documentElement.removeAttribute(options.activeAttribute)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
15
15
|
* edit the shared source and re-run the sync instead of editing a copy.
|
|
16
16
|
*/
|
|
17
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
17
18
|
|
|
18
19
|
/** Per-package configuration for one sidebar entry row. */
|
|
19
20
|
export interface SidebarEntryOptions {
|
|
@@ -188,13 +189,15 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
188
189
|
|
|
189
190
|
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
190
191
|
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
191
|
-
// only this body observation can notice the new pane mounting. It
|
|
192
|
-
//
|
|
192
|
+
// only this body observation can notice the new pane mounting. It stays
|
|
193
|
+
// subscribed after placement; the placed-and-still-mounted case
|
|
193
194
|
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
194
|
-
// unrelated app mutations (e.g. chat streaming) cost one contains check
|
|
195
|
-
// instead of churning the full re-query.
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
// unrelated app mutations (e.g. chat streaming) cost one contains check per
|
|
196
|
+
// frame instead of churning the full re-query. The body observation itself is
|
|
197
|
+
// the page-wide hub (shared/client/body-mutations.ts): every family plugin
|
|
198
|
+
// used to hold its own body subtree observer, so the per-mutation cost grew
|
|
199
|
+
// with the number of installed plugins; the hub keeps exactly one.
|
|
200
|
+
const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
|
|
198
201
|
|
|
199
202
|
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
200
203
|
// same frame (microtask before paint -> no visible flicker).
|
|
@@ -225,7 +228,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
225
228
|
tryPlace()
|
|
226
229
|
|
|
227
230
|
return () => {
|
|
228
|
-
|
|
231
|
+
unsubscribeBody()
|
|
229
232
|
rootObserver.disconnect()
|
|
230
233
|
unsubscribeRefresh?.()
|
|
231
234
|
unsubscribeActive?.()
|
package/src/core/controller.ts
CHANGED
|
@@ -327,9 +327,8 @@ export class BoardController {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
/**
|
|
330
|
-
* Archive a
|
|
331
|
-
*
|
|
332
|
-
* lifecycle.
|
|
330
|
+
* Archive a task from any status but `running`, whose lifecycle the runner
|
|
331
|
+
* keeps exclusive ownership of until it settles.
|
|
333
332
|
* @returns true when applied.
|
|
334
333
|
*/
|
|
335
334
|
archiveTask(id: string): boolean {
|
package/src/core/schedule.ts
CHANGED
|
@@ -179,7 +179,10 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
|
|
|
179
179
|
high = Number(b)
|
|
180
180
|
} else if (isDigits(range)) {
|
|
181
181
|
low = Number(range)
|
|
182
|
-
|
|
182
|
+
// Standard cron: a bare value carrying a step runs from that value to the
|
|
183
|
+
// field maximum (minutes 5/15 are 5,20,35,50), while a bare value alone is
|
|
184
|
+
// exactly itself (#1493).
|
|
185
|
+
high = stepRaw === undefined ? low : max
|
|
183
186
|
} else {
|
|
184
187
|
return false
|
|
185
188
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import type { TaskHandover } from './handover.ts'
|
|
15
15
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
16
16
|
import { sanitizeHandover } from './handover.ts'
|
|
@@ -189,6 +189,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
189
189
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
190
190
|
task.freeze = normalizeFreeze(row.freeze)
|
|
191
191
|
task.handover = normalizeHandover(row.handover)
|
|
192
|
+
// Tags are repaired field by field like the schedule: a malformed entry is
|
|
193
|
+
// dropped, and a list that repairs to nothing clears the field instead of
|
|
194
|
+
// dropping the task row.
|
|
195
|
+
task.tags = normalizeTags(row.tags)
|
|
192
196
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
193
197
|
tasks.push(task)
|
|
194
198
|
}
|
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
|
|
@@ -172,8 +277,13 @@ export interface TaskRecord {
|
|
|
172
277
|
archivedAt?: number
|
|
173
278
|
}
|
|
174
279
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Statuses a task may be archived from: every status but `running`, whose
|
|
282
|
+
* execution the runner still owns until it settles. A settled-only gate made
|
|
283
|
+
* the duplicate-and-archive flow a silent no-op for scheduled tasks, which
|
|
284
|
+
* return to `todo` after every successful run (issue #1447).
|
|
285
|
+
*/
|
|
286
|
+
export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'done', 'failed']
|
|
177
287
|
|
|
178
288
|
|
|
179
289
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
@@ -218,6 +328,11 @@ export interface NewTaskInput {
|
|
|
218
328
|
* sanitized by the protocol gate) attached at creation.
|
|
219
329
|
*/
|
|
220
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[]
|
|
221
336
|
}
|
|
222
337
|
|
|
223
338
|
/** The five kanban columns, in display order. */
|
|
@@ -276,6 +391,7 @@ export function freezeOf(
|
|
|
276
391
|
|
|
277
392
|
/** Create a task from user input. */
|
|
278
393
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
394
|
+
const tags = normalizeTags(input.tags)
|
|
279
395
|
return {
|
|
280
396
|
id,
|
|
281
397
|
title: input.title.trim(),
|
|
@@ -292,6 +408,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
292
408
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
293
409
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
294
410
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
411
|
+
...(tags === undefined ? {} : { tags }),
|
|
295
412
|
}
|
|
296
413
|
}
|
|
297
414
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Archive/restore task use case: move a
|
|
3
|
-
*
|
|
2
|
+
* Archive/restore task use case: move a task off the main board and back,
|
|
3
|
+
* whatever its status but `running`. The task keeps its status, execution history, and
|
|
4
4
|
* transcript references, while archiving disarms any schedule so it cannot
|
|
5
5
|
* create more execution records until the user restores and re-enables it.
|
|
6
6
|
*/
|
|
@@ -16,10 +16,10 @@ export interface ArchiveTaskResult {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Archive one task: only
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Archive one task: only a `running` task stays on the board (its runner
|
|
20
|
+
* still owns its lifecycle until the execution settles); every other status
|
|
21
|
+
* can be archived. Archiving disarms a schedule; already-archived tasks are
|
|
22
|
+
* a no-op.
|
|
23
23
|
*/
|
|
24
24
|
export function applyArchiveTask(
|
|
25
25
|
tasks: readonly TaskRecord[],
|
|
@@ -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-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/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'
|
|
@@ -101,6 +101,10 @@ function optionalFiniteNumber(value: unknown): boolean {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
104
|
+
// Tags are validated (not repaired) on the import path: an imported task
|
|
105
|
+
// must carry a well-formed list or none at all, so a hand-edited export
|
|
106
|
+
// cannot smuggle a malformed tag past the gate.
|
|
107
|
+
if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
|
|
104
108
|
if (value.schedule !== undefined) {
|
|
105
109
|
const schedule = record(value.schedule)
|
|
106
110
|
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
@@ -161,6 +165,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
161
165
|
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
162
166
|
...(task.freeze === undefined ? {} : { freeze: task.freeze }),
|
|
163
167
|
...(task.handover === undefined ? {} : { handover: task.handover }),
|
|
168
|
+
...(task.tags === undefined ? {} : { tags: task.tags }),
|
|
164
169
|
// 安全门(对抗场景 b):import 不是人工确认动作,确认戳一律剥除——
|
|
165
170
|
// 高于会话默认权限的绑定经 import 进入后必须重新武装 confirm-permission 门。
|
|
166
171
|
}
|
|
@@ -194,11 +199,12 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
194
199
|
|
|
195
200
|
function createInput(value: unknown): value is NewTaskInput {
|
|
196
201
|
const input = record(value)
|
|
197
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
202
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
198
203
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
199
204
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
200
205
|
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
201
206
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
207
|
+
if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
|
|
202
208
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
203
209
|
if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
|
|
204
210
|
if (input.schedule !== undefined) {
|
|
@@ -211,13 +217,15 @@ function createInput(value: unknown): value is NewTaskInput {
|
|
|
211
217
|
|
|
212
218
|
function updatePatch(value: unknown): boolean {
|
|
213
219
|
const patch = record(value)
|
|
214
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
|
|
220
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
215
221
|
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
216
222
|
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
217
223
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
218
224
|
if (!optionalString(patch[key])) return false
|
|
219
225
|
}
|
|
220
226
|
if (patch.permission !== undefined && !isTaskPermission(patch.permission)) return false
|
|
227
|
+
// null clears the label set; a present array must be a well-formed list.
|
|
228
|
+
if (patch.tags !== undefined && patch.tags !== null && !isTaskTagList(patch.tags)) return false
|
|
221
229
|
// null clears the snapshot; an object must pass the freeze gate.
|
|
222
230
|
if (patch.freeze !== undefined && patch.freeze !== null && freezePayload(patch.freeze) === undefined) return false
|
|
223
231
|
// Same convention for the handover bundle.
|