@linxin666/dsh-client-ui-task-board 0.2.8 → 0.3.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.
@@ -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
- json(res, 403, { ok: false, error: 'forbidden' })
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 json(res, 405, { ok: false, error: 'method-not-allowed' })
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
- json(res, 200, service.snapshot())
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 json(res, 405, { ok: false, error: 'method-not-allowed' })
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 json(res, 415, { ok: false, error: 'json-required' })
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 json(res, 400, { ok: false, error: 'invalid-action' })
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 json(res, 413, { ok: false, error: 'body-too-large' })
152
+ return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
157
153
  }
158
- json(res, 200, service.apply(parsed.requestId, parsed.action))
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
- json(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
157
+ writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
162
158
  }
163
159
  },
164
160
  }
@@ -1,7 +1,6 @@
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
- import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
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'
7
6
  import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
@@ -136,31 +135,43 @@ export class TaskBoardHostService {
136
135
 
137
136
  private async pollSessions(): Promise<void> {
138
137
  if (this.disposed) return
139
- if (!this.active && !this.hasOpenExecutions()) return
138
+ if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return
140
139
  const running = await this.runner.listRunning()
141
140
  const previous = this.power.snapshot()
141
+ if (!running.known) {
142
+ this.power.updateReasons({
143
+ runningSessions: previous.runningSessions,
144
+ armedSchedules: this.ledger.armedScheduleCount(),
145
+ sessionStateKnown: false,
146
+ })
147
+ return
148
+ }
149
+ // Read after the RPC so executions attached while it was in flight are
150
+ // included in this pass, matching the former full-state snapshot timing.
151
+ const runtime = this.ledger.runtimeView()
142
152
  this.power.updateReasons({
143
- runningSessions: running.known ? running.count : previous.runningSessions,
144
- armedSchedules: this.armedSchedules(),
145
- sessionStateKnown: running.known,
153
+ runningSessions: running.count,
154
+ armedSchedules: runtime.armedSchedules,
155
+ sessionStateKnown: true,
146
156
  })
147
157
  // No unconditional emit here: real changes already emit through the
148
158
  // ledger subscription (settles) and the gated power listener above.
149
- if (running.known) await this.reconcileExecutions(running.items)
159
+ await this.reconcileExecutions(running.items, runtime.openExecutions)
150
160
  }
151
161
 
152
162
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
153
- private async reconcileExecutions(sessions: readonly SessionSummary[]): Promise<void> {
154
- for (const task of this.ledger.state().tasks) {
155
- for (const execution of task.executions) {
156
- if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
157
- try {
158
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
159
- if (result.outcome === 'pending') continue
160
- this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
161
- } catch {
162
- // A transient inspection failure never settles a running execution.
163
- }
163
+ private async reconcileExecutions(
164
+ sessions: readonly SessionSummary[],
165
+ executions: readonly OpenExecutionReference[],
166
+ ): Promise<void> {
167
+ for (const execution of executions) {
168
+ if (execution.sessionId === undefined) continue
169
+ try {
170
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
171
+ if (result.outcome === 'pending') continue
172
+ this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
173
+ } catch {
174
+ // A transient inspection failure never settles a running execution.
164
175
  }
165
176
  }
166
177
  }
@@ -175,22 +186,15 @@ export class TaskBoardHostService {
175
186
  this.ledger.skipMissed(now)
176
187
  return
177
188
  }
178
- for (const task of this.ledger.state().tasks) {
179
- if (task.archivedAt !== undefined) continue
180
- const schedule = task.schedule
181
- if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
189
+ for (const schedule of this.ledger.dueSchedules(now)) {
182
190
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
183
- const opened = this.ledger.openScheduled(task.id, next, now)
191
+ const opened = this.ledger.openScheduled(schedule.taskId, next, now)
184
192
  if (opened !== undefined) this.scheduleLaunch(opened)
185
193
  }
186
194
  }
187
195
 
188
196
  private armedSchedules(): number {
189
- return this.ledger.state().tasks.filter((task: TaskRecord) => task.archivedAt === undefined && task.schedule?.enabled === true).length
190
- }
191
-
192
- private hasOpenExecutions(): boolean {
193
- return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
197
+ return this.ledger.armedScheduleCount()
194
198
  }
195
199
 
196
200
  private scheduleLaunch(opened: OpenedRun): void {
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
+ }
@@ -1,197 +0,0 @@
1
- /**
2
- * Execution service: runs a task through dsh's real session machinery.
3
- *
4
- * The board's "run" button must make dsh actually work, not fake a status:
5
- * the service connects a real session (workspace blank-session reuse or
6
- * `session.create` on the host via the workspaces service), renames it to
7
- * the task title, sends the task prompt with `session.prompt`, and then
8
- * watches the session's conversation snapshot until its turn settles. The
9
- * task board controller consumes {@link ExecutionEvent}s to move the card
10
- * running → done/failed and to keep the execution record.
11
- *
12
- * Deliberately framework-free: the runtime faces are declared structurally
13
- * (a narrow slice of the real `ctx.sessions` / `ctx.workspaces` contracts)
14
- * so tests drive it with plain fakes.
15
- */
16
- import type { ExecutionRecord, TaskRecord } from './tasks.ts';
17
- /** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
18
- export interface ExecutionSessionSummary {
19
- running: boolean;
20
- completed?: boolean;
21
- /** Empty-log bit: the preset can only be recomposed while the session is blank. */
22
- blank?: boolean;
23
- /** The preset the session currently runs (absent on deployments without presets). */
24
- agentPreset?: string;
25
- }
26
- /** The narrow sessions face the service needs. */
27
- export interface SessionsExecutionFace {
28
- list: {
29
- getSnapshot(): {
30
- /** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
31
- phase: 'pending' | 'ready';
32
- byId: Record<string, ExecutionSessionSummary>;
33
- };
34
- subscribe(fn: () => void): () => void;
35
- };
36
- binding(id: string): {
37
- session: SessionDriver;
38
- } | undefined;
39
- /** Record a host-confirmed preset switch so the list label moves immediately. */
40
- noteAgentPreset?(sessionId: string, agentPreset: string): void;
41
- }
42
- /** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
43
- export interface PresetsExecutionFace {
44
- /** Recompose a blank session's agent from a preset. */
45
- select(sessionId: string, agentPreset: string): Promise<{
46
- ok: true;
47
- } | {
48
- ok: false;
49
- error: unknown;
50
- }>;
51
- }
52
- /** The narrow workspaces face the service needs. */
53
- export interface WorkspacesExecutionFace {
54
- list: {
55
- getSnapshot(): {
56
- items: readonly {
57
- workspaceId: string;
58
- }[];
59
- recentWorkspaceId: string | undefined;
60
- };
61
- };
62
- connectWorkspace(workspaceId: string): Promise<string>;
63
- }
64
- /** One raw session-history event narrowed to the failure signal reconcile needs. */
65
- export interface ExecutionHistoryEvent {
66
- type: string;
67
- data?: unknown;
68
- }
69
- /** Optional raw-history face used to detect failures of never-opened sessions. */
70
- export interface HistoryExecutionFace {
71
- loadTail(sessionId: string): Promise<{
72
- events: readonly ExecutionHistoryEvent[];
73
- } | undefined>;
74
- }
75
- /** The behavior verbs the service invokes on an execution session. */
76
- export interface SessionDriver {
77
- rename(title: string): Promise<unknown>;
78
- prompt(content: readonly unknown[], mode: 'queue'): Promise<{
79
- ok: true;
80
- } | {
81
- ok: false;
82
- error: unknown;
83
- }>;
84
- /**
85
- * Admit one slash-command line against the session's agent (the
86
- * `/permission <id>` mechanism). `matched` reports whether a command
87
- * claimed the line.
88
- */
89
- command(line: string): Promise<{
90
- ok: true;
91
- matched: boolean;
92
- } | {
93
- ok: false;
94
- error: unknown;
95
- }>;
96
- getSnapshot(): {
97
- running: boolean;
98
- lastAgentError: string | null;
99
- turnEnds: ReadonlyMap<number, number>;
100
- };
101
- subscribe(fn: () => void): () => void;
102
- }
103
- /** Everything the service needs from the runtime. */
104
- export interface ExecutionEnvironment {
105
- sessions: SessionsExecutionFace;
106
- workspaces: WorkspacesExecutionFace;
107
- /** Agent-preset wire face; absent on deployments without preset support. */
108
- presets?: PresetsExecutionFace;
109
- /** Raw-history reader for failure detection of never-opened sessions. */
110
- history?: HistoryExecutionFace;
111
- }
112
- /** Outcome events the service emits to the controller. */
113
- export type ExecutionEvent = {
114
- kind: 'started';
115
- taskId: string;
116
- executionId: string;
117
- sessionId: string;
118
- } | {
119
- kind: 'settled';
120
- taskId: string;
121
- executionId: string;
122
- outcome: 'succeeded' | 'failed' | 'cancelled';
123
- error?: string;
124
- };
125
- /**
126
- * Run one task to completion (or to a settled failure).
127
- *
128
- * @param task - the task being executed.
129
- * @param execution - the freshly opened execution record (id + start time).
130
- * @param onEvent - callback for started/settled events.
131
- * @returns resolves when the run settles (or fails to start); never rejects —
132
- * every failure path is reported as a settled event.
133
- */
134
- export declare class ExecutionService {
135
- private readonly env;
136
- /**
137
- * In-flight preset switches keyed by `sessionId\u0000mode`. The session
138
- * list mirror can lag behind a just-applied switch, so concurrent runs
139
- * against one blank session would otherwise each issue the same select
140
- * RPC (an amplified storm); sharing one in-flight call keeps the wire to
141
- * a single request. The settled entry is forgotten so a later run can
142
- * switch again.
143
- */
144
- private readonly presetSwitches;
145
- /** @param env - the runtime faces (real or fake). */
146
- constructor(env: ExecutionEnvironment);
147
- run(task: TaskRecord, execution: ExecutionRecord, onEvent: (event: ExecutionEvent) => void): Promise<void>;
148
- /**
149
- * Recompose the execution session's agent from the task-pinned preset.
150
- * No-op when the task pins none or the session already runs it; fails the
151
- * run when the session is no longer blank, the preset face is missing, or
152
- * the wire refuses.
153
- */
154
- private applyMode;
155
- /**
156
- * One in-flight `select` per (session, preset): concurrent runs against
157
- * the same blank session share the same wire call instead of each issuing
158
- * a duplicate RPC. The entry is removed once the call settles, so a later
159
- * run (after a shared failure, say) issues a fresh switch.
160
- */
161
- private switchPreset;
162
- /**
163
- * Apply the task-pinned permission preset through the `/permission <id>`
164
- * slash command. No-op when the task pins none; fails the run when the
165
- * admission is rejected or no command claimed the line.
166
- */
167
- private applyPermission;
168
- /**
169
- * Inspect a reloaded/background task that was left 'running' and emit a
170
- * settled event when its session already finished.
171
- *
172
- * A session that was never opened keeps a cold conversation snapshot (the
173
- * runtime only maintains the window for the staged/current session), so the
174
- * settled outcome is decided by the strongest available signal, in order:
175
- * 1. the list summary — missing session → cancelled; still running → pending;
176
- * 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
177
- * 3. the raw history tail (when a history face is wired) — a `turn/end`
178
- * error reason proves failure;
179
- * 4. otherwise a finished session counts as succeeded.
180
- *
181
- * @param task - a task whose latest execution has no endedAt.
182
- * @returns a settled event when the session state proves completion, else undefined.
183
- */
184
- reconcile(task: TaskRecord): Promise<ExecutionEvent | undefined>;
185
- /** Best-effort failure probe over the raw history tail (false when unavailable). */
186
- private historyShowsFailure;
187
- private connectSession;
188
- private driverOf;
189
- private sendPrompt;
190
- /**
191
- * Subscribe to the execution session and settle the run once the accepted
192
- * turn completes (turn counter advanced past the acceptance baseline and
193
- * the session is no longer running). Never settles while the session is
194
- * still running; unsubscribes on settle.
195
- */
196
- private watchForSettlement;
197
- }