@linxin666/dsh-client-ui-task-board 0.1.17 → 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.
@@ -14,6 +14,10 @@ export const zh = {
14
14
  'board.search': '筛选任务…',
15
15
  'board.empty': '这个状态还没有任务',
16
16
  'board.filterAll': '全部',
17
+ 'board.archive': '归档',
18
+ 'board.archiveView': '归档 ({count})',
19
+ 'board.backToBoard': '返回看板',
20
+ 'archive.empty': '没有已归档的任务',
17
21
  'board.status': '状态',
18
22
  'board.status.backlog': '待规划',
19
23
  'board.status.todo': '待办',
@@ -41,6 +45,9 @@ export const zh = {
41
45
  'detail.run': '执行',
42
46
  'detail.rerun': '重新执行',
43
47
  'detail.delete': '删除',
48
+ 'detail.archive': '归档',
49
+ 'detail.restore': '恢复',
50
+ 'detail.archivedAt': '已归档 · {time}',
44
51
  'detail.viewSession': '查看会话',
45
52
  'detail.noSession': '暂无会话',
46
53
  'detail.executionStarted': '已启动',
@@ -120,6 +127,10 @@ export const en: Record<keyof typeof zh, string> = {
120
127
  'board.search': 'Filter tasks…',
121
128
  'board.empty': 'No tasks in this column',
122
129
  'board.filterAll': 'All',
130
+ 'board.archive': 'Archive',
131
+ 'board.archiveView': 'Archived ({count})',
132
+ 'board.backToBoard': 'Back to board',
133
+ 'archive.empty': 'No archived tasks',
123
134
  'board.status': 'Status',
124
135
  'board.status.backlog': 'Backlog',
125
136
  'board.status.todo': 'To Do',
@@ -147,6 +158,9 @@ export const en: Record<keyof typeof zh, string> = {
147
158
  'detail.run': 'Run',
148
159
  'detail.rerun': 'Run Again',
149
160
  'detail.delete': 'Delete',
161
+ 'detail.archive': 'Archive',
162
+ 'detail.restore': 'Restore',
163
+ 'detail.archivedAt': 'Archived · {time}',
150
164
  'detail.viewSession': 'View Session',
151
165
  'detail.noSession': 'No session',
152
166
  'detail.executionStarted': 'Started',
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared scheduled-run presets for the schedule editors (the new-task dialog
3
+ * and the task detail panel): cron expression → locale label.
4
+ */
5
+ import type { TaskBoardKey } from './locales.ts'
6
+
7
+ /** Common scheduled-run presets (cron → locale label). */
8
+ export const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
9
+ { cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
10
+ { cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
11
+ { cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
12
+ { cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
13
+ ]
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;
@@ -19,6 +19,7 @@ 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'
@@ -74,6 +75,8 @@ export interface ExecutionOptionsSnapshot {
74
75
  export interface ControllerSnapshot {
75
76
  tasks: readonly TaskRecord[]
76
77
  boardOpen: boolean
78
+ /** True when the board shows the archive view instead of the columns. */
79
+ archiveView: boolean
77
80
  selectedTaskId: string | undefined
78
81
  /** Picker option sets (workspace list + agent-preset roster). */
79
82
  executionOptions: ExecutionOptionsSnapshot
@@ -109,6 +112,7 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
109
112
  export class BoardController {
110
113
  private tasks: TaskRecord[] = []
111
114
  private boardOpen = false
115
+ private archiveView = false
112
116
  private selectedTaskId: string | undefined
113
117
  private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
114
118
  private listeners = new Set<() => void>()
@@ -157,6 +161,7 @@ export class BoardController {
157
161
  return {
158
162
  tasks: this.tasks,
159
163
  boardOpen: this.boardOpen,
164
+ archiveView: this.archiveView,
160
165
  selectedTaskId: this.selectedTaskId,
161
166
  executionOptions: this.executionOptions,
162
167
  }
@@ -190,6 +195,20 @@ export class BoardController {
190
195
  else this.openBoard()
191
196
  }
192
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
+
193
212
  openTask(id: string): void {
194
213
  if (this.tasks.some(task => task.id === id)) {
195
214
  this.selectedTaskId = id
@@ -239,6 +258,29 @@ export class BoardController {
239
258
  this.persistAndNotify()
240
259
  }
241
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
+
242
284
  // --- scheduling ---------------------------------------------------------------
243
285
 
244
286
  /**
@@ -269,6 +311,18 @@ export class BoardController {
269
311
  this.persistAndNotify()
270
312
  }
271
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
+
272
326
  /**
273
327
  * Jump to an execution's session transcript. Selecting the session changes
274
328
  * `current`, which closes the board (the conversation view takes over).
@@ -46,6 +46,9 @@ export interface PresetsExecutionFace {
46
46
  select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
47
47
  }
48
48
 
49
+ /** Settled result of one agent-preset switch, shared by concurrent waiters. */
50
+ type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
51
+
49
52
  /** The narrow workspaces face the service needs. */
50
53
  export interface WorkspacesExecutionFace {
51
54
  list: {
@@ -137,6 +140,16 @@ function isErrorTurnEnd(data: unknown): boolean {
137
140
  * every failure path is reported as a settled event.
138
141
  */
139
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
+
140
153
  /** @param env - the runtime faces (real or fake). */
141
154
  constructor(private readonly env: ExecutionEnvironment) {}
142
155
 
@@ -199,13 +212,18 @@ export class ExecutionService {
199
212
  settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
200
213
  return false
201
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.
202
219
  if (summary?.agentPreset === mode) return true
203
- if (this.env.presets === undefined) {
220
+ const presets = this.env.presets
221
+ if (presets === undefined) {
204
222
  settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
205
223
  return false
206
224
  }
207
225
  try {
208
- const result = await this.env.presets.select(sessionId, mode)
226
+ const result = await this.switchPreset(presets, sessionId, mode)
209
227
  if (!result.ok) {
210
228
  // A list race can leave the summary without the preset label even
211
229
  // though the blank session already runs it; the wire answers that
@@ -227,6 +245,27 @@ export class ExecutionService {
227
245
  return true
228
246
  }
229
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
+
230
269
  /**
231
270
  * Apply the task-pinned permission preset through the `/permission <id>`
232
271
  * slash command. No-op when the task pins none; fails the run when the
@@ -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
@@ -148,6 +148,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
148
148
  // fall back to the session default instead of dropping the row.
149
149
  task.workspaceId = normalizeTargetId(row.workspaceId)
150
150
  task.mode = normalizeTargetId(row.mode)
151
+ task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
151
152
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
152
153
  tasks.push(task)
153
154
  }
package/src/core/tasks.ts CHANGED
@@ -79,8 +79,17 @@ export interface TaskRecord {
79
79
  * `/permission <id>` slash command; absent leaves the session default.
80
80
  */
81
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
82
87
  }
83
88
 
89
+ /** Statuses a settled task may be archived from. */
90
+ export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
91
+
92
+
84
93
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
85
94
  export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
86
95
 
@@ -103,6 +112,12 @@ export interface NewTaskInput {
103
112
  mode?: string
104
113
  /** Permission preset applied to the execution session; absent = session default. */
105
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 }
106
121
  }
107
122
 
108
123
  /** The five kanban columns, in display order. */
@@ -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
  }