dsh-taskboard 0.1.2 → 0.2.0
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.md +30 -3
- package/lib/client.js +743 -142
- package/lib/host/execution.js +156 -28
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +30 -3
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +10 -1
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/store.js +31 -18
- package/lib/host/store.js.map +1 -1
- package/lib/host/tools.js +14 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +19 -4
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js +53 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -0
- package/src/client/board/TaskBoard.tsx +95 -13
- package/src/client/board/TaskCard.tsx +6 -3
- package/src/client/board/TaskDetail.tsx +81 -7
- package/src/client/board/TaskFormModal.tsx +1 -1
- package/src/client/controller.ts +163 -4
- package/src/client/index.ts +10 -0
- package/src/client/session-jump.ts +93 -0
- package/src/client/sidebar-entry.ts +98 -1
- package/src/client/styles.ts +56 -2
- package/src/host/execution.ts +190 -16
- package/src/host/routes.ts +38 -11
- package/src/host/scheduler.ts +20 -4
- package/src/host/store.ts +34 -13
- package/src/host/tools.ts +30 -8
- package/src/index.ts +27 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
package/src/host/tools.ts
CHANGED
|
@@ -26,13 +26,16 @@ import {
|
|
|
26
26
|
canTransition,
|
|
27
27
|
effectivePrompt,
|
|
28
28
|
isClaim,
|
|
29
|
+
isClaimedBy,
|
|
29
30
|
newCommentId,
|
|
30
31
|
newTaskId,
|
|
31
32
|
normalizeBody,
|
|
32
33
|
normalizeExecution,
|
|
34
|
+
normalizeModel,
|
|
33
35
|
normalizePrompt,
|
|
34
36
|
normalizeTitle,
|
|
35
37
|
summarize,
|
|
38
|
+
syncClaim,
|
|
36
39
|
type Actor,
|
|
37
40
|
type TaskModel,
|
|
38
41
|
type TaskRecord,
|
|
@@ -72,6 +75,8 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
72
75
|
`状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
|
|
73
76
|
`执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
|
|
74
77
|
]
|
|
78
|
+
const holder = isClaimedBy(t)
|
|
79
|
+
if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
|
|
75
80
|
if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
|
|
76
81
|
if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
|
|
77
82
|
lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
|
|
@@ -151,6 +156,22 @@ export interface ToolDeps {
|
|
|
151
156
|
workspaces: WorkspaceFace
|
|
152
157
|
/** Current epoch ms (injectable for tests). */
|
|
153
158
|
now: () => number
|
|
159
|
+
/**
|
|
160
|
+
* Registered model provider routes (from the host llm runtime), for
|
|
161
|
+
* advisory validation of pinned models; undefined = runtime unavailable,
|
|
162
|
+
* in which case only the structural check applies.
|
|
163
|
+
*/
|
|
164
|
+
modelProviders?: () => string[] | undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Validate a pinned model: structural check always, provider route when known. */
|
|
168
|
+
function checkModel(deps: ToolDeps, raw: unknown): TaskModel {
|
|
169
|
+
const model = normalizeModel(raw)
|
|
170
|
+
const providers = deps.modelProviders?.()
|
|
171
|
+
if (providers !== undefined && !providers.includes(model.provider)) {
|
|
172
|
+
throw new ToolError(ERR.invalidInput, `model provider "${model.provider}" has no registered route (available: ${providers.join(', ')})`)
|
|
173
|
+
}
|
|
174
|
+
return model
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
/** Resolve the calling agent's actor and session id. */
|
|
@@ -358,9 +379,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
358
379
|
throw new ToolError(ERR.invalidTransition, 'a new task cannot start as done/archived')
|
|
359
380
|
}
|
|
360
381
|
const execution = normalizeExecution(args.execution ?? {}, deps.now())
|
|
361
|
-
|
|
362
|
-
throw new ToolError(ERR.invalidInput, 'model must be { provider: string, model: string }')
|
|
363
|
-
}
|
|
382
|
+
const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
|
|
364
383
|
const now = deps.now()
|
|
365
384
|
const task: TaskRecord = {
|
|
366
385
|
id: newTaskId(),
|
|
@@ -372,7 +391,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
372
391
|
status,
|
|
373
392
|
blocked: false,
|
|
374
393
|
execution,
|
|
375
|
-
model
|
|
394
|
+
model,
|
|
376
395
|
version: 1,
|
|
377
396
|
createdAt: now,
|
|
378
397
|
updatedAt: now,
|
|
@@ -483,10 +502,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
483
502
|
if (!canTransition(task.status, to)) {
|
|
484
503
|
throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
|
|
485
504
|
}
|
|
486
|
-
// Exclusive hold: while a task is in_progress under
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
505
|
+
// Exclusive hold: while a task is in_progress under a session
|
|
506
|
+
// (explicit claimedBy — an agent claim or a live execution), no other
|
|
507
|
+
// session may move it (that would be a takeover).
|
|
508
|
+
if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
|
|
509
|
+
throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
|
|
490
510
|
}
|
|
491
511
|
// Claim boundary: the calling session must belong to the task's project.
|
|
492
512
|
if (isClaim(task.status, to)) {
|
|
@@ -501,6 +521,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
501
521
|
next.updatedAt = deps.now()
|
|
502
522
|
next.updatedBy = actor
|
|
503
523
|
if (isClaim(task.status, to)) next.blocked = false
|
|
524
|
+
// Record the holder on a claim; every move out of in_progress releases it.
|
|
525
|
+
syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
|
|
504
526
|
await store.mutate('task-moved', ledger => {
|
|
505
527
|
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
506
528
|
ledger.tasks[i] = next
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
22
22
|
import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
|
|
23
|
-
import { ExecutionService, type EventsFace } from './host/execution.ts'
|
|
23
|
+
import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
|
|
24
24
|
import { registerTaskboardRoutes } from './host/routes.ts'
|
|
25
25
|
import { SchedulerService } from './host/scheduler.ts'
|
|
26
26
|
import { dshHomePath } from './host/sdk.ts'
|
|
@@ -43,6 +43,8 @@ export const inject = ['tools', 'systemPrompt']
|
|
|
43
43
|
export function apply(ctx: Context): void {
|
|
44
44
|
const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
|
|
45
45
|
const now = () => Date.now()
|
|
46
|
+
// Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
|
|
47
|
+
const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
|
|
46
48
|
|
|
47
49
|
// Agent workflow protocol (claim discipline, retry rules, done-gate).
|
|
48
50
|
const disposeSection = ctx.systemPrompt.section({
|
|
@@ -56,10 +58,24 @@ export function apply(ctx: Context): void {
|
|
|
56
58
|
// workspace registry (claim boundary + project execution need it).
|
|
57
59
|
ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {
|
|
58
60
|
const disposers: Array<() => void> = []
|
|
61
|
+
|
|
62
|
+
// Registered model provider routes (from the host llm runtime), read
|
|
63
|
+
// lazily at call time so late availability still applies; undefined when
|
|
64
|
+
// the runtime is absent → only structural model validation runs.
|
|
65
|
+
const modelProviders = (): string[] | undefined => {
|
|
66
|
+
try {
|
|
67
|
+
const llm = wsCtx.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined
|
|
68
|
+
return llm === undefined || typeof llm.listProviders !== 'function'
|
|
69
|
+
? undefined
|
|
70
|
+
: llm.listProviders().map(p => p.id)
|
|
71
|
+
} catch { return undefined }
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
disposers.push(...registerTaskboardTools(wsCtx, {
|
|
60
75
|
store,
|
|
61
76
|
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
62
77
|
now,
|
|
78
|
+
modelProviders,
|
|
63
79
|
}))
|
|
64
80
|
|
|
65
81
|
// Settlement listener over the session event bus.
|
|
@@ -101,6 +117,7 @@ export function apply(ctx: Context): void {
|
|
|
101
117
|
return read === undefined ? undefined : read.call(selection)
|
|
102
118
|
} catch { return undefined }
|
|
103
119
|
},
|
|
120
|
+
maxConcurrent,
|
|
104
121
|
})
|
|
105
122
|
|
|
106
123
|
// /dsh-taskboard routes (the run action reaches the execution service).
|
|
@@ -111,13 +128,20 @@ export function apply(ctx: Context): void {
|
|
|
111
128
|
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
112
129
|
now,
|
|
113
130
|
run: (taskId: string) => execution.run(taskId, 'manual'),
|
|
131
|
+
cancel: (taskId: string) => execution.cancel(taskId),
|
|
132
|
+
modelProviders,
|
|
114
133
|
})
|
|
115
134
|
return () => disposeRoutes?.()
|
|
116
135
|
})
|
|
117
136
|
|
|
137
|
+
// Startup reconciliation: executions left 'running' by a previous host
|
|
138
|
+
// process are marked failed and their tasks handed back to todo (their
|
|
139
|
+
// settlement watchers died with that process).
|
|
140
|
+
void execution.reconcile()
|
|
141
|
+
|
|
118
142
|
// Host-side cron scheduler: due scheduled tasks execute even with no
|
|
119
|
-
// browser open.
|
|
120
|
-
const scheduler = new SchedulerService({ store, execution, now })
|
|
143
|
+
// browser open. Shares the execution concurrency cap.
|
|
144
|
+
const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
|
|
121
145
|
scheduler.start()
|
|
122
146
|
disposers.push(() => scheduler.dispose())
|
|
123
147
|
|
package/src/shared/protocol.ts
CHANGED
|
@@ -271,6 +271,15 @@ export type TaskRecord = {
|
|
|
271
271
|
blocked: boolean
|
|
272
272
|
execution: ExecutionConfig
|
|
273
273
|
model?: TaskModel
|
|
274
|
+
/**
|
|
275
|
+
* The session currently holding the in-progress claim (explicit claim or a
|
|
276
|
+
* live execution). Present only while `status === 'in_progress'`: any move
|
|
277
|
+
* out of in_progress releases it. `updatedBy` is audit-only — user edits no
|
|
278
|
+
* longer erase the holder.
|
|
279
|
+
*/
|
|
280
|
+
claimedBy?: string
|
|
281
|
+
/** When the current holder claimed the task (epoch ms). */
|
|
282
|
+
claimedAt?: number
|
|
274
283
|
version: number
|
|
275
284
|
createdAt: number
|
|
276
285
|
updatedAt: number
|
|
@@ -278,10 +287,28 @@ export type TaskRecord = {
|
|
|
278
287
|
updatedBy: Actor
|
|
279
288
|
comments: CommentRecord[]
|
|
280
289
|
executions: ExecutionRecord[]
|
|
290
|
+
/** How many older execution records were pruned by the retention cap. */
|
|
291
|
+
executionsPruned?: number
|
|
281
292
|
/** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */
|
|
282
293
|
trashedAt?: number
|
|
283
294
|
}
|
|
284
295
|
|
|
296
|
+
/** Retention cap: how many execution records each task keeps (oldest pruned). */
|
|
297
|
+
export const MAX_EXECUTIONS = 20
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Enforce the execution-record retention cap on one task (in place): keep the
|
|
301
|
+
* newest {@link MAX_EXECUTIONS} records, count the dropped ones in
|
|
302
|
+
* `executionsPruned`. Running records are always the newest, never dropped.
|
|
303
|
+
* @param task - the task to prune.
|
|
304
|
+
*/
|
|
305
|
+
export function pruneExecutions(task: TaskRecord): void {
|
|
306
|
+
if (task.executions.length <= MAX_EXECUTIONS) return
|
|
307
|
+
const dropped = task.executions.length - MAX_EXECUTIONS
|
|
308
|
+
task.executions = task.executions.slice(-MAX_EXECUTIONS)
|
|
309
|
+
task.executionsPruned = (task.executionsPruned ?? 0) + dropped
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
/** The whole durable ledger. */
|
|
286
313
|
export type TaskLedger = {
|
|
287
314
|
schemaVersion: number
|
|
@@ -424,9 +451,51 @@ export function effectivePrompt(task: TaskRecord): string {
|
|
|
424
451
|
* @param task - the task.
|
|
425
452
|
*/
|
|
426
453
|
export function isClaimedBy(task: TaskRecord): string | undefined {
|
|
427
|
-
return task.status === 'in_progress' && task.
|
|
428
|
-
|
|
429
|
-
|
|
454
|
+
return task.status === 'in_progress' && task.claimedBy !== undefined ? task.claimedBy : undefined
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Maintain the explicit claim fields around a status change: entering
|
|
459
|
+
* in_progress under a session records the holder (an execution-start or an
|
|
460
|
+
* agent claim); every move out of in_progress releases the claim (handoff,
|
|
461
|
+
* give-back, cancel). A user-driven move into in_progress records no holder —
|
|
462
|
+
* no session works on it yet.
|
|
463
|
+
* @param task - the task being written (mutated in place).
|
|
464
|
+
* @param to - the target status.
|
|
465
|
+
* @param now - current epoch ms.
|
|
466
|
+
* @param holder - the session id claiming the task, when applicable.
|
|
467
|
+
*/
|
|
468
|
+
export function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?: string): void {
|
|
469
|
+
if (to !== 'in_progress') {
|
|
470
|
+
delete task.claimedBy
|
|
471
|
+
delete task.claimedAt
|
|
472
|
+
} else if (holder !== undefined) {
|
|
473
|
+
task.claimedBy = holder
|
|
474
|
+
task.claimedAt = now
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Validate and normalize a pinned model: `{ provider, model }`, both
|
|
480
|
+
* non-empty trimmed strings.
|
|
481
|
+
* @param raw - the raw input.
|
|
482
|
+
* @returns the normalized model.
|
|
483
|
+
* @throws when the shape or the fields are invalid.
|
|
484
|
+
*/
|
|
485
|
+
export function normalizeModel(raw: unknown): TaskModel {
|
|
486
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
487
|
+
throw new Error('model must be { provider: string, model: string }')
|
|
488
|
+
}
|
|
489
|
+
const { provider, model } = raw as { provider?: unknown; model?: unknown }
|
|
490
|
+
if (typeof provider !== 'string' || typeof model !== 'string') {
|
|
491
|
+
throw new Error('model must be { provider: string, model: string }')
|
|
492
|
+
}
|
|
493
|
+
const p = provider.trim()
|
|
494
|
+
const m = model.trim()
|
|
495
|
+
if (p.length === 0 || m.length === 0) {
|
|
496
|
+
throw new Error('model.provider and model.model must be non-empty strings')
|
|
497
|
+
}
|
|
498
|
+
return { provider: p, model: m }
|
|
430
499
|
}
|
|
431
500
|
|
|
432
501
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin package version shown in the board UI. Kept in sync with
|
|
3
|
+
* package.json by a regression test (tests lock drift).
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-taskboard/shared/version
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The package version (must equal package.json "version"). */
|
|
9
|
+
export const PLUGIN_VERSION = '0.2.0'
|