@linxin666/dsh-client-ui-task-board 0.1.16 → 0.1.18

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 (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +11 -7
  3. package/README.zh.md +11 -7
  4. package/lib/client.js +821 -145
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1 -1
  7. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  8. package/lib/types/client/locales.d.ts +21 -0
  9. package/lib/types/client/schedule-presets.d.ts +10 -0
  10. package/lib/types/core/controller.d.ts +57 -1
  11. package/lib/types/core/execution.d.ts +65 -4
  12. package/lib/types/core/scheduler.d.ts +7 -0
  13. package/lib/types/core/store.d.ts +1 -1
  14. package/lib/types/core/tasks.d.ts +43 -0
  15. package/lib/types/core/use-cases/task-archive.d.ts +22 -0
  16. package/lib/types/core/use-cases/task-create.d.ts +1 -6
  17. package/lib/types/core/use-cases/task-update.d.ts +9 -4
  18. package/lib/types/index.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/src/client/PluginSettingsCard.tsx +50 -47
  21. package/src/client/board/NewTaskModal.tsx +136 -3
  22. package/src/client/board/TaskBoard.tsx +48 -19
  23. package/src/client/board/TaskDetail.tsx +97 -8
  24. package/src/client/board.module.css +16 -0
  25. package/src/client/index.ts +71 -2
  26. package/src/client/locales.ts +42 -0
  27. package/src/client/schedule-presets.ts +13 -0
  28. package/src/client/settings-card.module.css +9 -0
  29. package/src/core/controller.ts +92 -2
  30. package/src/core/execution.ts +179 -12
  31. package/src/core/scheduler.ts +15 -0
  32. package/src/core/store.ts +16 -2
  33. package/src/core/tasks.ts +56 -0
  34. package/src/core/use-cases/task-archive.ts +48 -0
  35. package/src/core/use-cases/task-create.ts +12 -3
  36. package/src/core/use-cases/task-update.ts +34 -5
  37. package/src/index.ts +1 -1
@@ -19,10 +19,11 @@ import {
19
19
  settleExecution, startExecution, withStatus,
20
20
  type NewTaskInput, type TaskRecord, type TaskStatus,
21
21
  } from './tasks.ts'
22
+ import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
22
23
  import { applyCreateTask } from './use-cases/task-create.ts'
23
24
  import { applyDeleteTask } from './use-cases/task-delete.ts'
24
25
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
25
- import { applyUpdateTask } from './use-cases/task-update.ts'
26
+ import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
26
27
 
27
28
  /** The sessions face the controller needs for navigation awareness. */
28
29
  export interface SessionsControllerFace {
@@ -47,11 +48,38 @@ export interface ControllerDeps {
47
48
  reconcileDebounceMs?: number
48
49
  }
49
50
 
51
+ /** One workspace option the execution-target pickers offer. */
52
+ export interface ExecutionWorkspaceOption {
53
+ workspaceId: string
54
+ /** Display label (workspace title; the wiring falls back to the path). */
55
+ title: string
56
+ }
57
+
58
+ /** One agent-preset option the execution-target pickers offer. */
59
+ export interface ExecutionPresetOption {
60
+ id: string
61
+ name?: string
62
+ description?: string
63
+ /** Why this preset cannot compose a session; the pickers disable it. */
64
+ broken?: string
65
+ isDefault: boolean
66
+ }
67
+
68
+ /** The execution-target option sets the UI feeds into the controller. */
69
+ export interface ExecutionOptionsSnapshot {
70
+ workspaces: readonly ExecutionWorkspaceOption[]
71
+ presets: readonly ExecutionPresetOption[]
72
+ }
73
+
50
74
  /** Immutable controller snapshot for UI subscriptions. */
51
75
  export interface ControllerSnapshot {
52
76
  tasks: readonly TaskRecord[]
53
77
  boardOpen: boolean
78
+ /** True when the board shows the archive view instead of the columns. */
79
+ archiveView: boolean
54
80
  selectedTaskId: string | undefined
81
+ /** Picker option sets (workspace list + agent-preset roster). */
82
+ executionOptions: ExecutionOptionsSnapshot
55
83
  }
56
84
 
57
85
  /** The selected task (resolved from the ledger), or undefined. */
@@ -84,7 +112,9 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
84
112
  export class BoardController {
85
113
  private tasks: TaskRecord[] = []
86
114
  private boardOpen = false
115
+ private archiveView = false
87
116
  private selectedTaskId: string | undefined
117
+ private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
88
118
  private listeners = new Set<() => void>()
89
119
  private disposers: Array<() => void> = []
90
120
  private readonly now: () => number
@@ -131,7 +161,9 @@ export class BoardController {
131
161
  return {
132
162
  tasks: this.tasks,
133
163
  boardOpen: this.boardOpen,
164
+ archiveView: this.archiveView,
134
165
  selectedTaskId: this.selectedTaskId,
166
+ executionOptions: this.executionOptions,
135
167
  }
136
168
  }
137
169
 
@@ -163,6 +195,20 @@ export class BoardController {
163
195
  else this.openBoard()
164
196
  }
165
197
 
198
+ /**
199
+ * Switch between the kanban columns and the archive view. Leaving the
200
+ * archive view with an archived task still selected closes the selection —
201
+ * the detail overlay must not linger over a task that is off-board.
202
+ */
203
+ toggleArchiveView(): void {
204
+ this.archiveView = !this.archiveView
205
+ if (!this.archiveView && this.selectedTaskId !== undefined) {
206
+ const selected = this.tasks.find(task => task.id === this.selectedTaskId)
207
+ if (selected?.archivedAt !== undefined) this.selectedTaskId = undefined
208
+ }
209
+ this.notify()
210
+ }
211
+
166
212
  openTask(id: string): void {
167
213
  if (this.tasks.some(task => task.id === id)) {
168
214
  this.selectedTaskId = id
@@ -186,11 +232,20 @@ export class BoardController {
186
232
  return task
187
233
  }
188
234
 
189
- updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
235
+ updateTask(id: string, patch: TaskUpdatePatch): void {
190
236
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
191
237
  this.persistAndNotify()
192
238
  }
193
239
 
240
+ /**
241
+ * Replace (a part of) the picker option sets the UI feeds (workspace list
242
+ * and agent-preset roster come from the runtime, not the ledger).
243
+ */
244
+ setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void {
245
+ this.executionOptions = { ...this.executionOptions, ...patch }
246
+ this.notify()
247
+ }
248
+
194
249
  moveTask(id: string, status: TaskStatus): void {
195
250
  this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
196
251
  this.persistAndNotify()
@@ -203,6 +258,29 @@ export class BoardController {
203
258
  this.persistAndNotify()
204
259
  }
205
260
 
261
+ /**
262
+ * Archive a settled task (done/failed). Running or on-board-unsettled
263
+ * tasks are refused so the runner keeps exclusive ownership of their
264
+ * lifecycle.
265
+ * @returns true when applied.
266
+ */
267
+ archiveTask(id: string): boolean {
268
+ const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
269
+ if (!archived) return false
270
+ this.tasks = [...tasks]
271
+ this.persistAndNotify()
272
+ return true
273
+ }
274
+
275
+ /** Restore an archived task back onto the board (same status column). */
276
+ restoreTask(id: string): boolean {
277
+ const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
278
+ if (!archived) return false
279
+ this.tasks = [...tasks]
280
+ this.persistAndNotify()
281
+ return true
282
+ }
283
+
206
284
  // --- scheduling ---------------------------------------------------------------
207
285
 
208
286
  /**
@@ -233,6 +311,18 @@ export class BoardController {
233
311
  this.persistAndNotify()
234
312
  }
235
313
 
314
+ /**
315
+ * Reload the ledger from the persisted store without notifying subscribers.
316
+ * The scheduler calls this before every tick so a task deleted in another
317
+ * tab (or a stale in-memory copy) can never be fired from this tab: the
318
+ * fire decision and the subsequent roll-forward both run on the freshest
319
+ * persisted truth. Deliberately silent — same-origin external changes still
320
+ * reach subscribers through the storage-event subscription.
321
+ */
322
+ reloadFromStore(): void {
323
+ this.tasks = this.deps.store.load()
324
+ }
325
+
236
326
  /**
237
327
  * Jump to an execution's session transcript. Selecting the session changes
238
328
  * `current`, which closes the board (the conversation view takes over).
@@ -15,19 +15,40 @@
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, { running: boolean; completed?: boolean }>
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
 
49
+ /** Settled result of one agent-preset switch, shared by concurrent waiters. */
50
+ type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
51
+
31
52
  /** The narrow workspaces face the service needs. */
32
53
  export interface WorkspacesExecutionFace {
33
54
  list: {
@@ -57,6 +78,12 @@ export interface SessionDriver {
57
78
  content: readonly unknown[],
58
79
  mode: 'queue',
59
80
  ): Promise<{ ok: true } | { ok: false; error: unknown }>
81
+ /**
82
+ * Admit one slash-command line against the session's agent (the
83
+ * `/permission <id>` mechanism). `matched` reports whether a command
84
+ * claimed the line.
85
+ */
86
+ command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
60
87
  getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
61
88
  subscribe(fn: () => void): () => void
62
89
  }
@@ -65,6 +92,8 @@ export interface SessionDriver {
65
92
  export interface ExecutionEnvironment {
66
93
  sessions: SessionsExecutionFace
67
94
  workspaces: WorkspacesExecutionFace
95
+ /** Agent-preset wire face; absent on deployments without preset support. */
96
+ presets?: PresetsExecutionFace
68
97
  /** Raw-history reader for failure detection of never-opened sessions. */
69
98
  history?: HistoryExecutionFace
70
99
  }
@@ -80,6 +109,19 @@ function messageOf(error: unknown): string {
80
109
  return String(error)
81
110
  }
82
111
 
112
+ /**
113
+ * Whether a rejected preset switch actually means "the session already runs
114
+ * this preset" (the host's agent-preset-conflict with a matching
115
+ * existingPreset). A blank-session reuse race can produce this even though
116
+ * the requested composition is in place.
117
+ */
118
+ function presetAlreadyRuns(error: unknown, mode: string): boolean {
119
+ if (typeof error !== 'object' || error === null) return false
120
+ const details = (error as { details?: unknown }).details
121
+ if (typeof details !== 'object' || details === null) return false
122
+ return (details as { existingPreset?: unknown }).existingPreset === mode
123
+ }
124
+
83
125
  /** Whether a `turn/end` payload closed the turn with an error reason. */
84
126
  function isErrorTurnEnd(data: unknown): boolean {
85
127
  if (typeof data !== 'object' || data === null) return false
@@ -98,6 +140,16 @@ function isErrorTurnEnd(data: unknown): boolean {
98
140
  * every failure path is reported as a settled event.
99
141
  */
100
142
  export class ExecutionService {
143
+ /**
144
+ * In-flight preset switches keyed by `sessionId\u0000mode`. The session
145
+ * list mirror can lag behind a just-applied switch, so concurrent runs
146
+ * against one blank session would otherwise each issue the same select
147
+ * RPC (an amplified storm); sharing one in-flight call keeps the wire to
148
+ * a single request. The settled entry is forgotten so a later run can
149
+ * switch again.
150
+ */
151
+ private readonly presetSwitches = new Map<string, Promise<PresetSelectResult>>()
152
+
101
153
  /** @param env - the runtime faces (real or fake). */
102
154
  constructor(private readonly env: ExecutionEnvironment) {}
103
155
 
@@ -106,14 +158,24 @@ export class ExecutionService {
106
158
  execution: ExecutionRecord,
107
159
  onEvent: (event: ExecutionEvent) => void,
108
160
  ): Promise<void> {
161
+ const settleFailed = (error: string): void => {
162
+ onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
163
+ }
109
164
  try {
110
- const sessionId = await this.connectSession()
165
+ const sessionId = await this.connectSession(task.workspaceId)
111
166
  onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
112
167
  const driver = this.driverOf(sessionId)
113
168
  if (driver === undefined) {
114
- onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'execution session is not ready' })
169
+ settleFailed('execution session is not ready')
115
170
  return
116
171
  }
172
+ // Task-pinned execution targets are applied BEFORE any prompt: a preset
173
+ // can only be recomposed while the session is blank, and the permission
174
+ // command must run before the task's turn starts. A rejected target
175
+ // fails the run without sending the prompt — running the task under
176
+ // different settings than it declared would be worse than not running.
177
+ if (!await this.applyMode(driver, task, sessionId, settleFailed)) return
178
+ if (!await this.applyPermission(driver, task, settleFailed)) return
117
179
  // Best-effort rename so the execution is recognizable in the session list.
118
180
  await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
119
181
  // Baseline the turn counter BEFORE the prompt round-trip: a turn that
@@ -122,21 +184,118 @@ export class ExecutionService {
122
184
  const baseline = driver.getSnapshot().turnEnds.size
123
185
  const accepted = await this.sendPrompt(driver, task)
124
186
  if (!accepted.ok) {
125
- onEvent({
126
- kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
127
- error: messageOf(accepted.error),
128
- })
187
+ settleFailed(messageOf(accepted.error))
129
188
  return
130
189
  }
131
190
  this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
132
191
  } catch (error) {
133
- onEvent({
134
- kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
135
- error: messageOf(error),
136
- })
192
+ settleFailed(messageOf(error))
137
193
  }
138
194
  }
139
195
 
196
+ /**
197
+ * Recompose the execution session's agent from the task-pinned preset.
198
+ * No-op when the task pins none or the session already runs it; fails the
199
+ * run when the session is no longer blank, the preset face is missing, or
200
+ * the wire refuses.
201
+ */
202
+ private async applyMode(
203
+ driver: SessionDriver,
204
+ task: TaskRecord,
205
+ sessionId: string,
206
+ settleFailed: (error: string) => void,
207
+ ): Promise<boolean> {
208
+ const mode = task.mode
209
+ if (mode === undefined || mode === '') return true
210
+ const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
211
+ if (summary?.blank === false) {
212
+ settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
213
+ return false
214
+ }
215
+ // Fast path: the list mirror already reports the pinned preset. The
216
+ // mirror can lag a just-applied switch, though, so this guard alone is
217
+ // not enough under concurrent runs — `switchPreset` below dedupes the
218
+ // wire call for the lag window.
219
+ if (summary?.agentPreset === mode) return true
220
+ const presets = this.env.presets
221
+ if (presets === undefined) {
222
+ settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
223
+ return false
224
+ }
225
+ try {
226
+ const result = await this.switchPreset(presets, sessionId, mode)
227
+ if (!result.ok) {
228
+ // A list race can leave the summary without the preset label even
229
+ // though the blank session already runs it; the wire answers that
230
+ // case with agent-preset-conflict (existingPreset === requested).
231
+ // The requested composition is already in place, so count it as
232
+ // applied instead of failing the run.
233
+ if (presetAlreadyRuns(result.error, mode)) {
234
+ this.env.sessions.noteAgentPreset?.(sessionId, mode)
235
+ return true
236
+ }
237
+ settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
238
+ return false
239
+ }
240
+ } catch (error) {
241
+ settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
242
+ return false
243
+ }
244
+ this.env.sessions.noteAgentPreset?.(sessionId, mode)
245
+ return true
246
+ }
247
+
248
+ /**
249
+ * One in-flight `select` per (session, preset): concurrent runs against
250
+ * the same blank session share the same wire call instead of each issuing
251
+ * a duplicate RPC. The entry is removed once the call settles, so a later
252
+ * run (after a shared failure, say) issues a fresh switch.
253
+ */
254
+ private switchPreset(
255
+ presets: PresetsExecutionFace,
256
+ sessionId: string,
257
+ mode: string,
258
+ ): Promise<PresetSelectResult> {
259
+ const key = `${sessionId}\u0000${mode}`
260
+ const inflight = this.presetSwitches.get(key)
261
+ if (inflight !== undefined) return inflight
262
+ const attempt = presets.select(sessionId, mode).finally(() => {
263
+ if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key)
264
+ })
265
+ this.presetSwitches.set(key, attempt)
266
+ return attempt
267
+ }
268
+
269
+ /**
270
+ * Apply the task-pinned permission preset through the `/permission <id>`
271
+ * slash command. No-op when the task pins none; fails the run when the
272
+ * admission is rejected or no command claimed the line.
273
+ */
274
+ private async applyPermission(
275
+ driver: SessionDriver,
276
+ task: TaskRecord,
277
+ settleFailed: (error: string) => void,
278
+ ): Promise<boolean> {
279
+ const permission = task.permission
280
+ if (permission === undefined) return true
281
+ const line = `/permission ${permission}`
282
+ try {
283
+ const result = await driver.command(line)
284
+ if (!result.ok) {
285
+ settleFailed(`permission command rejected: ${messageOf(result.error)}`)
286
+ return false
287
+ }
288
+ if (!result.matched) {
289
+ settleFailed(`permission command not recognized: ${line}`)
290
+ return false
291
+ }
292
+ } catch (error) {
293
+ settleFailed(`permission command failed: ${messageOf(error)}`)
294
+ return false
295
+ }
296
+ return true
297
+ }
298
+
140
299
  /**
141
300
  * Inspect a reloaded/background task that was left 'running' and emit a
142
301
  * settled event when its session already finished.
@@ -198,8 +357,16 @@ export class ExecutionService {
198
357
  }
199
358
  }
200
359
 
201
- private async connectSession(): Promise<string> {
360
+ private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
202
361
  const workspace = this.env.workspaces.list.getSnapshot()
362
+ if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
363
+ // A task-pinned workspace must exist in the list: connecting an
364
+ // unknown id would only defer the failure into the wire.
365
+ if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
366
+ throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
367
+ }
368
+ return this.env.workspaces.connectWorkspace(taskWorkspaceId)
369
+ }
203
370
  const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
204
371
  if (workspaceId === undefined) {
205
372
  throw new Error('no workspace available to run the task in')
@@ -9,6 +9,11 @@
9
9
  * a task still running at its due instant is skipped by the controller's
10
10
  * runTask guard and simply waits for the next cron match.
11
11
  *
12
+ * A deleted task can never fire again from a stale in-memory copy: every
13
+ * tick first re-reads the persisted ledger through the optional `refresh`
14
+ * hook (the controller reloads its store), so the fire decision and the
15
+ * subsequent roll-forward both run on the freshest persisted truth.
16
+ *
12
17
  * The ticker is controlled: `start` arms a single interval guarded by an
13
18
  * idempotence check (a second start while running is a no-op, so wiring can
14
19
  * never leak a duplicate timer), and `stop`/`dispose` clear the timer and
@@ -25,6 +30,13 @@ import type { TaskRecord } from './tasks.ts'
25
30
  export interface SchedulerDeps {
26
31
  /** Read the current task ledger (the controller snapshot). */
27
32
  tasks(): readonly TaskRecord[]
33
+ /**
34
+ * Re-sync the task ledger from its persisted source before the tick decides
35
+ * what is due. The wiring passes the controller's store reload so a task
36
+ * deleted elsewhere can never be fired from a stale in-memory copy.
37
+ * Optional: absent in pure in-memory harnesses.
38
+ */
39
+ refresh?: () => void
28
40
  /** Clock; defaults to Date.now in the controller wiring. */
29
41
  now(): number
30
42
  /** Trigger one task's real execution (the controller's runTask); resolves
@@ -112,6 +124,9 @@ export class SchedulerService {
112
124
  async tick(): Promise<void> {
113
125
  if (this.disposed) return
114
126
  if (this.deps.ready !== undefined && !this.deps.ready()) return
127
+ // Freshest persisted truth before any fire decision: a task deleted in
128
+ // another tab (or a stale in-memory copy) must never be triggered.
129
+ this.deps.refresh?.()
115
130
  const now = this.deps.now()
116
131
  for (const task of this.deps.tasks()) {
117
132
  const schedule = task.schedule
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 { ScheduleRule, TaskRecord, TaskStatus } from './tasks.ts'
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,13 @@ 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.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
152
+ task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
139
153
  tasks.push(task)
140
154
  }
141
155
  return tasks
package/src/core/tasks.ts CHANGED
@@ -64,6 +64,41 @@ 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
+ * When the task was archived (ms epoch). Archived tasks keep their status
84
+ * and execution history but leave the main board; absent means on-board.
85
+ */
86
+ archivedAt?: number
87
+ }
88
+
89
+ /** Statuses a settled task may be archived from. */
90
+ export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
91
+
92
+
93
+ /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
94
+ export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
95
+
96
+ /** One permission preset id. */
97
+ export type TaskPermission = typeof TASK_PERMISSIONS[number]
98
+
99
+ /** Whether an unknown value is a known permission preset id. */
100
+ export function isTaskPermission(value: unknown): value is TaskPermission {
101
+ return typeof value === 'string' && (TASK_PERMISSIONS as readonly string[]).includes(value)
67
102
  }
68
103
 
69
104
  /** Input for creating a task. */
@@ -71,6 +106,18 @@ export interface NewTaskInput {
71
106
  title: string
72
107
  description: string
73
108
  prompt: string
109
+ /** Workspace the execution must run in; empty/absent = the recent workspace. */
110
+ workspaceId?: string
111
+ /** Agent preset the execution session must be composed from; empty/absent = deployment default. */
112
+ mode?: string
113
+ /** Permission preset applied to the execution session; absent = session default. */
114
+ permission?: TaskPermission
115
+ /**
116
+ * Optional scheduled-run rule requested at creation time (the new-task
117
+ * dialog): an enable flag plus a 5-field cron expression. The create use
118
+ * case arms it only when enabled and the expression is valid.
119
+ */
120
+ schedule?: { enabled: boolean; cron: string }
74
121
  }
75
122
 
76
123
  /** The five kanban columns, in display order. */
@@ -103,6 +150,12 @@ export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
103
150
  return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
104
151
  }
105
152
 
153
+ /** Normalize one optional execution-target string: trim; blank collapses to undefined. */
154
+ function normalizeTargetId(value: string | undefined): string | undefined {
155
+ const trimmed = value?.trim()
156
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
157
+ }
158
+
106
159
  /** Create a task from user input. */
107
160
  export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
108
161
  return {
@@ -114,6 +167,9 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
114
167
  createdAt: now,
115
168
  updatedAt: now,
116
169
  executions: [],
170
+ workspaceId: normalizeTargetId(input.workspaceId),
171
+ mode: normalizeTargetId(input.mode),
172
+ permission: isTaskPermission(input.permission) ? input.permission : undefined,
117
173
  }
118
174
  }
119
175
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Archive/restore task use case: move a settled (done/failed) task off the
3
+ * main board and back. Purely a marker transition — the task keeps its
4
+ * status, execution history and transcript references, so archiving never
5
+ * loses traceability and restoring lands it in the exact column it left.
6
+ */
7
+ import type { TaskRecord } from '../tasks.ts'
8
+ import { ARCHIVABLE_STATUSES } from '../tasks.ts'
9
+
10
+ /** Result of an archive transition. */
11
+ export interface ArchiveTaskResult {
12
+ /** The next ledger. */
13
+ tasks: readonly TaskRecord[]
14
+ /** Whether the archive was applied (false = unknown task / not archivable). */
15
+ archived: boolean
16
+ }
17
+
18
+ /**
19
+ * Archive one task: only settled statuses (done/failed) can be archived;
20
+ * a running or not-yet-settled task stays on the board (its runner still
21
+ * owns its lifecycle). Already-archived tasks are a no-op.
22
+ */
23
+ export function applyArchiveTask(
24
+ tasks: readonly TaskRecord[],
25
+ id: string,
26
+ now: number,
27
+ ): ArchiveTaskResult {
28
+ let applied = false
29
+ const next = tasks.map(task => {
30
+ if (task.id !== id || task.archivedAt !== undefined) return task
31
+ if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
32
+ applied = true
33
+ return { ...task, archivedAt: now, updatedAt: now }
34
+ })
35
+ return { tasks: next, archived: applied }
36
+ }
37
+
38
+ /** Restore one task back onto the main board (clears the archive marker). */
39
+ export function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult {
40
+ let applied = false
41
+ const next = tasks.map(task => {
42
+ if (task.id !== id || task.archivedAt === undefined) return task
43
+ applied = true
44
+ const { archivedAt: _archived, ...rest } = task
45
+ return { ...rest, updatedAt: now }
46
+ })
47
+ return { tasks: next, archived: applied }
48
+ }
@@ -3,7 +3,8 @@
3
3
  * title. Pure ledger transition (no persistence or notify — the controller
4
4
  * orchestrates those), so it is unit-testable without any runtime face.
5
5
  */
6
- import { createTask, type NewTaskInput, type TaskRecord } from '../tasks.ts'
6
+ import { isValidCron, nextRunAtMs } from '../schedule.ts'
7
+ import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
7
8
 
8
9
  /** Result of a create transition: the new task (when accepted) + the next ledger. */
9
10
  export interface CreateTaskResult {
@@ -17,7 +18,7 @@ export interface CreateTaskResult {
17
18
  * Apply a create against the current ledger. Returns the new task and the
18
19
  * appended ledger, or the unchanged ledger when the title is blank.
19
20
  * @param tasks - current ledger.
20
- * @param input - raw user input (title/description/prompt).
21
+ * @param input - raw user input (title/description/prompt + optional schedule).
21
22
  * @param now - clock instant (ms epoch).
22
23
  * @param id - minted task id.
23
24
  */
@@ -28,6 +29,14 @@ export function applyCreateTask(
28
29
  id: string,
29
30
  ): CreateTaskResult {
30
31
  if (input.title.trim() === '') return { task: undefined, tasks }
31
- const task = createTask(input, now, id)
32
+ let task = createTask(input, now, id)
33
+ // Arm the requested schedule (new-task dialog): only an enabled rule with
34
+ // a valid cron is applied; blank, invalid, or disabled requests leave the
35
+ // task unscheduled.
36
+ const requested = input.schedule
37
+ if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
38
+ const cron = requested.cron.trim()
39
+ task = withSchedule(task, { enabled: true, cron, nextRunAt: nextRunAtMs(cron, now) }, now)
40
+ }
32
41
  return { task, tasks: [...tasks, task] }
33
42
  }