@linxin666/dsh-client-ui-task-board 0.1.17 → 0.1.19

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 (35) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +3 -3
  3. package/README.zh.md +3 -3
  4. package/lib/client.js +430 -134
  5. package/lib/client.js.map +1 -1
  6. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -0
  8. package/lib/types/client/locales.d.ts +7 -0
  9. package/lib/types/client/schedule-presets.d.ts +10 -0
  10. package/lib/types/client/settings-form.d.ts +8 -0
  11. package/lib/types/core/controller.d.ts +27 -0
  12. package/lib/types/core/execution.d.ts +16 -0
  13. package/lib/types/core/scheduler.d.ts +7 -0
  14. package/lib/types/core/tasks.d.ts +16 -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/package.json +1 -1
  18. package/src/client/PluginSettingsCard.tsx +50 -47
  19. package/src/client/TaskBoardSettingsCard.tsx +8 -0
  20. package/src/client/board/NewTaskModal.tsx +68 -0
  21. package/src/client/board/TaskBoard.tsx +56 -25
  22. package/src/client/board/TaskDetail.tsx +27 -8
  23. package/src/client/board.module.css +68 -1
  24. package/src/client/index.ts +16 -7
  25. package/src/client/locales.ts +15 -1
  26. package/src/client/schedule-presets.ts +13 -0
  27. package/src/client/settings-card.module.css +9 -0
  28. package/src/client/settings-form.ts +15 -1
  29. package/src/core/controller.ts +58 -0
  30. package/src/core/execution.ts +41 -2
  31. package/src/core/scheduler.ts +15 -0
  32. package/src/core/store.ts +1 -0
  33. package/src/core/tasks.ts +15 -0
  34. package/src/core/use-cases/task-archive.ts +48 -0
  35. package/src/core/use-cases/task-create.ts +12 -3
@@ -9,6 +9,7 @@ import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
11
11
  import { t, type TaskBoardKey } from '../locales.ts'
12
+ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
12
13
  import css from '../board.module.css'
13
14
  import { ConfirmDialog } from './ConfirmDialog.tsx'
14
15
  import { formatTime } from './TaskCard.tsx'
@@ -58,14 +59,6 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
58
59
  )
59
60
  }
60
61
 
61
- /** Common scheduled-run presets (cron → locale label). */
62
- const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
63
- { cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
64
- { cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
65
- { cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
66
- { cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
67
- ]
68
-
69
62
  /** The execution-target editor: workspace / mode / permission pickers. */
70
63
  function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
71
64
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
@@ -324,6 +317,31 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
324
317
  >
325
318
  {current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
326
319
  </button>
320
+ {current.archivedAt !== undefined ? (
321
+ <button
322
+ type="button"
323
+ className={css.primaryButton}
324
+ onClick={() => {
325
+ controller.restoreTask(current.id)
326
+ controller.closeTask()
327
+ }}
328
+ >
329
+ {t('detail.restore')}
330
+ </button>
331
+ ) : (
332
+ (current.status === 'done' || current.status === 'failed') && (
333
+ <button
334
+ type="button"
335
+ className={css.ghostButton}
336
+ onClick={() => {
337
+ controller.archiveTask(current.id)
338
+ controller.closeTask()
339
+ }}
340
+ >
341
+ {t('detail.archive')}
342
+ </button>
343
+ )
344
+ )}
327
345
  <button
328
346
  type="button"
329
347
  className={css.dangerButton}
@@ -333,6 +351,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
333
351
  </button>
334
352
  <span className={css.detailMeta}>
335
353
  {t('board.created')} {formatTime(current.createdAt)}
354
+ {current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
336
355
  </span>
337
356
  </footer>
338
357
  </div>
@@ -18,6 +18,8 @@
18
18
  inset: 0;
19
19
  display: none;
20
20
  z-index: 60;
21
+ container-name: task-board-view;
22
+ container-type: inline-size;
21
23
  /* Opaque backdrop: the conversation subtree stays mounted under the view.
22
24
  Even if the hide rule below were defeated by a stronger host inline
23
25
  style, this background keeps sticky code-block banners (host z-index 6)
@@ -103,6 +105,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
103
105
  .board {
104
106
  display: flex;
105
107
  flex-direction: column;
108
+ box-sizing: border-box;
106
109
  height: 100%;
107
110
  min-width: 0;
108
111
  min-height: 0;
@@ -128,6 +131,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
128
131
  white-space: nowrap;
129
132
  }
130
133
 
134
+ .backButton {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ gap: 4px;
138
+ }
139
+
131
140
  .search {
132
141
  flex: 0 1 260px;
133
142
  min-width: 120px;
@@ -148,10 +157,38 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
148
157
 
149
158
  .columns {
150
159
  display: grid;
151
- grid-template-columns: repeat(5, minmax(0, 1fr));
160
+ grid-auto-flow: column;
161
+ grid-auto-columns: minmax(220px, 1fr);
152
162
  gap: 12px;
153
163
  flex: 1;
154
164
  min-height: 0;
165
+ overflow-x: auto;
166
+ overflow-y: hidden;
167
+ overscroll-behavior-inline: contain;
168
+ padding-bottom: 6px;
169
+ scrollbar-color: var(--dsw-alias-border-l3) var(--dsw-alias-interactive-bg-hover);
170
+ scrollbar-width: thin;
171
+ }
172
+
173
+ .columns::-webkit-scrollbar {
174
+ height: 10px;
175
+ }
176
+
177
+ .columns::-webkit-scrollbar-track {
178
+ background: var(--dsw-alias-interactive-bg-hover);
179
+ border-radius: 999px;
180
+ }
181
+
182
+ .columns::-webkit-scrollbar-thumb {
183
+ background: var(--dsw-alias-border-l3);
184
+ background-clip: content-box;
185
+ border: 2px solid transparent;
186
+ border-radius: 999px;
187
+ }
188
+
189
+ .columns::-webkit-scrollbar-thumb:hover {
190
+ background: var(--dsw-alias-border-l4);
191
+ background-clip: content-box;
155
192
  }
156
193
 
157
194
  .column {
@@ -770,6 +807,36 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
770
807
  color: var(--dsw-alias-label-tertiary);
771
808
  }
772
809
 
810
+ /* --- constrained board containers ------------------------------------------ */
811
+
812
+ @container task-board-view (max-width: 720px) {
813
+ .board {
814
+ gap: 10px;
815
+ padding: 10px;
816
+ }
817
+
818
+ .columns {
819
+ gap: 10px;
820
+ }
821
+ }
822
+
823
+ @container task-board-view (max-width: 600px) {
824
+ .boardHeader {
825
+ flex-wrap: wrap;
826
+ gap: 8px;
827
+ }
828
+
829
+ .search {
830
+ flex: 1 1 calc(100% - 72px);
831
+ min-width: 0;
832
+ }
833
+
834
+ .boardHeader > button {
835
+ flex: 1 1 0;
836
+ min-width: max-content;
837
+ }
838
+ }
839
+
773
840
  /* -----------------------------------------------------------------------------
774
841
  * Polish pass: complete interaction states (hover / active / focus-visible /
775
842
  * disabled), unified 120ms motion, and reduced-motion fallback.
@@ -92,13 +92,19 @@ export function apply(ctx: ClientContext): void {
92
92
  const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
93
93
  const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
94
94
  const settingsCard = new TaskBoardSettingsCardController(settingsScope)
95
- ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
96
- name: 'web-ui.plugin.item',
97
- id: 'task-board',
98
- order: 110,
99
- locale: NS,
100
- inject: () => settingsCard.inject(),
101
- }, TaskBoardSettingsCard))
95
+ ctx.slots.inject('web-ui.plugin.item', () => {
96
+ const unregister = ctx.slots.register({
97
+ name: 'web-ui.plugin.item',
98
+ id: 'task-board',
99
+ order: 110,
100
+ locale: NS,
101
+ inject: () => settingsCard.inject(),
102
+ }, TaskBoardSettingsCard)
103
+ return () => {
104
+ settingsCard.dispose()
105
+ unregister()
106
+ }
107
+ })
102
108
 
103
109
  // The sidebar entry and board view mount once the settings scope settles;
104
110
  // while the scope is still loading, the composition default is unknown, so
@@ -178,6 +184,9 @@ export function apply(ctx: ClientContext): void {
178
184
  // not-yet-ready runtime; tab visibility recovery ticks immediately.
179
185
  const scheduler = new SchedulerService({
180
186
  tasks: () => controller.getSnapshot().tasks,
187
+ // Re-read the persisted ledger before every fire decision: a task
188
+ // deleted in another tab must never fire from a stale in-memory copy.
189
+ refresh: () => controller.reloadFromStore(),
181
190
  now: () => Date.now(),
182
191
  runTask: id => controller.runTask(id),
183
192
  applySchedule: (id, nextRunAt, lastTriggeredAt) =>
@@ -9,11 +9,15 @@
9
9
  export const zh = {
10
10
  'entry.label': '任务看板',
11
11
  'board.title': '任务看板',
12
- 'board.close': '返回对话',
12
+ 'board.close': '返回会话',
13
13
  'board.new': '新建任务',
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;
@@ -234,6 +234,9 @@ export class CardForm<T> {
234
234
  private readonly specs: Map<string, FieldSpec>
235
235
  private readonly staged = new Map<string, StagedEdit>()
236
236
  private readonly listeners = new Set<() => void>()
237
+ /** The scope subscription installed in the constructor; released by dispose(). */
238
+ private readonly disposeScope: () => void
239
+ private disposed = false
237
240
  private saving = false
238
241
  private failed = false
239
242
  private failedReason: string | undefined
@@ -244,7 +247,18 @@ export class CardForm<T> {
244
247
  specs: FieldSpec[],
245
248
  ) {
246
249
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
247
- scope.subscribe(() => { this.publish() })
250
+ this.disposeScope = scope.subscribe(() => { this.publish() })
251
+ }
252
+
253
+ /**
254
+ * Release the scope subscription and every bound store listener. The card
255
+ * must call this on teardown; later calls are no-ops.
256
+ */
257
+ dispose(): void {
258
+ if (this.disposed) return
259
+ this.disposed = true
260
+ this.disposeScope()
261
+ this.listeners.clear()
248
262
  }
249
263
 
250
264
  /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
@@ -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
@@ -86,6 +89,10 @@ export function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undef
86
89
  }
87
90
 
88
91
  function randomUuid(): string {
92
+ const randomUUID = globalThis.crypto?.randomUUID
93
+ if (randomUUID !== undefined) {
94
+ return randomUUID.call(globalThis.crypto!)
95
+ }
89
96
  const bytes = globalThis.crypto?.getRandomValues(new Uint8Array(16))
90
97
  if (bytes === undefined) {
91
98
  // Non-secure fallback (tests, odd environments).
@@ -109,6 +116,7 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
109
116
  export class BoardController {
110
117
  private tasks: TaskRecord[] = []
111
118
  private boardOpen = false
119
+ private archiveView = false
112
120
  private selectedTaskId: string | undefined
113
121
  private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
114
122
  private listeners = new Set<() => void>()
@@ -157,6 +165,7 @@ export class BoardController {
157
165
  return {
158
166
  tasks: this.tasks,
159
167
  boardOpen: this.boardOpen,
168
+ archiveView: this.archiveView,
160
169
  selectedTaskId: this.selectedTaskId,
161
170
  executionOptions: this.executionOptions,
162
171
  }
@@ -190,6 +199,20 @@ export class BoardController {
190
199
  else this.openBoard()
191
200
  }
192
201
 
202
+ /**
203
+ * Switch between the kanban columns and the archive view. Leaving the
204
+ * archive view with an archived task still selected closes the selection —
205
+ * the detail overlay must not linger over a task that is off-board.
206
+ */
207
+ toggleArchiveView(): void {
208
+ this.archiveView = !this.archiveView
209
+ if (!this.archiveView && this.selectedTaskId !== undefined) {
210
+ const selected = this.tasks.find(task => task.id === this.selectedTaskId)
211
+ if (selected?.archivedAt !== undefined) this.selectedTaskId = undefined
212
+ }
213
+ this.notify()
214
+ }
215
+
193
216
  openTask(id: string): void {
194
217
  if (this.tasks.some(task => task.id === id)) {
195
218
  this.selectedTaskId = id
@@ -239,6 +262,29 @@ export class BoardController {
239
262
  this.persistAndNotify()
240
263
  }
241
264
 
265
+ /**
266
+ * Archive a settled task (done/failed). Running or on-board-unsettled
267
+ * tasks are refused so the runner keeps exclusive ownership of their
268
+ * lifecycle.
269
+ * @returns true when applied.
270
+ */
271
+ archiveTask(id: string): boolean {
272
+ const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
273
+ if (!archived) return false
274
+ this.tasks = [...tasks]
275
+ this.persistAndNotify()
276
+ return true
277
+ }
278
+
279
+ /** Restore an archived task back onto the board (same status column). */
280
+ restoreTask(id: string): boolean {
281
+ const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
282
+ if (!archived) return false
283
+ this.tasks = [...tasks]
284
+ this.persistAndNotify()
285
+ return true
286
+ }
287
+
242
288
  // --- scheduling ---------------------------------------------------------------
243
289
 
244
290
  /**
@@ -269,6 +315,18 @@ export class BoardController {
269
315
  this.persistAndNotify()
270
316
  }
271
317
 
318
+ /**
319
+ * Reload the ledger from the persisted store without notifying subscribers.
320
+ * The scheduler calls this before every tick so a task deleted in another
321
+ * tab (or a stale in-memory copy) can never be fired from this tab: the
322
+ * fire decision and the subsequent roll-forward both run on the freshest
323
+ * persisted truth. Deliberately silent — same-origin external changes still
324
+ * reach subscribers through the storage-event subscription.
325
+ */
326
+ reloadFromStore(): void {
327
+ this.tasks = this.deps.store.load()
328
+ }
329
+
272
330
  /**
273
331
  * Jump to an execution's session transcript. Selecting the session changes
274
332
  * `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
  }