@linxin666/dsh-client-ui-task-board 0.2.9 → 0.3.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.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +43 -174
- package/lib/client.js.map +1 -1
- package/lib/index.js +56 -28
- package/lib/types/core/controller.d.ts +10 -31
- package/lib/types/core/tasks.d.ts +17 -1
- package/lib/types/http.d.ts +42 -0
- package/package.json +1 -1
- package/src/client/board/TaskBoard.tsx +2 -0
- package/src/client/board.module.css +185 -13
- package/src/client/index.ts +21 -11
- package/src/client/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +16 -152
- package/src/core/tasks.ts +31 -2
- package/src/host-ledger.ts +4 -3
- package/src/host-routes.ts +10 -14
- package/src/host-service.ts +0 -1
- package/src/http.ts +105 -0
- package/lib/types/core/execution.d.ts +0 -197
- package/src/core/execution.ts +0 -425
package/src/core/controller.ts
CHANGED
|
@@ -2,22 +2,17 @@
|
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
4
|
* In production it projects the Host ledger and submits confirmed actions;
|
|
5
|
-
* the legacy store
|
|
6
|
-
*
|
|
5
|
+
* the legacy store seam remains for v1 migration tests. It also closes the
|
|
6
|
+
* board whenever the user navigates to a session.
|
|
7
7
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
8
8
|
* unit-testable with fakes.
|
|
9
9
|
*
|
|
10
10
|
* The per use-case domain transitions (create/update/delete/schedule) live in
|
|
11
11
|
* dedicated modules under core/use-cases and are applied here; the controller
|
|
12
|
-
* owns only the orchestration seam (state, persistence, notify,
|
|
13
|
-
* navigation, reconciliation).
|
|
12
|
+
* owns only the orchestration seam (state, persistence, notify, navigation).
|
|
14
13
|
*/
|
|
15
|
-
import type { ExecutionEvent, ExecutionService } from './execution.ts'
|
|
16
14
|
import type { TaskStore } from './store.ts'
|
|
17
|
-
import {
|
|
18
|
-
settleExecution, startExecution, withStatus,
|
|
19
|
-
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
20
|
-
} from './tasks.ts'
|
|
15
|
+
import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
|
|
21
16
|
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
22
17
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
@@ -45,15 +40,11 @@ export interface SessionsControllerFace {
|
|
|
45
40
|
/** Controller dependencies (all swappable in tests). */
|
|
46
41
|
export interface ControllerDeps {
|
|
47
42
|
store: TaskStore
|
|
48
|
-
/** Legacy browser execution seam used only by isolated v1 tests. */
|
|
49
|
-
exec?: ExecutionService
|
|
50
43
|
sessions: SessionsControllerFace
|
|
51
44
|
/** Clock; defaults to Date.now. */
|
|
52
45
|
now?: () => number
|
|
53
46
|
/** Id minting; defaults to a random-uuid. */
|
|
54
47
|
uuid?: () => string
|
|
55
|
-
/** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
|
|
56
|
-
reconcileDebounceMs?: number
|
|
57
48
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
58
49
|
transport?: TaskBoardTransport
|
|
59
50
|
}
|
|
@@ -147,7 +138,7 @@ export class BoardController {
|
|
|
147
138
|
private remoteSubscribed = false
|
|
148
139
|
private remoteInitialization: Promise<boolean> | undefined
|
|
149
140
|
|
|
150
|
-
/** @param deps - store
|
|
141
|
+
/** @param deps - store and the sessions navigation face. */
|
|
151
142
|
constructor(private readonly deps: ControllerDeps) {
|
|
152
143
|
this.now = deps.now ?? (() => Date.now())
|
|
153
144
|
this.uuid = deps.uuid ?? randomUuid
|
|
@@ -158,11 +149,7 @@ export class BoardController {
|
|
|
158
149
|
/** Load the persisted ledger and start the navigation/status subscriptions. */
|
|
159
150
|
start(): void {
|
|
160
151
|
this.tasks = this.deps.store.load()
|
|
161
|
-
if (this.deps.transport !== undefined)
|
|
162
|
-
void this.initializeRemote()
|
|
163
|
-
} else {
|
|
164
|
-
void this.reconcileRunningTasks()
|
|
165
|
-
}
|
|
152
|
+
if (this.deps.transport !== undefined) void this.initializeRemote()
|
|
166
153
|
// A sibling tab may have edited or deleted the ledger (same origin,
|
|
167
154
|
// storage events). Reload on external change so a task deleted in
|
|
168
155
|
// another tab stops firing here — and is never written back by this
|
|
@@ -182,8 +169,6 @@ export class BoardController {
|
|
|
182
169
|
dispose(): void {
|
|
183
170
|
for (const dispose of this.disposers.splice(0)) dispose()
|
|
184
171
|
this.listeners.clear()
|
|
185
|
-
if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
|
|
186
|
-
this.reconcileTimer = undefined
|
|
187
172
|
}
|
|
188
173
|
|
|
189
174
|
// --- snapshot / subscription ------------------------------------------------
|
|
@@ -413,68 +398,30 @@ export class BoardController {
|
|
|
413
398
|
// --- execution ---------------------------------------------------------------
|
|
414
399
|
|
|
415
400
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* already running is ignored
|
|
401
|
+
* Request a Host execution for a task: the Host ledger owns the running
|
|
402
|
+
* transition, the execution record, and the settlement. A second call
|
|
403
|
+
* while the task is already running is ignored; without a Host transport
|
|
404
|
+
* the run is refused (returns false).
|
|
419
405
|
*/
|
|
420
406
|
async runTask(id: string): Promise<boolean> {
|
|
421
407
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
422
408
|
if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
|
|
423
|
-
if (this.deps.transport
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
const { task: next, execution } = startExecution(task, this.now(), this.uuid())
|
|
427
|
-
this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
|
|
428
|
-
this.persistAndNotify()
|
|
429
|
-
// This page owns the settlement of its own launches: the live watch
|
|
430
|
-
// (ExecutionService.run) settles on the turn boundary, and list
|
|
431
|
-
// reconciliation must not pre-empt it with a session that has not
|
|
432
|
-
// started a turn yet (its list row is idle, not completed).
|
|
433
|
-
this.activeExecutionIds.add(execution.id)
|
|
434
|
-
if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
|
|
435
|
-
await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
|
|
436
|
-
return true
|
|
409
|
+
if (this.deps.transport === undefined) return false
|
|
410
|
+
return await this.commitRemote({ kind: 'run', taskId: id }, id)
|
|
437
411
|
}
|
|
438
412
|
|
|
439
|
-
/** Re-run a settled task
|
|
413
|
+
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
440
414
|
async rerunTask(id: string): Promise<void> {
|
|
441
415
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
442
416
|
if (task === undefined || task.archivedAt !== undefined) return
|
|
443
|
-
if (this.deps.transport
|
|
444
|
-
|
|
445
|
-
return
|
|
446
|
-
}
|
|
447
|
-
if (task.status !== 'running') {
|
|
448
|
-
this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
|
|
449
|
-
this.persistAndNotify()
|
|
450
|
-
}
|
|
451
|
-
await this.runTask(id)
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
private handleExecutionEvent(event: ExecutionEvent): void {
|
|
455
|
-
if (event.kind === 'started') {
|
|
456
|
-
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
457
|
-
? attachSessionId(task, event.executionId, event.sessionId, this.now())
|
|
458
|
-
: task)
|
|
459
|
-
this.persistAndNotify()
|
|
460
|
-
return
|
|
461
|
-
}
|
|
462
|
-
this.activeExecutionIds.delete(event.executionId)
|
|
463
|
-
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
464
|
-
? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
465
|
-
: task)
|
|
466
|
-
this.persistAndNotify()
|
|
417
|
+
if (this.deps.transport === undefined) return
|
|
418
|
+
await this.commitRemote({ kind: 'rerun', taskId: id }, id)
|
|
467
419
|
}
|
|
468
420
|
|
|
469
421
|
// --- internals ---------------------------------------------------------------
|
|
470
422
|
|
|
471
|
-
/**
|
|
423
|
+
/** Close the board when the user navigates to another session. */
|
|
472
424
|
private onSessionsChanged(): void {
|
|
473
|
-
// Background/leftover executions settle through the session list (their
|
|
474
|
-
// conversation snapshots stay cold until opened). Coalesce the burst of
|
|
475
|
-
// list notifications into one reconcile pass instead of fanning out a
|
|
476
|
-
// history read per notification; see scheduleReconcile.
|
|
477
|
-
if (this.deps.transport === undefined) this.scheduleReconcile()
|
|
478
425
|
if (!this.boardOpen) return
|
|
479
426
|
const current = currentOf(this.deps.sessions)
|
|
480
427
|
if (current !== this.lastCurrent) this.closeBoard()
|
|
@@ -483,74 +430,6 @@ export class BoardController {
|
|
|
483
430
|
|
|
484
431
|
private lastCurrent: string | undefined = undefined
|
|
485
432
|
|
|
486
|
-
/** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
|
|
487
|
-
private readonly activeExecutionIds = new Set<string>()
|
|
488
|
-
|
|
489
|
-
/** Debounce timer for {@link reconcileRunningTasks}. */
|
|
490
|
-
private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
|
|
491
|
-
|
|
492
|
-
/** Whether a reconcile pass is underway (single-flight guard). */
|
|
493
|
-
private reconcileInFlight = false
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Debounce + single-flight trigger for the running-task reconciliation.
|
|
497
|
-
* Session-list notifications arrive in bursts (one per session status
|
|
498
|
-
* change); both guards together keep a burst from reading the history API
|
|
499
|
-
* once per running task.
|
|
500
|
-
*/
|
|
501
|
-
private scheduleReconcile(): void {
|
|
502
|
-
if (this.reconcileTimer !== undefined) return
|
|
503
|
-
this.reconcileTimer = setTimeout(() => {
|
|
504
|
-
this.reconcileTimer = undefined
|
|
505
|
-
void this.reconcileRunningTasks()
|
|
506
|
-
}, this.deps.reconcileDebounceMs ?? 350)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Settle tasks left 'running' whose sessions already finished. */
|
|
510
|
-
private async reconcileRunningTasks(): Promise<void> {
|
|
511
|
-
if (this.deps.exec === undefined) return
|
|
512
|
-
if (this.reconcileInFlight) {
|
|
513
|
-
// A session change arrived while a pass was in flight. The in-flight
|
|
514
|
-
// pass already captured the task list it iterates and will not revisit
|
|
515
|
-
// this task, so dropping the notification would leave it stuck
|
|
516
|
-
// 'running'. Re-arm the debounce so the change is reconciled once the
|
|
517
|
-
// current pass settles.
|
|
518
|
-
this.scheduleReconcile()
|
|
519
|
-
return
|
|
520
|
-
}
|
|
521
|
-
this.reconcileInFlight = true
|
|
522
|
-
try {
|
|
523
|
-
type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
|
|
524
|
-
const events: Array<{ taskId: string; event: Settled }> = []
|
|
525
|
-
for (const task of this.tasks) {
|
|
526
|
-
if (task.status !== 'running') continue
|
|
527
|
-
const execution = task.executions[task.executions.length - 1]
|
|
528
|
-
// Runs launched on this page settle through their live watch (turn
|
|
529
|
-
// boundary); reconciliation exists for background/leftover runs.
|
|
530
|
-
if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
|
|
531
|
-
const event = await this.deps.exec.reconcile(task)
|
|
532
|
-
if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
|
|
533
|
-
}
|
|
534
|
-
if (events.length === 0) return
|
|
535
|
-
let changed = false
|
|
536
|
-
for (const { taskId, event } of events) {
|
|
537
|
-
// The reconcile call above awaited: a sibling tab may have rewritten
|
|
538
|
-
// the ledger (storage event reload) meanwhile. Re-read the freshest
|
|
539
|
-
// record now so the stale task captured before the await can never
|
|
540
|
-
// overwrite fields the sibling wrote.
|
|
541
|
-
const task = this.tasks.find(candidate => candidate.id === taskId)
|
|
542
|
-
if (task === undefined) continue
|
|
543
|
-
const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
544
|
-
if (next === task) continue
|
|
545
|
-
this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
|
|
546
|
-
changed = true
|
|
547
|
-
}
|
|
548
|
-
if (changed) this.persistAndNotify()
|
|
549
|
-
} finally {
|
|
550
|
-
this.reconcileInFlight = false
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
433
|
private persistAndNotify(): void {
|
|
555
434
|
if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
|
|
556
435
|
this.notify()
|
|
@@ -676,18 +555,3 @@ export class BoardController {
|
|
|
676
555
|
for (const fn of [...this.listeners]) fn()
|
|
677
556
|
}
|
|
678
557
|
}
|
|
679
|
-
|
|
680
|
-
/** Record which session ran an execution (once the execution service reports it). */
|
|
681
|
-
function attachSessionId(
|
|
682
|
-
task: TaskRecord,
|
|
683
|
-
executionId: string,
|
|
684
|
-
sessionId: string,
|
|
685
|
-
now: number,
|
|
686
|
-
): TaskRecord {
|
|
687
|
-
return {
|
|
688
|
-
...task,
|
|
689
|
-
updatedAt: now,
|
|
690
|
-
executions: task.executions.map(execution =>
|
|
691
|
-
execution.id === executionId ? { ...execution, sessionId } : execution),
|
|
692
|
-
}
|
|
693
|
-
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -28,6 +28,27 @@ export interface ExecutionRecord {
|
|
|
28
28
|
error: string | undefined
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of execution records retained per task. Older settled runs
|
|
33
|
+
* are trimmed when a new execution starts so per-action ledger cost stays
|
|
34
|
+
* bounded regardless of how often a task ran before.
|
|
35
|
+
*/
|
|
36
|
+
export const EXECUTION_HISTORY_LIMIT = 20
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
|
|
40
|
+
* most recent last. A running (unsettled) execution is never trimmed: the
|
|
41
|
+
* Host monitor and restart recovery depend on the active record, and a task
|
|
42
|
+
* cannot start a new run while one is still open.
|
|
43
|
+
*/
|
|
44
|
+
export function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[] {
|
|
45
|
+
if (executions.length <= EXECUTION_HISTORY_LIMIT) return [...executions]
|
|
46
|
+
const open = executions.filter(execution => execution.endedAt === undefined)
|
|
47
|
+
const settled = executions.filter(execution => execution.endedAt !== undefined)
|
|
48
|
+
const keepSettled = Math.max(EXECUTION_HISTORY_LIMIT - open.length, 0)
|
|
49
|
+
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
/**
|
|
32
53
|
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
33
54
|
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
@@ -59,7 +80,10 @@ export interface TaskRecord {
|
|
|
59
80
|
createdAt: number
|
|
60
81
|
/** Last mutation instant (ms epoch). */
|
|
61
82
|
updatedAt: number
|
|
62
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Execution history retained on the task, most recent last: the latest
|
|
85
|
+
* {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
|
|
86
|
+
*/
|
|
63
87
|
executions: ExecutionRecord[]
|
|
64
88
|
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
65
89
|
schedule?: ScheduleRule
|
|
@@ -221,7 +245,12 @@ export function startExecution(
|
|
|
221
245
|
error: undefined,
|
|
222
246
|
}
|
|
223
247
|
return {
|
|
224
|
-
task: {
|
|
248
|
+
task: {
|
|
249
|
+
...task,
|
|
250
|
+
status: 'running',
|
|
251
|
+
updatedAt: now,
|
|
252
|
+
executions: retainRecentExecutions([...task.executions, execution]),
|
|
253
|
+
},
|
|
225
254
|
execution,
|
|
226
255
|
}
|
|
227
256
|
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
7
7
|
import { parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
10
10
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
11
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
@@ -232,7 +232,8 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
232
232
|
const previous = byId.get(entry.id)
|
|
233
233
|
byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
|
|
234
234
|
}
|
|
235
|
-
|
|
235
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
|
|
236
|
+
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
@@ -574,7 +575,7 @@ export class HostTaskLedger {
|
|
|
574
575
|
try {
|
|
575
576
|
const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
|
|
576
577
|
if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
|
|
577
|
-
const tasks = parseHostTasks(parsed.tasks)
|
|
578
|
+
const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
|
|
578
579
|
const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
|
|
579
580
|
if (typeof value !== 'object' || value === null) return []
|
|
580
581
|
const row = value as { id?: unknown; schedule?: unknown }
|
package/src/host-routes.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto'
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
3
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
4
4
|
import type { TaskBoardHostService } from './host-service.ts'
|
|
5
|
+
import { writeJson } from './http.ts'
|
|
5
6
|
import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
|
|
6
7
|
import { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
|
|
7
8
|
|
|
@@ -23,11 +24,6 @@ interface ResolvedRouteAccess {
|
|
|
23
24
|
proxyToken?: string
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
27
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
28
|
-
res.end(JSON.stringify(body))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
function parseAuthority(authority: string): { canonical: string; url: URL } | undefined {
|
|
32
28
|
if (authority.trim() !== authority) return undefined
|
|
33
29
|
const match = authority.startsWith('[')
|
|
@@ -127,38 +123,38 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
|
|
|
127
123
|
const resolvedAccess = resolveAccess(access)
|
|
128
124
|
const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
129
125
|
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
|
|
130
|
-
|
|
126
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' }, { 'cache-control': 'no-store' })
|
|
131
127
|
return false
|
|
132
128
|
}
|
|
133
129
|
const state: WebRoute = {
|
|
134
130
|
kind: 'exact',
|
|
135
131
|
path: `${TASK_BOARD_API_PREFIX}/state`,
|
|
136
132
|
handler: (req, res): void => {
|
|
137
|
-
if (req.method !== 'GET') return
|
|
133
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
|
|
138
134
|
if (!guard(req, res)) return
|
|
139
|
-
|
|
135
|
+
writeJson(res, 200, service.snapshot(), { 'cache-control': 'no-store' })
|
|
140
136
|
},
|
|
141
137
|
}
|
|
142
138
|
const action: WebRoute = {
|
|
143
139
|
kind: 'exact',
|
|
144
140
|
path: `${TASK_BOARD_API_PREFIX}/action`,
|
|
145
141
|
handler: async (req, res): Promise<void> => {
|
|
146
|
-
if (req.method !== 'POST') return
|
|
142
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
|
|
147
143
|
if (!guard(req, res)) return
|
|
148
144
|
if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
149
|
-
return
|
|
145
|
+
return writeJson(res, 415, { ok: false, error: 'json-required' }, { 'cache-control': 'no-store' })
|
|
150
146
|
}
|
|
151
147
|
try {
|
|
152
148
|
const body = await readBody(req)
|
|
153
149
|
const parsed = parseActionEnvelope(body.value)
|
|
154
|
-
if (parsed === undefined) return
|
|
150
|
+
if (parsed === undefined) return writeJson(res, 400, { ok: false, error: 'invalid-action' }, { 'cache-control': 'no-store' })
|
|
155
151
|
if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
|
|
156
|
-
return
|
|
152
|
+
return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
|
|
157
153
|
}
|
|
158
|
-
|
|
154
|
+
writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { 'cache-control': 'no-store' })
|
|
159
155
|
} catch (error) {
|
|
160
156
|
const message = error instanceof Error ? error.message : String(error)
|
|
161
|
-
|
|
157
|
+
writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
|
|
162
158
|
}
|
|
163
159
|
},
|
|
164
160
|
}
|
package/src/host-service.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
2
2
|
import { nextRunAtMs } from './core/schedule.ts'
|
|
3
|
-
import type { TaskRecord } from './core/tasks.ts'
|
|
4
3
|
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
5
4
|
import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary } from './host-runner.ts'
|
|
6
5
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
6
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
9
|
+
*
|
|
10
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
11
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
12
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
13
|
+
* belong in the same change as its introduction.
|
|
14
|
+
* @module dsh-web-ui-shared/host/http
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
18
|
+
|
|
19
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
20
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
23
|
+
const JSON_HEADERS = {
|
|
24
|
+
'content-type': 'application/json; charset=utf-8',
|
|
25
|
+
'referrer-policy': 'no-referrer',
|
|
26
|
+
} satisfies OutgoingHttpHeaders
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
30
|
+
* JSON.
|
|
31
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
32
|
+
* invalid or empty payload.
|
|
33
|
+
*/
|
|
34
|
+
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
35
|
+
const chunks: Buffer[] = []
|
|
36
|
+
let size = 0
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const buffer = chunk as Buffer
|
|
39
|
+
size += buffer.length
|
|
40
|
+
if (size > maxBytes) throw new Error('body too large')
|
|
41
|
+
chunks.push(buffer)
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
48
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
49
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
50
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
51
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
52
|
+
* also yield null.
|
|
53
|
+
*/
|
|
54
|
+
export async function readJsonBody(
|
|
55
|
+
req: IncomingMessage,
|
|
56
|
+
opts: { maxBytes?: number; objectOnly?: boolean } = {},
|
|
57
|
+
): Promise<unknown | null> {
|
|
58
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
|
|
59
|
+
const chunks: Buffer[] = []
|
|
60
|
+
let size = 0
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
const buffer = chunk as Buffer
|
|
63
|
+
size += buffer.length
|
|
64
|
+
if (size > maxBytes) {
|
|
65
|
+
req.destroy()
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
chunks.push(buffer)
|
|
69
|
+
}
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
71
|
+
if (text === '') return null
|
|
72
|
+
try {
|
|
73
|
+
const parsed: unknown = JSON.parse(text)
|
|
74
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null
|
|
75
|
+
return parsed
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
82
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
87
|
+
export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
|
|
88
|
+
return isJsonObject(value) ? value : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write one JSON response. Default headers are the family defaults
|
|
93
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
94
|
+
* override them.
|
|
95
|
+
*/
|
|
96
|
+
export function writeJson(
|
|
97
|
+
res: ServerResponse,
|
|
98
|
+
status: number,
|
|
99
|
+
body: unknown,
|
|
100
|
+
headers: OutgoingHttpHeaders = {},
|
|
101
|
+
): void {
|
|
102
|
+
const payload = JSON.stringify(body)
|
|
103
|
+
res.writeHead(status, { ...JSON_HEADERS, ...headers })
|
|
104
|
+
res.end(payload)
|
|
105
|
+
}
|