dsh-taskboard 0.2.2 → 0.3.3

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/src/host/tools.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
22
22
  import { defineTool } from './sdk.ts'
23
23
  import {
24
+ asIsolation,
24
25
  asStatus,
25
26
  asUrgency,
26
27
  canTransition,
@@ -74,11 +75,13 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
74
75
  `任务 ${t.id} 「${t.title}」`,
75
76
  `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
76
77
  `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
78
+ `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}`,
77
79
  ]
78
80
  const holder = isClaimedBy(t)
79
81
  if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
80
82
  if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
81
83
  if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
84
+ if (t.presetId !== undefined) lines.push(`执行模式: ${t.presetId}(未指定时为部署默认 preset)`)
82
85
  lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
83
86
  lines.push(`执行 Prompt: ${t.effectivePrompt ?? effectivePrompt(t)}`)
84
87
  if (t.comments.length > 0) {
@@ -348,6 +351,14 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
348
351
  model: { type: 'string', description: 'Provider-owned model id.' },
349
352
  },
350
353
  },
354
+ isolation: {
355
+ type: 'string',
356
+ description: 'Code isolation for executions: "worktree" (default — each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction).',
357
+ },
358
+ presetId: {
359
+ type: 'string',
360
+ description: 'Agent preset the execution session is composed from (its tool set / persona); default = the deployment default preset. Optional.',
361
+ },
351
362
  },
352
363
  output: {
353
364
  schema: JSON_OUT,
@@ -366,6 +377,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
366
377
  prompt?: string
367
378
  execution?: { mode?: string; cron?: string }
368
379
  model?: { provider?: string; model?: string }
380
+ isolation?: string
381
+ presetId?: string
369
382
  }, exec: unknown) {
370
383
  try {
371
384
  const { actor } = caller(exec as ToolRunContext)
@@ -380,6 +393,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
380
393
  }
381
394
  const execution = normalizeExecution(args.execution ?? {}, deps.now())
382
395
  const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
396
+ const isolation = args.isolation === undefined ? undefined : asIsolation(args.isolation)
397
+ const presetId = args.presetId?.trim() || undefined
383
398
  const now = deps.now()
384
399
  const task: TaskRecord = {
385
400
  id: newTaskId(),
@@ -392,6 +407,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
392
407
  blocked: false,
393
408
  execution,
394
409
  model,
410
+ ...(isolation !== undefined ? { isolation } : {}),
411
+ ...(presetId !== undefined ? { presetId } : {}),
395
412
  version: 1,
396
413
  createdAt: now,
397
414
  updatedAt: now,
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ 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
23
  import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
24
+ import { createGitFace } from './host/git.ts'
24
25
  import { registerTaskboardRoutes } from './host/routes.ts'
25
26
  import { SchedulerService } from './host/scheduler.ts'
26
27
  import { dshHomePath } from './host/sdk.ts'
@@ -85,6 +86,10 @@ export function apply(ctx: Context): void {
85
86
  }),
86
87
  }
87
88
 
89
+ // The narrow git face shared by execution (worktree isolation) and the
90
+ // routes (merge / remove / workspace detection).
91
+ const git = createGitFace()
92
+
88
93
  wsCtx.inject(['agents'], (agentCtx: Context) => {
89
94
  const execution = new ExecutionService({
90
95
  store,
@@ -100,6 +105,23 @@ export function apply(ctx: Context): void {
100
105
  },
101
106
  events,
102
107
  now,
108
+ git,
109
+ // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve
110
+ // the id BEFORE creation (the session header snapshots meta), mount
111
+ // inside the factory's setup callback. No roster service → undefined
112
+ // (bare host composition, the pre-preset behavior).
113
+ composeAgent: async (presetId) => {
114
+ const presets = agentCtx.get('agentPresets') as {
115
+ resolve(id?: string): Promise<{ id: string }>
116
+ mount(agentCtx: unknown, id?: string): Promise<unknown>
117
+ } | undefined
118
+ if (presets === undefined) return undefined
119
+ const resolved = await presets.resolve(presetId)
120
+ return {
121
+ agentPreset: resolved.id,
122
+ setup: async (ctx: unknown) => { await presets.mount(ctx, resolved.id) },
123
+ }
124
+ },
103
125
  renameSession: (sessionId, title) => {
104
126
  // Best-effort: pin the execution session's title to the task title
105
127
  // through the log-backed session-title service (user-sourced rename).
@@ -127,9 +149,10 @@ export function apply(ctx: Context): void {
127
149
  store,
128
150
  workspaces: workspaceFace(wsCtx.workspaceRegistry),
129
151
  now,
130
- run: (taskId: string) => execution.run(taskId, 'manual'),
152
+ run: (taskId: string, runOptions?: { reuseWorktree?: boolean }) => execution.run(taskId, 'manual', runOptions),
131
153
  cancel: (taskId: string) => execution.cancel(taskId),
132
154
  modelProviders,
155
+ git,
133
156
  })
134
157
  return () => disposeRoutes?.()
135
158
  })
package/src/shared/api.ts CHANGED
@@ -41,7 +41,7 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
41
41
  export type StateResponse = TaskLedger
42
42
 
43
43
  /** Workspace listing for the UI pickers. */
44
- export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number }
44
+ export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }
45
45
 
46
46
  /** Create-task request body (actor is always the GUI user). */
47
47
  export type CreateTaskBody = {
@@ -52,6 +52,10 @@ export type CreateTaskBody = {
52
52
  prompt?: string
53
53
  execution?: { mode?: string; cron?: string }
54
54
  model?: { provider: string; model: string }
55
+ /** Code isolation for executions ('worktree' | 'none'); omitted = default. */
56
+ isolation?: string
57
+ /** Agent preset for execution sessions; omitted = deployment default. */
58
+ presetId?: string
55
59
  }
56
60
 
57
61
  /** Update-task request body (ifVersion mandatory). */
@@ -66,6 +70,10 @@ export type UpdateTaskBody = {
66
70
  workspaceId?: string
67
71
  execution?: { mode?: string; cron?: string }
68
72
  model?: { provider: string; model: string } | null
73
+ /** Change isolation; locked once the task has execution history. */
74
+ isolation?: string
75
+ /** Change the execution preset (takes effect on the next run). */
76
+ presetId?: string | null
69
77
  }
70
78
 
71
79
  /** Move-task request body (ifVersion mandatory; the user MAY move to done). */
@@ -84,8 +92,32 @@ export type CommentBody = { body: string }
84
92
  /** Delete request body (purge=true physically removes a trashed task). */
85
93
  export type DeleteTaskBody = { ifVersion?: number; purge?: boolean }
86
94
 
87
- /** Run request body (P3). */
88
- export type RunTaskBody = Record<string, never>
95
+ /** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */
96
+ export type RunTaskBody = { reuse?: boolean }
97
+
98
+ /** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */
99
+ export type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }
100
+
101
+ /** Remove a task's worktree; optionally delete its branch too. */
102
+ export type WorktreeRemoveBody = { deleteBranch?: boolean }
103
+
104
+ /** One orphan worktree directory (exists on disk, owned by no live task). */
105
+ export type OrphanWorktree = { workspaceId: string; workspacePath: string; taskId: string; path: string }
106
+
107
+ /** A git-enabled workspace whose .gitignore does not cover the worktree dir. */
108
+ export type GitignoreSuggestion = { workspaceId: string; workspacePath: string }
109
+
110
+ /** Health-diagnostics response (⚙ panel). */
111
+ export type DiagnosticsResponse = {
112
+ revision: number
113
+ tasks: number
114
+ /** Executions currently marked `running`. */
115
+ staleRunning: number
116
+ /** Worktree directories whose task no longer exists in the ledger. */
117
+ orphanWorktrees: OrphanWorktree[]
118
+ /** Git workspaces whose .gitignore does not ignore the worktree dir. */
119
+ gitIgnoreSuggestions: GitignoreSuggestion[]
120
+ }
89
121
 
90
122
  /** One task (full record) response. */
91
123
  export type TaskResponse = TaskRecord
@@ -102,6 +102,29 @@ export const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {
102
102
  // Execution
103
103
  // ---------------------------------------------------------------------------
104
104
 
105
+ /**
106
+ * Per-task code isolation mode (0.3.0).
107
+ * - `worktree`: each execution runs in a fresh `git worktree` on a dedicated
108
+ * task branch (`task/<标题>+<taskId>`) under `<workspace>/.dsh-worktrees/`.
109
+ * - `none`: run in the workspace directory as before, zero git interaction.
110
+ * Omitted = the default `worktree`; non-git projects auto-degrade at run
111
+ * time (the execution record carries an `isolationNote` explaining why).
112
+ */
113
+ export type IsolationMode = 'worktree' | 'none'
114
+
115
+ /** Validate an isolation value. */
116
+ export function asIsolation(raw: string): IsolationMode {
117
+ if (raw !== 'worktree' && raw !== 'none') {
118
+ throw new Error("isolation must be 'worktree' or 'none'")
119
+ }
120
+ return raw
121
+ }
122
+
123
+ /** Resolve a task's effective isolation (omitted → the worktree default). */
124
+ export function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {
125
+ return task.isolation === undefined ? 'worktree' : task.isolation
126
+ }
127
+
105
128
  /** How a task may run. */
106
129
  export type ExecutionMode = 'claim' | 'scheduled'
107
130
 
@@ -237,6 +260,9 @@ export type CommentRecord = {
237
260
  threadId?: string
238
261
  }
239
262
 
263
+ /** One commit produced by an isolated execution (hash + subject). */
264
+ export type CommitInfo = { hash: string; subject: string }
265
+
240
266
  /** One execution attempt of a task. */
241
267
  export type ExecutionRecord = {
242
268
  id: string
@@ -248,6 +274,30 @@ export type ExecutionRecord = {
248
274
  endedAt?: number
249
275
  outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'
250
276
  error?: string
277
+ /** Code isolation actually used (`none` also covers degraded worktree runs). */
278
+ isolation?: IsolationMode
279
+ /** Why worktree isolation degraded to running in the original directory. */
280
+ isolationNote?: string
281
+ /** The task branch this execution worked on (worktree runs only). */
282
+ branch?: string
283
+ /** Absolute path of the dedicated worktree (worktree runs only). */
284
+ worktreePath?: string
285
+ /** HEAD of the task branch before the execution started. */
286
+ baseCommit?: string
287
+ /** HEAD at settlement. */
288
+ headCommit?: string
289
+ /** Commits between baseCommit and headCommit (hash + subject; capped at 50, newest first). */
290
+ commits?: CommitInfo[]
291
+ /** Total commits before the evidence cap (equals commits.length when under it). */
292
+ commitsTotal?: number
293
+ /** Uncommitted changes present at settlement (`git status --porcelain` lines; capped at 100). */
294
+ dirtyFiles?: string[]
295
+ /** Total uncommitted lines before the evidence cap. */
296
+ dirtyFilesTotal?: number
297
+ /** Aggregate diff stat between baseCommit and headCommit. */
298
+ diffStat?: string
299
+ /** How many files differ between baseCommit and headCommit. */
300
+ changedFiles?: number
251
301
  }
252
302
 
253
303
  /** The per-model override a task may carry; absent = session default model. */
@@ -271,6 +321,20 @@ export type TaskRecord = {
271
321
  blocked: boolean
272
322
  execution: ExecutionConfig
273
323
  model?: TaskModel
324
+ /** Code isolation for executions (omitted = the worktree default; see {@link IsolationMode}). */
325
+ isolation?: IsolationMode
326
+ /**
327
+ * The agent preset execution sessions are composed from (omitted = the
328
+ * deployment default preset). Recorded on the session header and mounted
329
+ * via the presets service at creation — this is what hands the session its
330
+ * tool set. Editable any time (each run composes fresh).
331
+ */
332
+ presetId?: string
333
+ /**
334
+ * The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).
335
+ * Renaming the task afterwards never changes it (history preservation).
336
+ */
337
+ branch?: string
274
338
  /**
275
339
  * The session currently holding the in-progress claim (explicit claim or a
276
340
  * live execution). Present only while `status === 'in_progress'`: any move
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.2.2'
9
+ export const PLUGIN_VERSION = '0.3.3'