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.
- package/README.md +24 -1
- package/lib/client.js +434 -193
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +210 -112
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +128 -97
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +48 -5
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +9 -8
- package/src/client/api.ts +26 -8
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/SettingsModal.tsx +84 -0
- package/src/client/board/TaskBoard.tsx +47 -40
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +39 -31
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/controller.ts +86 -34
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +5 -1
- package/src/client/styles.ts +4 -0
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +263 -128
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +187 -126
- package/src/index.ts +10 -1
- package/src/shared/api.ts +11 -2
- package/src/shared/protocol.ts +83 -6
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/src/host/scheduler.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
54
|
-
this.catchup = setTimeout(() => { void this.tick()
|
|
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
|
-
|
|
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:
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
|
89
|
-
//
|
|
90
|
-
|
|
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
|
-
/**
|
|
101
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
}
|
package/src/host/templates.ts
CHANGED
|
@@ -93,13 +93,19 @@ export class TemplateStore {
|
|
|
93
93
|
this.loaded = true
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** Atomic persist (temp + rename
|
|
96
|
+
/** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
|
|
97
97
|
private async persist(templates: TaskTemplate[]): Promise<void> {
|
|
98
|
-
const { mkdir,
|
|
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
|
|
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 }
|