dsh-taskboard 0.4.5 → 0.5.1

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 (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module dsh-taskboard/host/scheduler
10
10
  */
11
- import { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'
11
+ import { newCommentId, nextCronTime, normalizeBody, parseCron, type TaskLedger } from '../shared/protocol.ts'
12
12
  import { DEFAULT_MAX_CONCURRENT, type ExecutionService } from './execution.ts'
13
13
  import type { TaskStore } from './store.ts'
14
14
 
@@ -25,44 +25,61 @@ export interface SchedulerDeps {
25
25
  now: () => number
26
26
  /** Max concurrently running executions (default 3; must match the execution service). */
27
27
  maxConcurrent?: number
28
- /** Timer face (injectable for tests). */
28
+ /** Timer face (injectable for tests). The timeout pair is optional so
29
+ * older injections keep working; gaps fall back to the globals. */
29
30
  timers?: {
30
31
  setInterval(fn: () => void, ms: number): unknown
31
32
  clearInterval(handle: unknown): void
33
+ setTimeout?(fn: () => void, ms: number): unknown
34
+ clearTimeout?(handle: unknown): void
32
35
  }
33
36
  }
34
37
 
38
+ type SchedulerTimers = NonNullable<SchedulerDeps['timers']>
39
+
40
+ const DEFAULT_TIMERS: Required<SchedulerTimers> = {
41
+ setInterval: (fn: () => void, ms: number): unknown => setInterval(fn, ms),
42
+ clearInterval: (handle: unknown): void => { clearInterval(handle as Parameters<typeof clearInterval>[0]) },
43
+ setTimeout: (fn: () => void, ms: number): unknown => setTimeout(fn, ms),
44
+ clearTimeout: (handle: unknown): void => { clearTimeout(handle as Parameters<typeof clearTimeout>[0]) },
45
+ }
46
+
35
47
  /**
36
48
  * The cron scheduler.
37
49
  */
38
50
  export class SchedulerService {
39
51
  private handle: unknown
40
- private catchup: ReturnType<typeof setTimeout> | undefined
52
+ private catchup: unknown
53
+ private timers: Required<SchedulerTimers> = DEFAULT_TIMERS
41
54
 
42
55
  /** @param deps - store + execution + clock. */
43
56
  constructor(private readonly deps: SchedulerDeps) {}
44
57
 
45
58
  /** Start ticking. */
46
59
  start(): void {
47
- const timers = this.deps.timers ?? {
48
- setInterval: (fn: () => void, ms: number) => setInterval(fn, ms),
49
- clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),
50
- }
51
- this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)
60
+ // Fill optional timer slots from the globals so a legacy injection that
61
+ // only carries the interval pair still works end to end.
62
+ this.timers = this.deps.timers === undefined ? DEFAULT_TIMERS : { ...DEFAULT_TIMERS, ...this.deps.timers }
63
+ // A tick rejection (disk error inside a mutation) must never surface as
64
+ // an unhandled rejection log it and keep the schedule alive.
65
+ this.handle = this.timers.setInterval(() => { void this.tick().catch(error => {
66
+ console.error('[dsh-taskboard] scheduler tick failed:', error)
67
+ }) }, TICK_MS)
52
68
  // Catch up promptly on host restart: run one tick soon after start. The
53
- // handle is cleared on dispose so a torn-down scheduler never fires.
54
- this.catchup = setTimeout(() => { void this.tick() }, 3_000)
69
+ // handles are cleared on dispose so a torn-down scheduler never fires.
70
+ this.catchup = this.timers.setTimeout(() => { void this.tick().catch(error => {
71
+ console.error('[dsh-taskboard] scheduler tick failed:', error)
72
+ }) }, 3_000)
55
73
  }
56
74
 
57
75
  /** Stop ticking. */
58
76
  dispose(): void {
59
77
  if (this.catchup !== undefined) {
60
- clearTimeout(this.catchup)
78
+ this.timers.clearTimeout(this.catchup)
61
79
  this.catchup = undefined
62
80
  }
63
81
  if (this.handle === undefined) return
64
- const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }
65
- timers.clearInterval(this.handle)
82
+ this.timers.clearInterval(this.handle)
66
83
  this.handle = undefined
67
84
  }
68
85
 
@@ -74,49 +91,58 @@ export class SchedulerService {
74
91
  await this.deps.store.load()
75
92
  const now = this.deps.now()
76
93
  const ledger: TaskLedger = this.deps.store.snapshot()
77
- const atCapacity = this.deps.execution.inFlight() >= (this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)
78
94
  for (const task of ledger.tasks) {
79
95
  if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
80
96
  if (task.execution.nextRunAt === undefined) continue
81
97
  if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
82
98
  if (task.execution.nextRunAt > now) continue
83
- // At the concurrency cap: leave nextRunAt in the past and retry next
84
- // tick advancing here would silently burn this window.
85
- if (atCapacity) continue
99
+ // At the concurrency cap (S4: checked FRESH per task runs register
100
+ // only after agent creation, so a once-per-tick snapshot under-counted
101
+ // the startup window): leave nextRunAt in the past and retry next tick
102
+ // — advancing here would silently burn this window.
103
+ if (this.deps.execution.inFlight() >= (this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)) continue
86
104
  const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
87
105
 
88
- // Advance the schedule FIRST (idempotent under re-ticks), then run
89
- // unless the window was missed entirely.
90
- await this.advance(task.id, now)
106
+ // Advance the schedule AND record the trigger in ONE mutation (S13:
107
+ // one revision bump, one broadcast, and the two writes can no longer
108
+ // straddle a status change), then run unless the window was missed.
109
+ await this.advanceAndMark(task.id, now, missed ? undefined : task.execution.nextRunAt)
91
110
  if (missed) continue
92
- const lastTriggeredAt = task.execution.nextRunAt
93
- await this.markTriggered(task.id, lastTriggeredAt)
94
111
  await this.deps.execution.run(task.id, 'scheduled').catch(error => {
95
112
  console.error('[dsh-taskboard] scheduled run failed:', error)
96
113
  })
97
114
  }
98
115
  }
99
116
 
100
- /** Recompute and persist the next run for one scheduled task. */
101
- private async advance(taskId: string, now: number): Promise<void> {
117
+ /**
118
+ * Recompute the next run and record the trigger instant for one scheduled
119
+ * task, in one serial-queue mutation. S12: a cron that can no longer match
120
+ * anything within the 4-year scan window (only reachable through a
121
+ * hand-edited ledger — every normal entry point validates) would otherwise
122
+ * leave nextRunAt in the past and spin a full ~2M-iteration scan every
123
+ * tick; it is cleared with a system comment instead of dying silently.
124
+ */
125
+ private async advanceAndMark(taskId: string, now: number, triggeredAt: number | undefined): Promise<void> {
102
126
  await this.deps.store.mutate('task-updated', (ledger) => {
103
127
  const task = ledger.tasks.find(t => t.id === taskId)
104
128
  if (task === undefined || task.execution.cron === undefined) return undefined
129
+ if (task.status === 'in_progress' || task.trashedAt !== undefined) return undefined
105
130
  const match = parseCron(task.execution.cron)
106
131
  const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
107
- if (next === undefined) return undefined
132
+ if (next === undefined) {
133
+ const deadCron = task.execution.cron
134
+ task.execution.cron = undefined
135
+ task.execution.nextRunAt = undefined
136
+ task.comments.push({
137
+ id: newCommentId(),
138
+ body: normalizeBody(`[系统] 定时表达式 ${deadCron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。`),
139
+ version: 1,
140
+ createdAt: now,
141
+ })
142
+ return [task]
143
+ }
108
144
  task.execution.nextRunAt = next
109
- return [task]
110
- })
111
- }
112
-
113
- /** Record the trigger instant on the task. */
114
- private async markTriggered(taskId: string, at: number | undefined): Promise<void> {
115
- if (at === undefined) return
116
- await this.deps.store.mutate('task-updated', (ledger) => {
117
- const task = ledger.tasks.find(t => t.id === taskId)
118
- if (task === undefined) return undefined
119
- task.execution.lastTriggeredAt = at
145
+ if (triggeredAt !== undefined) task.execution.lastTriggeredAt = triggeredAt
120
146
  return [task]
121
147
  })
122
148
  }
package/src/host/sdk.ts CHANGED
@@ -141,7 +141,18 @@ function validateValue(schema: RawSchema, value: unknown, path: string): string[
141
141
  }
142
142
  return violations
143
143
  }
144
- return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`]
144
+ if (!matchesScalarType(value, schema.type)) return [`${path} must be ${schema.type}`]
145
+ // T10: enum/const are compiled into the schema — validate them at runtime
146
+ // too, so the "pre-validates the same way" promise holds for every node.
147
+ const enumValues = schema.enum as unknown[] | undefined
148
+ if (enumValues !== undefined && !enumValues.some(v => v === value)) {
149
+ return [`${path} must be one of ${enumValues.map(String).join(', ')}`]
150
+ }
151
+ const constValue = (schema as { const?: unknown }).const
152
+ if (constValue !== undefined && constValue !== value) {
153
+ return [`${path} must be ${String(constValue)}`]
154
+ }
155
+ return []
145
156
  }
146
157
 
147
158
  /** Options shape we consume (a structural subset of the SDK's defineTool). */
package/src/host/store.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  *
7
7
  * @module dsh-taskboard/host/store
8
8
  */
9
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
9
+ import { mkdir, open, readFile, rename } from 'node:fs/promises'
10
10
  import { dirname, join } from 'node:path'
11
11
  import {
12
12
  LEDGER_SCHEMA_VERSION,
13
13
  emptyLedger,
14
+ isPlausibleTaskRecord,
14
15
  pruneExecutions,
15
16
  type TaskLedger,
16
17
  type TaskRecord,
@@ -23,7 +24,7 @@ export interface LedgerChange {
23
24
  /** The mutated tasks, if any (a comment purge may touch none). */
24
25
  tasks: readonly TaskRecord[]
25
26
  /** What kind of mutation this was (for SSE event naming later). */
26
- kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
27
+ kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
27
28
  }
28
29
 
29
30
  /** Options for {@link TaskStore}. */
@@ -56,7 +57,20 @@ export class TaskStore {
56
57
  const raw = await readFile(this.file, 'utf8')
57
58
  const parsed = JSON.parse(raw) as TaskLedger
58
59
  if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {
59
- const tasks = parsed.tasks as TaskRecord[]
60
+ // S11: trust no record wholesale — drop structurally broken entries
61
+ // (including R4's traversal-shaped ids from a hand-edited file) with
62
+ // a notice instead of letting them reach the path-building layers.
63
+ const plausible: TaskRecord[] = []
64
+ for (const entry of parsed.tasks as unknown[]) {
65
+ if (!isPlausibleTaskRecord(entry)) {
66
+ const rawId = (entry as { id?: unknown })?.id
67
+ const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)
68
+ console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)
69
+ continue
70
+ }
71
+ plausible.push(entry as TaskRecord)
72
+ }
73
+ const tasks = plausible
60
74
  // Migration from pre-claim-field ledgers: an agent-held in_progress
61
75
  // task carried its holder in updatedBy — backfill the explicit claim
62
76
  // fields so the hold survives user edits (updatedBy is audit-only).
@@ -131,7 +145,9 @@ export class TaskStore {
131
145
  const draft: TaskLedger = structuredClone(this.ledger)
132
146
  const changed = mutator(draft)
133
147
  if (changed === undefined) {
134
- return { ledger: this.ledger, changed: [] }
148
+ // S9 parity: even a no-op mutation hands out a frozen clone — never
149
+ // the live internal ledger.
150
+ return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }
135
151
  }
136
152
  // Retention cap: every committed mutation re-checks the touched tasks,
137
153
  // so execution history can never grow unbounded (SSE state payload).
@@ -146,11 +162,31 @@ export class TaskStore {
146
162
  fn(change)
147
163
  } catch { /* subscriber errors never abort the write */ }
148
164
  }
149
- return { ledger: draft, changed }
165
+ // S9: hand out frozen clones — the return value used to BE the new
166
+ // internal ledger; callers must never mutate internal state in place.
167
+ return {
168
+ ledger: deepFreeze(structuredClone(draft)),
169
+ changed: changed.map(t => deepFreeze(structuredClone(t))),
170
+ }
150
171
  }
151
172
  const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
152
173
  return result
153
174
  }
175
+
176
+ /**
177
+ * Run a read INSIDE the serial queue (R3): observes exactly the ledger
178
+ * state after all previously enqueued mutations — immune to the
179
+ * write-then-publish window around `mutate`'s persistence. Read-only: the
180
+ * callback receives a frozen deep clone and nothing is written.
181
+ */
182
+ async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {
183
+ const run = async (): Promise<T> => {
184
+ await this.load()
185
+ return fn(deepFreeze(structuredClone(this.ledger)))
186
+ }
187
+ const result = (this.queue = this.queue.then(run, run)) as Promise<T>
188
+ return result
189
+ }
154
190
  }
155
191
 
156
192
  /** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */
@@ -164,10 +200,20 @@ function deepFreeze<T>(value: T): T {
164
200
  return value
165
201
  }
166
202
 
167
- /** Atomic file persist: write temp, then rename over the target. */
203
+ /**
204
+ * Atomic file persist: write temp, fsync, then rename over the target (S10:
205
+ * without the sync, a power loss after rename can leave a zero-length file —
206
+ * the next load would quarantine the ledger and start empty).
207
+ */
168
208
  async function persistAtomic(file: string, contents: string): Promise<void> {
169
209
  await mkdir(dirname(file), { recursive: true })
170
210
  const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)
171
- await writeFile(temp, contents, 'utf8')
211
+ const fh = await open(temp, 'w')
212
+ try {
213
+ await fh.writeFile(contents, 'utf8')
214
+ await fh.sync()
215
+ } finally {
216
+ await fh.close()
217
+ }
172
218
  await rename(temp, file)
173
219
  }
@@ -93,13 +93,19 @@ export class TemplateStore {
93
93
  this.loaded = true
94
94
  }
95
95
 
96
- /** Atomic persist (temp + rename), same discipline as the ledger. */
96
+ /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
97
97
  private async persist(templates: TaskTemplate[]): Promise<void> {
98
- const { mkdir, writeFile, rename } = await import('node:fs/promises')
98
+ const { mkdir, open, rename } = await import('node:fs/promises')
99
99
  const { dirname, join } = await import('node:path')
100
100
  await mkdir(dirname(this.file), { recursive: true })
101
101
  const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)
102
- await writeFile(temp, JSON.stringify({ templates }, null, 2), 'utf8')
102
+ const fh = await open(temp, 'w')
103
+ try {
104
+ await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')
105
+ await fh.sync()
106
+ } finally {
107
+ await fh.close()
108
+ }
103
109
  await rename(temp, this.file)
104
110
  }
105
111
 
@@ -120,6 +126,9 @@ export class TemplateStore {
120
126
  if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')
121
127
  const now = Date.now()
122
128
  const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined
129
+ // T12: built-ins are factory content — editable only by delete + recreate
130
+ // (deleting stays allowed), never silently overwritten in place.
131
+ if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')
123
132
  const stored: TaskTemplate = existing !== undefined
124
133
  ? { ...existing, name, task: input.task, updatedAt: now }
125
134
  : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }