@linxin666/dsh-client-ui-task-board 0.1.16 → 0.1.17
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 +8 -4
- package/README.zh.md +8 -4
- package/lib/client.js +447 -33
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/core/controller.d.ts +30 -1
- package/lib/types/core/execution.d.ts +49 -4
- package/lib/types/core/store.d.ts +1 -1
- package/lib/types/core/tasks.d.ts +27 -0
- package/lib/types/core/use-cases/task-update.d.ts +9 -4
- package/lib/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/board/NewTaskModal.tsx +68 -3
- package/src/client/board/TaskDetail.tsx +71 -1
- package/src/client/board.module.css +16 -0
- package/src/client/index.ts +68 -2
- package/src/client/locales.ts +28 -0
- package/src/core/controller.ts +38 -2
- package/src/core/execution.ts +140 -12
- package/src/core/store.ts +15 -2
- package/src/core/tasks.ts +41 -0
- package/src/core/use-cases/task-update.ts +34 -5
- package/src/index.ts +1 -1
package/src/core/controller.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
23
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
24
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
25
|
-
import { applyUpdateTask } from './use-cases/task-update.ts'
|
|
25
|
+
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
26
26
|
|
|
27
27
|
/** The sessions face the controller needs for navigation awareness. */
|
|
28
28
|
export interface SessionsControllerFace {
|
|
@@ -47,11 +47,36 @@ export interface ControllerDeps {
|
|
|
47
47
|
reconcileDebounceMs?: number
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** One workspace option the execution-target pickers offer. */
|
|
51
|
+
export interface ExecutionWorkspaceOption {
|
|
52
|
+
workspaceId: string
|
|
53
|
+
/** Display label (workspace title; the wiring falls back to the path). */
|
|
54
|
+
title: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One agent-preset option the execution-target pickers offer. */
|
|
58
|
+
export interface ExecutionPresetOption {
|
|
59
|
+
id: string
|
|
60
|
+
name?: string
|
|
61
|
+
description?: string
|
|
62
|
+
/** Why this preset cannot compose a session; the pickers disable it. */
|
|
63
|
+
broken?: string
|
|
64
|
+
isDefault: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The execution-target option sets the UI feeds into the controller. */
|
|
68
|
+
export interface ExecutionOptionsSnapshot {
|
|
69
|
+
workspaces: readonly ExecutionWorkspaceOption[]
|
|
70
|
+
presets: readonly ExecutionPresetOption[]
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
51
74
|
export interface ControllerSnapshot {
|
|
52
75
|
tasks: readonly TaskRecord[]
|
|
53
76
|
boardOpen: boolean
|
|
54
77
|
selectedTaskId: string | undefined
|
|
78
|
+
/** Picker option sets (workspace list + agent-preset roster). */
|
|
79
|
+
executionOptions: ExecutionOptionsSnapshot
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -85,6 +110,7 @@ export class BoardController {
|
|
|
85
110
|
private tasks: TaskRecord[] = []
|
|
86
111
|
private boardOpen = false
|
|
87
112
|
private selectedTaskId: string | undefined
|
|
113
|
+
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
|
|
88
114
|
private listeners = new Set<() => void>()
|
|
89
115
|
private disposers: Array<() => void> = []
|
|
90
116
|
private readonly now: () => number
|
|
@@ -132,6 +158,7 @@ export class BoardController {
|
|
|
132
158
|
tasks: this.tasks,
|
|
133
159
|
boardOpen: this.boardOpen,
|
|
134
160
|
selectedTaskId: this.selectedTaskId,
|
|
161
|
+
executionOptions: this.executionOptions,
|
|
135
162
|
}
|
|
136
163
|
}
|
|
137
164
|
|
|
@@ -186,11 +213,20 @@ export class BoardController {
|
|
|
186
213
|
return task
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
updateTask(id: string, patch:
|
|
216
|
+
updateTask(id: string, patch: TaskUpdatePatch): void {
|
|
190
217
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
191
218
|
this.persistAndNotify()
|
|
192
219
|
}
|
|
193
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Replace (a part of) the picker option sets the UI feeds (workspace list
|
|
223
|
+
* and agent-preset roster come from the runtime, not the ledger).
|
|
224
|
+
*/
|
|
225
|
+
setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void {
|
|
226
|
+
this.executionOptions = { ...this.executionOptions, ...patch }
|
|
227
|
+
this.notify()
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
moveTask(id: string, status: TaskStatus): void {
|
|
195
231
|
this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
|
|
196
232
|
this.persistAndNotify()
|
package/src/core/execution.ts
CHANGED
|
@@ -15,17 +15,35 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
17
17
|
|
|
18
|
+
/** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
|
|
19
|
+
export interface ExecutionSessionSummary {
|
|
20
|
+
running: boolean
|
|
21
|
+
completed?: boolean
|
|
22
|
+
/** Empty-log bit: the preset can only be recomposed while the session is blank. */
|
|
23
|
+
blank?: boolean
|
|
24
|
+
/** The preset the session currently runs (absent on deployments without presets). */
|
|
25
|
+
agentPreset?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
/** The narrow sessions face the service needs. */
|
|
19
29
|
export interface SessionsExecutionFace {
|
|
20
30
|
list: {
|
|
21
31
|
getSnapshot(): {
|
|
22
32
|
/** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
|
|
23
33
|
phase: 'pending' | 'ready'
|
|
24
|
-
byId: Record<string,
|
|
34
|
+
byId: Record<string, ExecutionSessionSummary>
|
|
25
35
|
}
|
|
26
36
|
subscribe(fn: () => void): () => void
|
|
27
37
|
}
|
|
28
38
|
binding(id: string): { session: SessionDriver } | undefined
|
|
39
|
+
/** Record a host-confirmed preset switch so the list label moves immediately. */
|
|
40
|
+
noteAgentPreset?(sessionId: string, agentPreset: string): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
|
|
44
|
+
export interface PresetsExecutionFace {
|
|
45
|
+
/** Recompose a blank session's agent from a preset. */
|
|
46
|
+
select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
/** The narrow workspaces face the service needs. */
|
|
@@ -57,6 +75,12 @@ export interface SessionDriver {
|
|
|
57
75
|
content: readonly unknown[],
|
|
58
76
|
mode: 'queue',
|
|
59
77
|
): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
78
|
+
/**
|
|
79
|
+
* Admit one slash-command line against the session's agent (the
|
|
80
|
+
* `/permission <id>` mechanism). `matched` reports whether a command
|
|
81
|
+
* claimed the line.
|
|
82
|
+
*/
|
|
83
|
+
command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
|
|
60
84
|
getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
|
|
61
85
|
subscribe(fn: () => void): () => void
|
|
62
86
|
}
|
|
@@ -65,6 +89,8 @@ export interface SessionDriver {
|
|
|
65
89
|
export interface ExecutionEnvironment {
|
|
66
90
|
sessions: SessionsExecutionFace
|
|
67
91
|
workspaces: WorkspacesExecutionFace
|
|
92
|
+
/** Agent-preset wire face; absent on deployments without preset support. */
|
|
93
|
+
presets?: PresetsExecutionFace
|
|
68
94
|
/** Raw-history reader for failure detection of never-opened sessions. */
|
|
69
95
|
history?: HistoryExecutionFace
|
|
70
96
|
}
|
|
@@ -80,6 +106,19 @@ function messageOf(error: unknown): string {
|
|
|
80
106
|
return String(error)
|
|
81
107
|
}
|
|
82
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Whether a rejected preset switch actually means "the session already runs
|
|
111
|
+
* this preset" (the host's agent-preset-conflict with a matching
|
|
112
|
+
* existingPreset). A blank-session reuse race can produce this even though
|
|
113
|
+
* the requested composition is in place.
|
|
114
|
+
*/
|
|
115
|
+
function presetAlreadyRuns(error: unknown, mode: string): boolean {
|
|
116
|
+
if (typeof error !== 'object' || error === null) return false
|
|
117
|
+
const details = (error as { details?: unknown }).details
|
|
118
|
+
if (typeof details !== 'object' || details === null) return false
|
|
119
|
+
return (details as { existingPreset?: unknown }).existingPreset === mode
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
/** Whether a `turn/end` payload closed the turn with an error reason. */
|
|
84
123
|
function isErrorTurnEnd(data: unknown): boolean {
|
|
85
124
|
if (typeof data !== 'object' || data === null) return false
|
|
@@ -106,14 +145,24 @@ export class ExecutionService {
|
|
|
106
145
|
execution: ExecutionRecord,
|
|
107
146
|
onEvent: (event: ExecutionEvent) => void,
|
|
108
147
|
): Promise<void> {
|
|
148
|
+
const settleFailed = (error: string): void => {
|
|
149
|
+
onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
|
|
150
|
+
}
|
|
109
151
|
try {
|
|
110
|
-
const sessionId = await this.connectSession()
|
|
152
|
+
const sessionId = await this.connectSession(task.workspaceId)
|
|
111
153
|
onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
|
|
112
154
|
const driver = this.driverOf(sessionId)
|
|
113
155
|
if (driver === undefined) {
|
|
114
|
-
|
|
156
|
+
settleFailed('execution session is not ready')
|
|
115
157
|
return
|
|
116
158
|
}
|
|
159
|
+
// Task-pinned execution targets are applied BEFORE any prompt: a preset
|
|
160
|
+
// can only be recomposed while the session is blank, and the permission
|
|
161
|
+
// command must run before the task's turn starts. A rejected target
|
|
162
|
+
// fails the run without sending the prompt — running the task under
|
|
163
|
+
// different settings than it declared would be worse than not running.
|
|
164
|
+
if (!await this.applyMode(driver, task, sessionId, settleFailed)) return
|
|
165
|
+
if (!await this.applyPermission(driver, task, settleFailed)) return
|
|
117
166
|
// Best-effort rename so the execution is recognizable in the session list.
|
|
118
167
|
await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
|
|
119
168
|
// Baseline the turn counter BEFORE the prompt round-trip: a turn that
|
|
@@ -122,21 +171,92 @@ export class ExecutionService {
|
|
|
122
171
|
const baseline = driver.getSnapshot().turnEnds.size
|
|
123
172
|
const accepted = await this.sendPrompt(driver, task)
|
|
124
173
|
if (!accepted.ok) {
|
|
125
|
-
|
|
126
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
127
|
-
error: messageOf(accepted.error),
|
|
128
|
-
})
|
|
174
|
+
settleFailed(messageOf(accepted.error))
|
|
129
175
|
return
|
|
130
176
|
}
|
|
131
177
|
this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
|
|
132
178
|
} catch (error) {
|
|
133
|
-
|
|
134
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
135
|
-
error: messageOf(error),
|
|
136
|
-
})
|
|
179
|
+
settleFailed(messageOf(error))
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Recompose the execution session's agent from the task-pinned preset.
|
|
185
|
+
* No-op when the task pins none or the session already runs it; fails the
|
|
186
|
+
* run when the session is no longer blank, the preset face is missing, or
|
|
187
|
+
* the wire refuses.
|
|
188
|
+
*/
|
|
189
|
+
private async applyMode(
|
|
190
|
+
driver: SessionDriver,
|
|
191
|
+
task: TaskRecord,
|
|
192
|
+
sessionId: string,
|
|
193
|
+
settleFailed: (error: string) => void,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const mode = task.mode
|
|
196
|
+
if (mode === undefined || mode === '') return true
|
|
197
|
+
const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
|
|
198
|
+
if (summary?.blank === false) {
|
|
199
|
+
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
if (summary?.agentPreset === mode) return true
|
|
203
|
+
if (this.env.presets === undefined) {
|
|
204
|
+
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const result = await this.env.presets.select(sessionId, mode)
|
|
209
|
+
if (!result.ok) {
|
|
210
|
+
// A list race can leave the summary without the preset label even
|
|
211
|
+
// though the blank session already runs it; the wire answers that
|
|
212
|
+
// case with agent-preset-conflict (existingPreset === requested).
|
|
213
|
+
// The requested composition is already in place, so count it as
|
|
214
|
+
// applied instead of failing the run.
|
|
215
|
+
if (presetAlreadyRuns(result.error, mode)) {
|
|
216
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
232
|
+
* slash command. No-op when the task pins none; fails the run when the
|
|
233
|
+
* admission is rejected or no command claimed the line.
|
|
234
|
+
*/
|
|
235
|
+
private async applyPermission(
|
|
236
|
+
driver: SessionDriver,
|
|
237
|
+
task: TaskRecord,
|
|
238
|
+
settleFailed: (error: string) => void,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
const permission = task.permission
|
|
241
|
+
if (permission === undefined) return true
|
|
242
|
+
const line = `/permission ${permission}`
|
|
243
|
+
try {
|
|
244
|
+
const result = await driver.command(line)
|
|
245
|
+
if (!result.ok) {
|
|
246
|
+
settleFailed(`permission command rejected: ${messageOf(result.error)}`)
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
if (!result.matched) {
|
|
250
|
+
settleFailed(`permission command not recognized: ${line}`)
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
settleFailed(`permission command failed: ${messageOf(error)}`)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
140
260
|
/**
|
|
141
261
|
* Inspect a reloaded/background task that was left 'running' and emit a
|
|
142
262
|
* settled event when its session already finished.
|
|
@@ -198,8 +318,16 @@ export class ExecutionService {
|
|
|
198
318
|
}
|
|
199
319
|
}
|
|
200
320
|
|
|
201
|
-
private async connectSession(): Promise<string> {
|
|
321
|
+
private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
|
|
202
322
|
const workspace = this.env.workspaces.list.getSnapshot()
|
|
323
|
+
if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
|
|
324
|
+
// A task-pinned workspace must exist in the list: connecting an
|
|
325
|
+
// unknown id would only defer the failure into the wire.
|
|
326
|
+
if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
|
|
327
|
+
throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
|
|
328
|
+
}
|
|
329
|
+
return this.env.workspaces.connectWorkspace(taskWorkspaceId)
|
|
330
|
+
}
|
|
203
331
|
const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
|
|
204
332
|
if (workspaceId === undefined) {
|
|
205
333
|
throw new Error('no workspace available to run the task in')
|
package/src/core/store.ts
CHANGED
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
* localStorage backend.
|
|
15
15
|
*/
|
|
16
16
|
import { isValidCron } from './schedule.ts'
|
|
17
|
-
import type
|
|
18
|
-
import { isTaskStatus } from './tasks.ts'
|
|
17
|
+
import { isTaskPermission, isTaskStatus, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
19
18
|
|
|
20
19
|
/** Persistence seam for the task ledger. */
|
|
21
20
|
export interface TaskStore {
|
|
@@ -64,6 +63,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
64
63
|
if (typeof record.prompt !== 'string') return false
|
|
65
64
|
if (typeof record.createdAt !== 'number') return false
|
|
66
65
|
if (typeof record.updatedAt !== 'number') return false
|
|
66
|
+
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
67
|
+
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
68
|
+
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
67
69
|
if (!Array.isArray(record.executions)) return false
|
|
68
70
|
for (const execution of record.executions) {
|
|
69
71
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -78,6 +80,11 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
78
80
|
return true
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/** Collapse a blank persisted target string to undefined (clears the pin). */
|
|
84
|
+
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
85
|
+
return value !== undefined && value.trim() === '' ? undefined : value
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
/** A task record is structurally valid if it round-trips through the UI. */
|
|
82
89
|
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
83
90
|
return isTaskRecordShape(value) && isTaskStatus(value.status)
|
|
@@ -136,6 +143,12 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
136
143
|
// clear a malformed persisted rule rather than leave it in the row.
|
|
137
144
|
const task: TaskRecord = { ...row, status: normalizeStatus(row.status) }
|
|
138
145
|
task.schedule = normalizeSchedule(row.schedule)
|
|
146
|
+
// Execution targets are normalized like the schedule: blank strings
|
|
147
|
+
// clear the pin and unknown permission strings from a future version
|
|
148
|
+
// fall back to the session default instead of dropping the row.
|
|
149
|
+
task.workspaceId = normalizeTargetId(row.workspaceId)
|
|
150
|
+
task.mode = normalizeTargetId(row.mode)
|
|
151
|
+
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
139
152
|
tasks.push(task)
|
|
140
153
|
}
|
|
141
154
|
return tasks
|
package/src/core/tasks.ts
CHANGED
|
@@ -64,6 +64,32 @@ export interface TaskRecord {
|
|
|
64
64
|
executions: ExecutionRecord[]
|
|
65
65
|
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
66
66
|
schedule?: ScheduleRule
|
|
67
|
+
/**
|
|
68
|
+
* Workspace the execution must run in (a workspace-list id); absent means
|
|
69
|
+
* the recent-workspace fallback at execution time.
|
|
70
|
+
*/
|
|
71
|
+
workspaceId?: string
|
|
72
|
+
/**
|
|
73
|
+
* Agent preset the execution session must be composed from (an
|
|
74
|
+
* `agentPreset.list` id); absent means the deployment default.
|
|
75
|
+
*/
|
|
76
|
+
mode?: string
|
|
77
|
+
/**
|
|
78
|
+
* Permission preset applied to the execution session through the
|
|
79
|
+
* `/permission <id>` slash command; absent leaves the session default.
|
|
80
|
+
*/
|
|
81
|
+
permission?: TaskPermission
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
85
|
+
export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
|
|
86
|
+
|
|
87
|
+
/** One permission preset id. */
|
|
88
|
+
export type TaskPermission = typeof TASK_PERMISSIONS[number]
|
|
89
|
+
|
|
90
|
+
/** Whether an unknown value is a known permission preset id. */
|
|
91
|
+
export function isTaskPermission(value: unknown): value is TaskPermission {
|
|
92
|
+
return typeof value === 'string' && (TASK_PERMISSIONS as readonly string[]).includes(value)
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
/** Input for creating a task. */
|
|
@@ -71,6 +97,12 @@ export interface NewTaskInput {
|
|
|
71
97
|
title: string
|
|
72
98
|
description: string
|
|
73
99
|
prompt: string
|
|
100
|
+
/** Workspace the execution must run in; empty/absent = the recent workspace. */
|
|
101
|
+
workspaceId?: string
|
|
102
|
+
/** Agent preset the execution session must be composed from; empty/absent = deployment default. */
|
|
103
|
+
mode?: string
|
|
104
|
+
/** Permission preset applied to the execution session; absent = session default. */
|
|
105
|
+
permission?: TaskPermission
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
/** The five kanban columns, in display order. */
|
|
@@ -103,6 +135,12 @@ export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
|
|
|
103
135
|
return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
104
136
|
}
|
|
105
137
|
|
|
138
|
+
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
139
|
+
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
140
|
+
const trimmed = value?.trim()
|
|
141
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
/** Create a task from user input. */
|
|
107
145
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
108
146
|
return {
|
|
@@ -114,6 +152,9 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
114
152
|
createdAt: now,
|
|
115
153
|
updatedAt: now,
|
|
116
154
|
executions: [],
|
|
155
|
+
workspaceId: normalizeTargetId(input.workspaceId),
|
|
156
|
+
mode: normalizeTargetId(input.mode),
|
|
157
|
+
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
117
158
|
}
|
|
118
159
|
}
|
|
119
160
|
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Update-task use case: apply an editable-field patch (title/description/
|
|
3
|
-
* prompt
|
|
4
|
-
* notify — the
|
|
3
|
+
* prompt plus the execution targets workspaceId/mode/permission) with a
|
|
4
|
+
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
5
|
+
* controller orchestrates those).
|
|
6
|
+
*
|
|
7
|
+
* An explicit `undefined` in the patch clears the field (the task falls
|
|
8
|
+
* back to the runtime default); an unknown permission string is ignored so
|
|
9
|
+
* stale UI can never persist a value the execution service rejects.
|
|
5
10
|
*/
|
|
6
|
-
import type
|
|
11
|
+
import { isTaskPermission, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
7
12
|
|
|
8
13
|
/** Editable fields on a task (the update patch surface). */
|
|
9
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>
|
|
14
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
15
|
+
|
|
16
|
+
/** Collapse a blank target string to undefined (clears the pin). */
|
|
17
|
+
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
18
|
+
return value !== undefined && value.trim() === '' ? undefined : value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keep an unknown permission string from entering the ledger. */
|
|
22
|
+
function normalizePermission(
|
|
23
|
+
current: TaskPermission | undefined,
|
|
24
|
+
value: TaskPermission | undefined,
|
|
25
|
+
): TaskPermission | undefined {
|
|
26
|
+
if (value === undefined) return undefined
|
|
27
|
+
return isTaskPermission(value) ? value : current
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
/**
|
|
12
31
|
* Apply an update across the ledger. Tasks that do not match the id are left
|
|
@@ -22,5 +41,15 @@ export function applyUpdateTask(
|
|
|
22
41
|
patch: TaskUpdatePatch,
|
|
23
42
|
now: number,
|
|
24
43
|
): readonly TaskRecord[] {
|
|
25
|
-
return tasks.map(task =>
|
|
44
|
+
return tasks.map(task => {
|
|
45
|
+
if (task.id !== id) return task
|
|
46
|
+
const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
|
|
47
|
+
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
48
|
+
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
49
|
+
const next: TaskRecord = { ...task, ...patch, updatedAt: now }
|
|
50
|
+
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
51
|
+
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
52
|
+
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
|
53
|
+
return next
|
|
54
|
+
})
|
|
26
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ const SECTION_ORDER = 200
|
|
|
22
22
|
export const inject = ['systemPrompt']
|
|
23
23
|
|
|
24
24
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
25
|
-
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent
|
|
25
|
+
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent 会话);任务可钉住执行目标——工作区 / 模式(agent 预设)/ 权限(read-only / workspace-write / danger-full-access),缺省用运行时默认;任务支持 5 段 cron 定时执行(如 0 23 * * *);数据存浏览器 localStorage(键 dsh.taskBoard.v1)。限制:定时调度在浏览器端,需 GUI 标签页打开,错过即跳过;执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Settings namespace of the board's announcement capability — the section the
|