@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.0
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.i18n.yaml +2 -2
- package/README.md +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +589 -602
- package/lib/client.js.map +1 -1
- package/lib/index.js +2187 -6
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -2
- package/lib/types/core/tasks.d.ts +7 -5
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -0
- package/lib/types/index.d.ts +20 -10
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +22 -13
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +84 -59
- package/src/client/board/status-key.ts +11 -0
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- package/src/core/store.ts +5 -14
- package/src/core/tasks.ts +7 -7
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +58 -12
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -67
- package/src/core/scheduler.ts +0 -151
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, type SpawnSyncReturns } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { win32 } from 'node:path'
|
|
4
|
+
import type { PowerPhase, TaskBoardPowerSnapshot } from './protocol.ts'
|
|
5
|
+
|
|
6
|
+
const RETRY_DELAYS = [1_000, 2_000, 5_000, 10_000, 30_000] as const
|
|
7
|
+
const DARWIN_STABLE_MS = 30_000
|
|
8
|
+
|
|
9
|
+
const WINDOWS_HELPER = String.raw`
|
|
10
|
+
$source = @'
|
|
11
|
+
using System;
|
|
12
|
+
using System.Runtime.InteropServices;
|
|
13
|
+
public static class DshExecutionState {
|
|
14
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
15
|
+
public static extern uint SetThreadExecutionState(uint flags);
|
|
16
|
+
}
|
|
17
|
+
'@
|
|
18
|
+
Add-Type -TypeDefinition $source
|
|
19
|
+
$continuous = [Convert]::ToUInt32('80000000', 16)
|
|
20
|
+
$systemRequired = [uint32]0x00000001
|
|
21
|
+
try {
|
|
22
|
+
$result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)
|
|
23
|
+
if ($result -eq 0) { throw 'SetThreadExecutionState failed' }
|
|
24
|
+
[Console]::Out.WriteLine('READY')
|
|
25
|
+
[Console]::Out.Flush()
|
|
26
|
+
while ([Console]::In.ReadLine() -ne $null) { }
|
|
27
|
+
} finally {
|
|
28
|
+
[void][DshExecutionState]::SetThreadExecutionState($continuous)
|
|
29
|
+
}
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
const LINUX_HELPER = String.raw`
|
|
33
|
+
process.stdout.write('READY\n')
|
|
34
|
+
process.stdin.resume()
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit'] as const
|
|
38
|
+
|
|
39
|
+
export interface PowerReasons {
|
|
40
|
+
runningSessions: number
|
|
41
|
+
armedSchedules: number
|
|
42
|
+
sessionStateKnown: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SpawnOptions {
|
|
46
|
+
shell: false
|
|
47
|
+
windowsHide: boolean
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'] | ['ignore', 'ignore', 'ignore']
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SpawnLike = (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess
|
|
52
|
+
export type SpawnSyncLike = (
|
|
53
|
+
file: string,
|
|
54
|
+
args: readonly string[],
|
|
55
|
+
options: { stdio: 'ignore'; timeout: number; windowsHide: boolean },
|
|
56
|
+
) => Pick<SpawnSyncReturns<Buffer>, 'status' | 'error'>
|
|
57
|
+
|
|
58
|
+
export interface PowerInhibitorOptions {
|
|
59
|
+
platform?: NodeJS.Platform
|
|
60
|
+
pid?: number
|
|
61
|
+
env?: NodeJS.ProcessEnv
|
|
62
|
+
spawn?: SpawnLike
|
|
63
|
+
spawnSync?: SpawnSyncLike
|
|
64
|
+
exists?: typeof existsSync
|
|
65
|
+
execPath?: string
|
|
66
|
+
setTimeout?: typeof globalThis.setTimeout
|
|
67
|
+
clearTimeout?: typeof globalThis.clearTimeout
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class PowerInhibitor {
|
|
71
|
+
private readonly listeners = new Set<() => void>()
|
|
72
|
+
private enabled = false
|
|
73
|
+
private reasons: PowerReasons = { runningSessions: 0, armedSchedules: 0, sessionStateKnown: false }
|
|
74
|
+
private phase: PowerPhase = 'disabled'
|
|
75
|
+
private child: ChildProcess | undefined
|
|
76
|
+
private retry: ReturnType<typeof setTimeout> | undefined
|
|
77
|
+
private retryReset: ReturnType<typeof setTimeout> | undefined
|
|
78
|
+
private retryIndex = 0
|
|
79
|
+
private lastError: string | undefined
|
|
80
|
+
private stopping = false
|
|
81
|
+
private readonly platform: NodeJS.Platform
|
|
82
|
+
private readonly pid: number
|
|
83
|
+
private readonly env: NodeJS.ProcessEnv
|
|
84
|
+
private readonly spawn: SpawnLike
|
|
85
|
+
private readonly spawnSync: SpawnSyncLike
|
|
86
|
+
private readonly exists: typeof existsSync
|
|
87
|
+
private readonly execPath: string
|
|
88
|
+
private readonly timer: typeof globalThis.setTimeout
|
|
89
|
+
private readonly clearTimer: typeof globalThis.clearTimeout
|
|
90
|
+
private linuxProbe: { executable?: string; available: boolean } | undefined
|
|
91
|
+
|
|
92
|
+
constructor(options: PowerInhibitorOptions = {}) {
|
|
93
|
+
this.platform = options.platform ?? process.platform
|
|
94
|
+
this.pid = options.pid ?? process.pid
|
|
95
|
+
this.env = options.env ?? process.env
|
|
96
|
+
this.spawn = options.spawn ?? ((file, args, spawnOptions) => nodeSpawn(file, [...args], spawnOptions))
|
|
97
|
+
this.spawnSync = options.spawnSync ?? ((file, args, spawnOptions) => nodeSpawnSync(file, [...args], spawnOptions))
|
|
98
|
+
this.exists = options.exists ?? existsSync
|
|
99
|
+
this.execPath = options.execPath ?? process.execPath
|
|
100
|
+
this.timer = options.setTimeout ?? globalThis.setTimeout
|
|
101
|
+
this.clearTimer = options.clearTimeout ?? globalThis.clearTimeout
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setEnabled(enabled: boolean): void {
|
|
105
|
+
this.enabled = enabled
|
|
106
|
+
this.sync()
|
|
107
|
+
this.emit()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
updateReasons(reasons: PowerReasons): void {
|
|
111
|
+
this.reasons = reasons
|
|
112
|
+
this.sync()
|
|
113
|
+
this.emit()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
subscribe(listener: () => void): () => void {
|
|
117
|
+
this.listeners.add(listener)
|
|
118
|
+
return () => { this.listeners.delete(listener) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
snapshot(): TaskBoardPowerSnapshot {
|
|
122
|
+
return {
|
|
123
|
+
platform: this.platform,
|
|
124
|
+
phase: this.phase,
|
|
125
|
+
enabled: this.enabled,
|
|
126
|
+
...this.reasons,
|
|
127
|
+
...(this.lastError === undefined ? {} : { lastError: this.lastError }),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
dispose(): void {
|
|
132
|
+
this.enabled = false
|
|
133
|
+
this.release()
|
|
134
|
+
this.phase = 'disabled'
|
|
135
|
+
this.emit()
|
|
136
|
+
this.listeners.clear()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private desired(): boolean {
|
|
140
|
+
return this.enabled && (!this.reasons.sessionStateKnown || this.reasons.runningSessions > 0 || this.reasons.armedSchedules > 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private sync(): void {
|
|
144
|
+
if (!this.enabled) {
|
|
145
|
+
this.release()
|
|
146
|
+
this.phase = 'disabled'
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (this.platform !== 'darwin' && this.platform !== 'win32' && this.platform !== 'linux') {
|
|
150
|
+
this.release()
|
|
151
|
+
this.phase = 'unsupported'
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (this.platform === 'linux' && this.linuxSystemdInhibit() === undefined) {
|
|
155
|
+
this.release()
|
|
156
|
+
this.phase = 'unsupported'
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!this.desired()) {
|
|
160
|
+
this.release()
|
|
161
|
+
this.phase = 'idle'
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (this.child === undefined && this.retry === undefined) this.acquire()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private acquire(): void {
|
|
168
|
+
this.phase = 'acquiring'
|
|
169
|
+
this.emit()
|
|
170
|
+
this.stopping = false
|
|
171
|
+
try {
|
|
172
|
+
const child = this.spawnCommand()
|
|
173
|
+
this.child = child
|
|
174
|
+
let ready = false
|
|
175
|
+
let stderr = ''
|
|
176
|
+
if (this.platform === 'darwin') {
|
|
177
|
+
child.once('spawn', () => {
|
|
178
|
+
ready = true
|
|
179
|
+
this.markReady(child)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
183
|
+
if (!ready && chunk.toString('utf8').includes('READY')) {
|
|
184
|
+
ready = true
|
|
185
|
+
this.markReady(child)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
189
|
+
stderr = `${stderr}${chunk.toString('utf8')}`.slice(-2_000)
|
|
190
|
+
})
|
|
191
|
+
child.on('error', error => { this.fail(error, child) })
|
|
192
|
+
child.on('exit', (code, signal) => {
|
|
193
|
+
if (this.child !== child) return
|
|
194
|
+
this.child = undefined
|
|
195
|
+
if (this.stopping || !this.desired()) return
|
|
196
|
+
const detail = stderr.trim()
|
|
197
|
+
this.fail(new Error(`power helper exited (${String(code ?? signal ?? 'unknown')})${detail === '' ? '' : `: ${detail}`}`))
|
|
198
|
+
})
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.fail(error)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private markReady(child: ChildProcess): void {
|
|
205
|
+
if (this.child !== child || !this.desired()) return
|
|
206
|
+
this.phase = 'active'
|
|
207
|
+
if (this.platform === 'darwin') {
|
|
208
|
+
this.retryReset = this.timer(() => {
|
|
209
|
+
this.retryReset = undefined
|
|
210
|
+
if (this.child === child && this.desired()) this.retryIndex = 0
|
|
211
|
+
}, DARWIN_STABLE_MS)
|
|
212
|
+
} else {
|
|
213
|
+
this.retryIndex = 0
|
|
214
|
+
}
|
|
215
|
+
this.lastError = undefined
|
|
216
|
+
this.emit()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fail(error: unknown, source?: ChildProcess): void {
|
|
220
|
+
if (source !== undefined && this.child !== source) return
|
|
221
|
+
this.clearRetryReset()
|
|
222
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
223
|
+
this.phase = 'error'
|
|
224
|
+
this.emit()
|
|
225
|
+
const child = this.child
|
|
226
|
+
this.child = undefined
|
|
227
|
+
child?.stdin?.end()
|
|
228
|
+
child?.kill()
|
|
229
|
+
if (!this.desired() || this.retry !== undefined) return
|
|
230
|
+
const delay = RETRY_DELAYS[Math.min(this.retryIndex, RETRY_DELAYS.length - 1)]
|
|
231
|
+
this.retryIndex += 1
|
|
232
|
+
this.retry = this.timer(() => {
|
|
233
|
+
this.retry = undefined
|
|
234
|
+
if (this.desired()) this.acquire()
|
|
235
|
+
}, delay)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private release(): void {
|
|
239
|
+
this.clearRetryReset()
|
|
240
|
+
if (this.retry !== undefined) {
|
|
241
|
+
this.clearTimer(this.retry)
|
|
242
|
+
this.retry = undefined
|
|
243
|
+
}
|
|
244
|
+
this.lastError = undefined
|
|
245
|
+
const child = this.child
|
|
246
|
+
this.child = undefined
|
|
247
|
+
if (child === undefined) return
|
|
248
|
+
this.stopping = true
|
|
249
|
+
if (this.platform === 'win32' || this.platform === 'linux') {
|
|
250
|
+
child.stdin?.end()
|
|
251
|
+
const force = this.timer(() => { if (child.exitCode === null) child.kill() }, 1_000)
|
|
252
|
+
child.once('exit', () => { this.clearTimer(force) })
|
|
253
|
+
} else {
|
|
254
|
+
child.kill('SIGTERM')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private clearRetryReset(): void {
|
|
259
|
+
if (this.retryReset === undefined) return
|
|
260
|
+
this.clearTimer(this.retryReset)
|
|
261
|
+
this.retryReset = undefined
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private windowsPowerShell(): string {
|
|
265
|
+
const root = this.env.SystemRoot
|
|
266
|
+
if (root === undefined || root.trim() === '') throw new Error('SystemRoot is unavailable')
|
|
267
|
+
if (!win32.isAbsolute(root)) throw new Error('SystemRoot is not an absolute path')
|
|
268
|
+
return win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private linuxSystemdInhibit(): string | undefined {
|
|
272
|
+
if (this.linuxProbe !== undefined) return this.linuxProbe.available ? this.linuxProbe.executable : undefined
|
|
273
|
+
const executable = LINUX_INHIBIT_PATHS.find(path => this.exists(path))
|
|
274
|
+
if (executable === undefined) {
|
|
275
|
+
this.linuxProbe = { available: false }
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
const probe = this.spawnSync(executable, ['--list', '--no-pager'], {
|
|
279
|
+
stdio: 'ignore',
|
|
280
|
+
timeout: 2_000,
|
|
281
|
+
windowsHide: true,
|
|
282
|
+
})
|
|
283
|
+
this.linuxProbe = { executable, available: probe.status === 0 && probe.error === undefined }
|
|
284
|
+
return this.linuxProbe.available ? executable : undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private spawnCommand(): ChildProcess {
|
|
288
|
+
if (this.platform === 'darwin') {
|
|
289
|
+
return this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
|
|
290
|
+
shell: false,
|
|
291
|
+
windowsHide: false,
|
|
292
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
if (this.platform === 'linux') {
|
|
296
|
+
const executable = this.linuxSystemdInhibit()
|
|
297
|
+
if (executable === undefined) throw new Error('systemd-inhibit is unavailable')
|
|
298
|
+
return this.spawn(executable, [
|
|
299
|
+
'--what=idle',
|
|
300
|
+
'--who=DeepSeek Harness task board',
|
|
301
|
+
'--why=DSH sessions are running or schedules are armed',
|
|
302
|
+
'--mode=block',
|
|
303
|
+
'--',
|
|
304
|
+
this.execPath,
|
|
305
|
+
'-e',
|
|
306
|
+
LINUX_HELPER,
|
|
307
|
+
], { shell: false, windowsHide: false, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
308
|
+
}
|
|
309
|
+
return this.spawn(this.windowsPowerShell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
|
|
310
|
+
shell: false,
|
|
311
|
+
windowsHide: true,
|
|
312
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private emit(): void {
|
|
317
|
+
for (const listener of [...this.listeners]) listener()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export { LINUX_HELPER, LINUX_INHIBIT_PATHS, WINDOWS_HELPER }
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
|
|
2
|
+
import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
3
|
+
import { parseLedger } from './core/store.ts'
|
|
4
|
+
|
|
5
|
+
export const TASK_BOARD_SCHEMA_VERSION = 2 as const
|
|
6
|
+
export const TASK_BOARD_API_PREFIX = '/api/task-board'
|
|
7
|
+
|
|
8
|
+
export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
|
|
9
|
+
|
|
10
|
+
export interface TaskBoardPowerSnapshot {
|
|
11
|
+
platform: string
|
|
12
|
+
phase: PowerPhase
|
|
13
|
+
enabled: boolean
|
|
14
|
+
runningSessions: number
|
|
15
|
+
armedSchedules: number
|
|
16
|
+
sessionStateKnown: boolean
|
|
17
|
+
lastError?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskBoardSchedulerSnapshot {
|
|
21
|
+
timeZone: string
|
|
22
|
+
/** Opaque identity of the current Host ledger generation. */
|
|
23
|
+
ledgerId?: string
|
|
24
|
+
lastTickAt?: number
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TaskBoardSnapshot {
|
|
29
|
+
schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
|
|
30
|
+
revision: number
|
|
31
|
+
tasks: TaskRecord[]
|
|
32
|
+
scheduler: TaskBoardSchedulerSnapshot
|
|
33
|
+
power: TaskBoardPowerSnapshot
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TaskBoardAction =
|
|
37
|
+
| { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
|
|
38
|
+
| { kind: 'create'; id: string; input: NewTaskInput }
|
|
39
|
+
| { kind: 'update'; taskId: string; patch: TaskUpdatePatch }
|
|
40
|
+
| { kind: 'delete'; taskId: string }
|
|
41
|
+
| { kind: 'move'; taskId: string; status: TaskStatus }
|
|
42
|
+
| { kind: 'archive'; taskId: string }
|
|
43
|
+
| { kind: 'restore'; taskId: string }
|
|
44
|
+
| { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string } }
|
|
45
|
+
| { kind: 'run'; taskId: string }
|
|
46
|
+
| { kind: 'rerun'; taskId: string }
|
|
47
|
+
|
|
48
|
+
export interface TaskBoardActionEnvelope {
|
|
49
|
+
requestId: string
|
|
50
|
+
action: TaskBoardAction
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
55
|
+
? value as Record<string, unknown>
|
|
56
|
+
: undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
60
|
+
return Object.keys(value).every(key => allowed.includes(key))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function optionalString(value: unknown): boolean {
|
|
64
|
+
return value === undefined || typeof value === 'string'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FORBIDDEN_IMPORT_FIELDS = new Set(['args', 'command', 'executable', 'powershell', 'shell'])
|
|
68
|
+
|
|
69
|
+
function hasForbiddenImportField(value: unknown): boolean {
|
|
70
|
+
if (Array.isArray(value)) return value.some(hasForbiddenImportField)
|
|
71
|
+
const row = record(value)
|
|
72
|
+
if (row === undefined) return false
|
|
73
|
+
return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenImportField(nested))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function optionalFiniteNumber(value: unknown): boolean {
|
|
77
|
+
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
81
|
+
if (value.schedule !== undefined) {
|
|
82
|
+
const schedule = record(value.schedule)
|
|
83
|
+
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
84
|
+
if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
|
|
85
|
+
}
|
|
86
|
+
if (value.executions !== undefined) {
|
|
87
|
+
if (!Array.isArray(value.executions)) return false
|
|
88
|
+
for (const item of value.executions) {
|
|
89
|
+
const execution = record(item)
|
|
90
|
+
if (execution === undefined || typeof execution.id !== 'string' || !optionalString(execution.sessionId)) return false
|
|
91
|
+
if (typeof execution.startedAt !== 'number' || !Number.isFinite(execution.startedAt)) return false
|
|
92
|
+
if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false
|
|
93
|
+
if (execution.result !== undefined && !['succeeded', 'failed', 'cancelled'].includes(String(execution.result))) return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function importedTask(value: unknown): TaskRecord | undefined {
|
|
100
|
+
const input = record(value)
|
|
101
|
+
if (input === undefined || hasForbiddenImportField(input) || !validImportedKnownFields(input)) return undefined
|
|
102
|
+
const task = parseLedger(JSON.stringify([value]))[0]
|
|
103
|
+
if (task === undefined) return undefined
|
|
104
|
+
return {
|
|
105
|
+
id: task.id,
|
|
106
|
+
title: task.title,
|
|
107
|
+
description: task.description,
|
|
108
|
+
prompt: task.prompt,
|
|
109
|
+
status: task.status,
|
|
110
|
+
createdAt: task.createdAt,
|
|
111
|
+
updatedAt: task.updatedAt,
|
|
112
|
+
executions: task.executions.map(execution => ({
|
|
113
|
+
id: execution.id,
|
|
114
|
+
sessionId: execution.sessionId,
|
|
115
|
+
startedAt: execution.startedAt,
|
|
116
|
+
endedAt: execution.endedAt,
|
|
117
|
+
result: execution.result,
|
|
118
|
+
error: execution.error,
|
|
119
|
+
})),
|
|
120
|
+
...(task.schedule === undefined ? {} : {
|
|
121
|
+
schedule: {
|
|
122
|
+
enabled: task.schedule.enabled,
|
|
123
|
+
cron: task.schedule.cron,
|
|
124
|
+
nextRunAt: task.schedule.nextRunAt,
|
|
125
|
+
lastTriggeredAt: task.schedule.lastTriggeredAt,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
|
|
129
|
+
...(task.mode === undefined ? {} : { mode: task.mode }),
|
|
130
|
+
...(task.permission === undefined ? {} : { permission: task.permission }),
|
|
131
|
+
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createInput(value: unknown): value is NewTaskInput {
|
|
136
|
+
const input = record(value)
|
|
137
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule'])) return false
|
|
138
|
+
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
139
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false
|
|
140
|
+
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
141
|
+
if (input.schedule !== undefined) {
|
|
142
|
+
const schedule = record(input.schedule)
|
|
143
|
+
if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
|
|
144
|
+
if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function updatePatch(value: unknown): boolean {
|
|
150
|
+
const patch = record(value)
|
|
151
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission'])) return false
|
|
152
|
+
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode'] as const) {
|
|
153
|
+
if (!optionalString(patch[key])) return false
|
|
154
|
+
}
|
|
155
|
+
return patch.permission === undefined || isTaskPermission(patch.permission)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function schedulePatch(value: unknown): boolean {
|
|
159
|
+
const patch = record(value)
|
|
160
|
+
return patch !== undefined
|
|
161
|
+
&& exactKeys(patch, ['enabled', 'cron'])
|
|
162
|
+
&& (patch.enabled === undefined || typeof patch.enabled === 'boolean')
|
|
163
|
+
&& (patch.cron === undefined || typeof patch.cron === 'string')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
|
|
167
|
+
const envelope = record(value)
|
|
168
|
+
if (envelope === undefined || !exactKeys(envelope, ['requestId', 'action'])) return undefined
|
|
169
|
+
if (typeof envelope.requestId !== 'string' || envelope.requestId.trim() === '' || envelope.requestId.length > 256) return undefined
|
|
170
|
+
const action = record(envelope.action)
|
|
171
|
+
if (action === undefined || typeof action.kind !== 'string') return undefined
|
|
172
|
+
const taskId = typeof action.taskId === 'string' && action.taskId !== '' ? action.taskId : undefined
|
|
173
|
+
switch (action.kind) {
|
|
174
|
+
case 'import':
|
|
175
|
+
if (!exactKeys(action, ['kind', 'sourceId', 'tasks'])) return undefined
|
|
176
|
+
if (typeof action.sourceId !== 'string' || action.sourceId === '' || !Array.isArray(action.tasks)) return undefined
|
|
177
|
+
{
|
|
178
|
+
const tasks = action.tasks.map(importedTask)
|
|
179
|
+
return tasks.every((task): task is TaskRecord => task !== undefined)
|
|
180
|
+
? { requestId: envelope.requestId, action: { kind: 'import', sourceId: action.sourceId, tasks } }
|
|
181
|
+
: undefined
|
|
182
|
+
}
|
|
183
|
+
case 'create':
|
|
184
|
+
if (!exactKeys(action, ['kind', 'id', 'input'])) return undefined
|
|
185
|
+
return typeof action.id === 'string' && action.id !== '' && createInput(action.input)
|
|
186
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'create' }> }
|
|
187
|
+
: undefined
|
|
188
|
+
case 'update':
|
|
189
|
+
if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
|
|
190
|
+
return taskId !== undefined && updatePatch(action.patch)
|
|
191
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'update' }> }
|
|
192
|
+
: undefined
|
|
193
|
+
case 'set-schedule':
|
|
194
|
+
if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
|
|
195
|
+
return taskId !== undefined && schedulePatch(action.patch)
|
|
196
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'set-schedule' }> }
|
|
197
|
+
: undefined
|
|
198
|
+
case 'move':
|
|
199
|
+
if (!exactKeys(action, ['kind', 'taskId', 'status'])) return undefined
|
|
200
|
+
return taskId !== undefined && isTaskStatus(action.status)
|
|
201
|
+
? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'move' }> }
|
|
202
|
+
: undefined
|
|
203
|
+
case 'delete':
|
|
204
|
+
case 'archive':
|
|
205
|
+
case 'restore':
|
|
206
|
+
case 'run':
|
|
207
|
+
case 'rerun':
|
|
208
|
+
if (!exactKeys(action, ['kind', 'taskId'])) return undefined
|
|
209
|
+
return taskId === undefined ? undefined : { requestId: envelope.requestId, action: action as TaskBoardAction }
|
|
210
|
+
default:
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { TaskRecord } from './tasks.ts';
|
|
2
|
-
/** Everything the scheduler needs from its host (the board controller). */
|
|
3
|
-
export interface SchedulerDeps {
|
|
4
|
-
/** Read the current task ledger (the controller snapshot). */
|
|
5
|
-
tasks(): readonly TaskRecord[];
|
|
6
|
-
/**
|
|
7
|
-
* Re-sync the task ledger from its persisted source before the tick decides
|
|
8
|
-
* what is due. The wiring passes the controller's store reload so a task
|
|
9
|
-
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
10
|
-
* Optional: absent in pure in-memory harnesses.
|
|
11
|
-
*/
|
|
12
|
-
refresh?: () => void;
|
|
13
|
-
/** Clock; defaults to Date.now in the controller wiring. */
|
|
14
|
-
now(): number;
|
|
15
|
-
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
16
|
-
* true when the run was accepted, false when rejected (e.g. the task is
|
|
17
|
-
* already running). */
|
|
18
|
-
runTask(id: string): Promise<boolean>;
|
|
19
|
-
/** Persist a rolled-forward schedule (next due + this trigger instant). */
|
|
20
|
-
applySchedule(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void;
|
|
21
|
-
/** Tick cadence; defaults to 60_000 ms. */
|
|
22
|
-
tickMs?: number;
|
|
23
|
-
/**
|
|
24
|
-
* Gate: while false the tick no-ops (e.g. the session list baseline has not
|
|
25
|
-
* arrived on page load, so executions would fail). Defaults to always ready.
|
|
26
|
-
*/
|
|
27
|
-
ready?: () => boolean;
|
|
28
|
-
/** Environment listeners for tab-visibility recovery (browser only). */
|
|
29
|
-
environment?: {
|
|
30
|
-
addEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
31
|
-
removeEventListener(type: 'visibilitychange', listener: () => void): void;
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* The schedule heartbeat (see module doc). `tick` is public so tests and
|
|
36
|
-
* callers can drive a check without waiting for the interval.
|
|
37
|
-
*/
|
|
38
|
-
export declare class SchedulerService {
|
|
39
|
-
private readonly deps;
|
|
40
|
-
private timer;
|
|
41
|
-
private environmentListener;
|
|
42
|
-
private disposed;
|
|
43
|
-
private started;
|
|
44
|
-
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
45
|
-
constructor(deps: SchedulerDeps);
|
|
46
|
-
/**
|
|
47
|
-
* Start ticking: one immediate check (catch-up after reload) + the interval.
|
|
48
|
-
* Single-instance: a second start while already armed is a no-op, so a
|
|
49
|
-
* re-entrant mount can never stack a duplicate timer.
|
|
50
|
-
*/
|
|
51
|
-
start(): void;
|
|
52
|
-
/**
|
|
53
|
-
* Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
|
|
54
|
-
* callers that treat the scheduler as a controlled ticker; `dispose` is an
|
|
55
|
-
* alias.
|
|
56
|
-
*/
|
|
57
|
-
stop(): void;
|
|
58
|
-
/** Stop ticking and drop listeners (idempotent). */
|
|
59
|
-
dispose(): void;
|
|
60
|
-
/**
|
|
61
|
-
* Check every enabled schedule and trigger the due ones. Idempotent per
|
|
62
|
-
* task per tick: the schedule is rolled forward only after runTask accepts
|
|
63
|
-
* the run, so a rejected run keeps its due slot and is retried on the next
|
|
64
|
-
* tick instead of being silently dropped.
|
|
65
|
-
*/
|
|
66
|
-
tick(): Promise<void>;
|
|
67
|
-
}
|