@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.
- package/LICENSE +202 -29
- package/README.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +18 -159
- package/lib/client.js.map +1 -1
- package/lib/index.js +123 -46
- package/lib/types/core/controller.d.ts +10 -31
- package/lib/types/core/tasks.d.ts +17 -1
- package/lib/types/host-ledger.d.ts +28 -0
- package/lib/types/host-service.d.ts +0 -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/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +16 -152
- package/src/core/tasks.ts +31 -2
- package/src/host-ledger.ts +69 -3
- package/src/host-routes.ts +10 -14
- package/src/host-service.ts +32 -28
- package/src/http.ts +105 -0
- package/lib/types/core/execution.d.ts +0 -197
- package/src/core/execution.ts +0 -425
package/lib/index.js
CHANGED
|
@@ -204,8 +204,19 @@ function resolveDshHome(env = process.env, home = homedir()) {
|
|
|
204
204
|
function dshHome() {
|
|
205
205
|
return resolveDshHome();
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
|
|
209
|
+
* most recent last. A running (unsettled) execution is never trimmed: the
|
|
210
|
+
* Host monitor and restart recovery depend on the active record, and a task
|
|
211
|
+
* cannot start a new run while one is still open.
|
|
212
|
+
*/
|
|
213
|
+
function retainRecentExecutions(executions) {
|
|
214
|
+
if (executions.length <= 20) return [...executions];
|
|
215
|
+
const open = executions.filter((execution) => execution.endedAt === void 0);
|
|
216
|
+
const settled = executions.filter((execution) => execution.endedAt !== void 0);
|
|
217
|
+
const keepSettled = Math.max(20 - open.length, 0);
|
|
218
|
+
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
|
|
219
|
+
}
|
|
209
220
|
/** Statuses a settled task may be archived from. */
|
|
210
221
|
const ARCHIVABLE_STATUSES = ["done", "failed"];
|
|
211
222
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
@@ -307,7 +318,7 @@ function startExecution(task, now, executionId) {
|
|
|
307
318
|
...task,
|
|
308
319
|
status: "running",
|
|
309
320
|
updatedAt: now,
|
|
310
|
-
executions: [...task.executions, execution]
|
|
321
|
+
executions: retainRecentExecutions([...task.executions, execution])
|
|
311
322
|
},
|
|
312
323
|
execution
|
|
313
324
|
};
|
|
@@ -1014,9 +1025,10 @@ function mergeTask(a, b) {
|
|
|
1014
1025
|
const previous = byId.get(entry.id);
|
|
1015
1026
|
byId.set(entry.id, previous === void 0 ? entry : betterExecution(previous, entry));
|
|
1016
1027
|
}
|
|
1028
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt);
|
|
1017
1029
|
return {
|
|
1018
1030
|
...newer,
|
|
1019
|
-
executions:
|
|
1031
|
+
executions: retainRecentExecutions(executions)
|
|
1020
1032
|
};
|
|
1021
1033
|
}
|
|
1022
1034
|
function parseHostTasks(values) {
|
|
@@ -1087,6 +1099,52 @@ var HostTaskLedger = class {
|
|
|
1087
1099
|
scheduler
|
|
1088
1100
|
};
|
|
1089
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
1104
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
1105
|
+
* an authoritative mutable object from the ledger.
|
|
1106
|
+
*/
|
|
1107
|
+
runtimeView() {
|
|
1108
|
+
let armedSchedules = 0;
|
|
1109
|
+
const openExecutions = [];
|
|
1110
|
+
for (const task of this.document.tasks) {
|
|
1111
|
+
if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
|
|
1112
|
+
for (const execution of task.executions) {
|
|
1113
|
+
if (execution.endedAt !== void 0) continue;
|
|
1114
|
+
openExecutions.push({
|
|
1115
|
+
taskId: task.id,
|
|
1116
|
+
executionId: execution.id,
|
|
1117
|
+
sessionId: execution.sessionId,
|
|
1118
|
+
startedAt: execution.startedAt
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
armedSchedules,
|
|
1124
|
+
openExecutions
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
1128
|
+
armedScheduleCount() {
|
|
1129
|
+
let count = 0;
|
|
1130
|
+
for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
|
|
1131
|
+
return count;
|
|
1132
|
+
}
|
|
1133
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
1134
|
+
dueSchedules(now) {
|
|
1135
|
+
const due = [];
|
|
1136
|
+
for (const task of this.document.tasks) {
|
|
1137
|
+
if (task.archivedAt !== void 0) continue;
|
|
1138
|
+
const schedule = task.schedule;
|
|
1139
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
1140
|
+
due.push({
|
|
1141
|
+
taskId: task.id,
|
|
1142
|
+
cron: schedule.cron,
|
|
1143
|
+
nextRunAt: schedule.nextRunAt
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return due;
|
|
1147
|
+
}
|
|
1090
1148
|
subscribe(listener) {
|
|
1091
1149
|
this.listeners.add(listener);
|
|
1092
1150
|
return () => {
|
|
@@ -1314,7 +1372,10 @@ var HostTaskLedger = class {
|
|
|
1314
1372
|
try {
|
|
1315
1373
|
const parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
1316
1374
|
if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
|
|
1317
|
-
const tasks = parseHostTasks(parsed.tasks)
|
|
1375
|
+
const tasks = parseHostTasks(parsed.tasks).map((task) => ({
|
|
1376
|
+
...task,
|
|
1377
|
+
executions: retainRecentExecutions(task.executions)
|
|
1378
|
+
}));
|
|
1318
1379
|
const invalidScheduleIds = parsed.tasks.flatMap((value) => {
|
|
1319
1380
|
if (typeof value !== "object" || value === null) return [];
|
|
1320
1381
|
const row = value;
|
|
@@ -2062,24 +2123,33 @@ var TaskBoardHostService = class {
|
|
|
2062
2123
|
}
|
|
2063
2124
|
async pollSessions() {
|
|
2064
2125
|
if (this.disposed) return;
|
|
2065
|
-
if (!this.active &&
|
|
2126
|
+
if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return;
|
|
2066
2127
|
const running = await this.runner.listRunning();
|
|
2067
2128
|
const previous = this.power.snapshot();
|
|
2129
|
+
if (!running.known) {
|
|
2130
|
+
this.power.updateReasons({
|
|
2131
|
+
runningSessions: previous.runningSessions,
|
|
2132
|
+
armedSchedules: this.ledger.armedScheduleCount(),
|
|
2133
|
+
sessionStateKnown: false
|
|
2134
|
+
});
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const runtime = this.ledger.runtimeView();
|
|
2068
2138
|
this.power.updateReasons({
|
|
2069
|
-
runningSessions: running.
|
|
2070
|
-
armedSchedules:
|
|
2071
|
-
sessionStateKnown:
|
|
2139
|
+
runningSessions: running.count,
|
|
2140
|
+
armedSchedules: runtime.armedSchedules,
|
|
2141
|
+
sessionStateKnown: true
|
|
2072
2142
|
});
|
|
2073
|
-
|
|
2143
|
+
await this.reconcileExecutions(running.items, runtime.openExecutions);
|
|
2074
2144
|
}
|
|
2075
2145
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
2076
|
-
async reconcileExecutions(sessions) {
|
|
2077
|
-
for (const
|
|
2078
|
-
if (execution.sessionId === void 0
|
|
2146
|
+
async reconcileExecutions(sessions, executions) {
|
|
2147
|
+
for (const execution of executions) {
|
|
2148
|
+
if (execution.sessionId === void 0) continue;
|
|
2079
2149
|
try {
|
|
2080
2150
|
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
|
|
2081
2151
|
if (result.outcome === "pending") continue;
|
|
2082
|
-
this.ledger.settle(
|
|
2152
|
+
this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
|
|
2083
2153
|
} catch {}
|
|
2084
2154
|
}
|
|
2085
2155
|
}
|
|
@@ -2093,20 +2163,14 @@ var TaskBoardHostService = class {
|
|
|
2093
2163
|
this.ledger.skipMissed(now);
|
|
2094
2164
|
return;
|
|
2095
2165
|
}
|
|
2096
|
-
for (const
|
|
2097
|
-
if (task.archivedAt !== void 0) continue;
|
|
2098
|
-
const schedule = task.schedule;
|
|
2099
|
-
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
2166
|
+
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
2100
2167
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
|
|
2101
|
-
const opened = this.ledger.openScheduled(
|
|
2168
|
+
const opened = this.ledger.openScheduled(schedule.taskId, next, now);
|
|
2102
2169
|
if (opened !== void 0) this.scheduleLaunch(opened);
|
|
2103
2170
|
}
|
|
2104
2171
|
}
|
|
2105
2172
|
armedSchedules() {
|
|
2106
|
-
return this.ledger.
|
|
2107
|
-
}
|
|
2108
|
-
hasOpenExecutions() {
|
|
2109
|
-
return this.ledger.state().tasks.some((task) => task.executions.some((execution) => execution.endedAt === void 0));
|
|
2173
|
+
return this.ledger.armedScheduleCount();
|
|
2110
2174
|
}
|
|
2111
2175
|
scheduleLaunch(opened) {
|
|
2112
2176
|
this.launch(opened).catch((error) => {
|
|
@@ -2145,6 +2209,26 @@ var TaskBoardHostService = class {
|
|
|
2145
2209
|
}
|
|
2146
2210
|
};
|
|
2147
2211
|
//#endregion
|
|
2212
|
+
//#region src/http.ts
|
|
2213
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
2214
|
+
const JSON_HEADERS = {
|
|
2215
|
+
"content-type": "application/json; charset=utf-8",
|
|
2216
|
+
"referrer-policy": "no-referrer"
|
|
2217
|
+
};
|
|
2218
|
+
/**
|
|
2219
|
+
* Write one JSON response. Default headers are the family defaults
|
|
2220
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
2221
|
+
* override them.
|
|
2222
|
+
*/
|
|
2223
|
+
function writeJson(res, status, body, headers = {}) {
|
|
2224
|
+
const payload = JSON.stringify(body);
|
|
2225
|
+
res.writeHead(status, {
|
|
2226
|
+
...JSON_HEADERS,
|
|
2227
|
+
...headers
|
|
2228
|
+
});
|
|
2229
|
+
res.end(payload);
|
|
2230
|
+
}
|
|
2231
|
+
//#endregion
|
|
2148
2232
|
//#region src/loopback.ts
|
|
2149
2233
|
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
2150
2234
|
function isIPv4Loopback(v4) {
|
|
@@ -2196,13 +2280,6 @@ const IMPORT_LIMIT = 2 * 1024 * 1024;
|
|
|
2196
2280
|
const HEARTBEAT_MS = 15e3;
|
|
2197
2281
|
/** Header replaced by an authenticated same-host reverse proxy. */
|
|
2198
2282
|
const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
|
|
2199
|
-
function json(res, status, body) {
|
|
2200
|
-
res.writeHead(status, {
|
|
2201
|
-
"content-type": "application/json; charset=utf-8",
|
|
2202
|
-
"cache-control": "no-store"
|
|
2203
|
-
});
|
|
2204
|
-
res.end(JSON.stringify(body));
|
|
2205
|
-
}
|
|
2206
2283
|
function parseAuthority(authority) {
|
|
2207
2284
|
if (authority.trim() !== authority) return void 0;
|
|
2208
2285
|
const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
|
|
@@ -2297,10 +2374,10 @@ function makeTaskBoardRoutes(service, access = {}) {
|
|
|
2297
2374
|
const resolvedAccess = resolveAccess(access);
|
|
2298
2375
|
const guard = (req, res) => {
|
|
2299
2376
|
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
|
|
2300
|
-
|
|
2377
|
+
writeJson(res, 403, {
|
|
2301
2378
|
ok: false,
|
|
2302
2379
|
error: "forbidden"
|
|
2303
|
-
});
|
|
2380
|
+
}, { "cache-control": "no-store" });
|
|
2304
2381
|
return false;
|
|
2305
2382
|
};
|
|
2306
2383
|
return [
|
|
@@ -2308,45 +2385,45 @@ function makeTaskBoardRoutes(service, access = {}) {
|
|
|
2308
2385
|
kind: "exact",
|
|
2309
2386
|
path: `${TASK_BOARD_API_PREFIX}/state`,
|
|
2310
2387
|
handler: (req, res) => {
|
|
2311
|
-
if (req.method !== "GET") return
|
|
2388
|
+
if (req.method !== "GET") return writeJson(res, 405, {
|
|
2312
2389
|
ok: false,
|
|
2313
2390
|
error: "method-not-allowed"
|
|
2314
|
-
});
|
|
2391
|
+
}, { "cache-control": "no-store" });
|
|
2315
2392
|
if (!guard(req, res)) return;
|
|
2316
|
-
|
|
2393
|
+
writeJson(res, 200, service.snapshot(), { "cache-control": "no-store" });
|
|
2317
2394
|
}
|
|
2318
2395
|
},
|
|
2319
2396
|
{
|
|
2320
2397
|
kind: "exact",
|
|
2321
2398
|
path: `${TASK_BOARD_API_PREFIX}/action`,
|
|
2322
2399
|
handler: async (req, res) => {
|
|
2323
|
-
if (req.method !== "POST") return
|
|
2400
|
+
if (req.method !== "POST") return writeJson(res, 405, {
|
|
2324
2401
|
ok: false,
|
|
2325
2402
|
error: "method-not-allowed"
|
|
2326
|
-
});
|
|
2403
|
+
}, { "cache-control": "no-store" });
|
|
2327
2404
|
if (!guard(req, res)) return;
|
|
2328
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return
|
|
2405
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return writeJson(res, 415, {
|
|
2329
2406
|
ok: false,
|
|
2330
2407
|
error: "json-required"
|
|
2331
|
-
});
|
|
2408
|
+
}, { "cache-control": "no-store" });
|
|
2332
2409
|
try {
|
|
2333
2410
|
const body = await readBody(req);
|
|
2334
2411
|
const parsed = parseActionEnvelope(body.value);
|
|
2335
|
-
if (parsed === void 0) return
|
|
2412
|
+
if (parsed === void 0) return writeJson(res, 400, {
|
|
2336
2413
|
ok: false,
|
|
2337
2414
|
error: "invalid-action"
|
|
2338
|
-
});
|
|
2339
|
-
if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return
|
|
2415
|
+
}, { "cache-control": "no-store" });
|
|
2416
|
+
if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return writeJson(res, 413, {
|
|
2340
2417
|
ok: false,
|
|
2341
2418
|
error: "body-too-large"
|
|
2342
|
-
});
|
|
2343
|
-
|
|
2419
|
+
}, { "cache-control": "no-store" });
|
|
2420
|
+
writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { "cache-control": "no-store" });
|
|
2344
2421
|
} catch (error) {
|
|
2345
2422
|
const message = error instanceof Error ? error.message : String(error);
|
|
2346
|
-
|
|
2423
|
+
writeJson(res, message === "body-too-large" ? 413 : 400, {
|
|
2347
2424
|
ok: false,
|
|
2348
2425
|
error: message
|
|
2349
|
-
});
|
|
2426
|
+
}, { "cache-control": "no-store" });
|
|
2350
2427
|
}
|
|
2351
2428
|
}
|
|
2352
2429
|
},
|
|
@@ -2,17 +2,15 @@
|
|
|
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 { ExecutionService } from './execution.ts';
|
|
16
14
|
import type { TaskStore } from './store.ts';
|
|
17
15
|
import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
|
|
18
16
|
import { type TaskUpdatePatch } from './use-cases/task-update.ts';
|
|
@@ -37,15 +35,11 @@ export interface SessionsControllerFace {
|
|
|
37
35
|
/** Controller dependencies (all swappable in tests). */
|
|
38
36
|
export interface ControllerDeps {
|
|
39
37
|
store: TaskStore;
|
|
40
|
-
/** Legacy browser execution seam used only by isolated v1 tests. */
|
|
41
|
-
exec?: ExecutionService;
|
|
42
38
|
sessions: SessionsControllerFace;
|
|
43
39
|
/** Clock; defaults to Date.now. */
|
|
44
40
|
now?: () => number;
|
|
45
41
|
/** Id minting; defaults to a random-uuid. */
|
|
46
42
|
uuid?: () => string;
|
|
47
|
-
/** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
|
|
48
|
-
reconcileDebounceMs?: number;
|
|
49
43
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
50
44
|
transport?: TaskBoardTransport;
|
|
51
45
|
}
|
|
@@ -105,7 +99,7 @@ export declare class BoardController {
|
|
|
105
99
|
private hostState;
|
|
106
100
|
private remoteSubscribed;
|
|
107
101
|
private remoteInitialization;
|
|
108
|
-
/** @param deps - store
|
|
102
|
+
/** @param deps - store and the sessions navigation face. */
|
|
109
103
|
constructor(deps: ControllerDeps);
|
|
110
104
|
/** Load the persisted ledger and start the navigation/status subscriptions. */
|
|
111
105
|
start(): void;
|
|
@@ -178,32 +172,17 @@ export declare class BoardController {
|
|
|
178
172
|
*/
|
|
179
173
|
openSession(sessionId: string): void;
|
|
180
174
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* already running is ignored
|
|
175
|
+
* Request a Host execution for a task: the Host ledger owns the running
|
|
176
|
+
* transition, the execution record, and the settlement. A second call
|
|
177
|
+
* while the task is already running is ignored; without a Host transport
|
|
178
|
+
* the run is refused (returns false).
|
|
184
179
|
*/
|
|
185
180
|
runTask(id: string): Promise<boolean>;
|
|
186
|
-
/** Re-run a settled task
|
|
181
|
+
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
187
182
|
rerunTask(id: string): Promise<void>;
|
|
188
|
-
|
|
189
|
-
/** Reconcile running tasks and close the board when the user navigates. */
|
|
183
|
+
/** Close the board when the user navigates to another session. */
|
|
190
184
|
private onSessionsChanged;
|
|
191
185
|
private lastCurrent;
|
|
192
|
-
/** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
|
|
193
|
-
private readonly activeExecutionIds;
|
|
194
|
-
/** Debounce timer for {@link reconcileRunningTasks}. */
|
|
195
|
-
private reconcileTimer;
|
|
196
|
-
/** Whether a reconcile pass is underway (single-flight guard). */
|
|
197
|
-
private reconcileInFlight;
|
|
198
|
-
/**
|
|
199
|
-
* Debounce + single-flight trigger for the running-task reconciliation.
|
|
200
|
-
* Session-list notifications arrive in bursts (one per session status
|
|
201
|
-
* change); both guards together keep a burst from reading the history API
|
|
202
|
-
* once per running task.
|
|
203
|
-
*/
|
|
204
|
-
private scheduleReconcile;
|
|
205
|
-
/** Settle tasks left 'running' whose sessions already finished. */
|
|
206
|
-
private reconcileRunningTasks;
|
|
207
186
|
private persistAndNotify;
|
|
208
187
|
private commitRemote;
|
|
209
188
|
private performRemote;
|
|
@@ -25,6 +25,19 @@ export interface ExecutionRecord {
|
|
|
25
25
|
/** Human failure text when the run failed (prompt rejection or agent error). */
|
|
26
26
|
error: string | undefined;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Maximum number of execution records retained per task. Older settled runs
|
|
30
|
+
* are trimmed when a new execution starts so per-action ledger cost stays
|
|
31
|
+
* bounded regardless of how often a task ran before.
|
|
32
|
+
*/
|
|
33
|
+
export declare const EXECUTION_HISTORY_LIMIT = 20;
|
|
34
|
+
/**
|
|
35
|
+
* Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
|
|
36
|
+
* most recent last. A running (unsettled) execution is never trimmed: the
|
|
37
|
+
* Host monitor and restart recovery depend on the active record, and a task
|
|
38
|
+
* cannot start a new run while one is still open.
|
|
39
|
+
*/
|
|
40
|
+
export declare function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[];
|
|
28
41
|
/**
|
|
29
42
|
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
30
43
|
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
@@ -55,7 +68,10 @@ export interface TaskRecord {
|
|
|
55
68
|
createdAt: number;
|
|
56
69
|
/** Last mutation instant (ms epoch). */
|
|
57
70
|
updatedAt: number;
|
|
58
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Execution history retained on the task, most recent last: the latest
|
|
73
|
+
* {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
|
|
74
|
+
*/
|
|
59
75
|
executions: ExecutionRecord[];
|
|
60
76
|
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
61
77
|
schedule?: ScheduleRule;
|
|
@@ -9,6 +9,24 @@ export interface OpenedRun {
|
|
|
9
9
|
task: TaskRecord;
|
|
10
10
|
execution: ExecutionRecord;
|
|
11
11
|
}
|
|
12
|
+
/** Minimal value copy used by the Host session monitor. */
|
|
13
|
+
export interface OpenExecutionReference {
|
|
14
|
+
readonly taskId: string;
|
|
15
|
+
readonly executionId: string;
|
|
16
|
+
readonly sessionId: string | undefined;
|
|
17
|
+
readonly startedAt: number;
|
|
18
|
+
}
|
|
19
|
+
/** Minimal value copy used by the Host scheduler. */
|
|
20
|
+
export interface DueScheduleReference {
|
|
21
|
+
readonly taskId: string;
|
|
22
|
+
readonly cron: string;
|
|
23
|
+
readonly nextRunAt: number;
|
|
24
|
+
}
|
|
25
|
+
/** Derived runtime data for one session-poll pass. */
|
|
26
|
+
export interface LedgerRuntimeView {
|
|
27
|
+
readonly armedSchedules: number;
|
|
28
|
+
readonly openExecutions: readonly OpenExecutionReference[];
|
|
29
|
+
}
|
|
12
30
|
/**
|
|
13
31
|
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
14
32
|
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
@@ -36,6 +54,16 @@ export declare class HostTaskLedger {
|
|
|
36
54
|
scheduler: TaskBoardSchedulerSnapshot;
|
|
37
55
|
};
|
|
38
56
|
state(): LedgerState;
|
|
57
|
+
/**
|
|
58
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
59
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
60
|
+
* an authoritative mutable object from the ledger.
|
|
61
|
+
*/
|
|
62
|
+
runtimeView(): LedgerRuntimeView;
|
|
63
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
64
|
+
armedScheduleCount(): number;
|
|
65
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
66
|
+
dueSchedules(now: number): DueScheduleReference[];
|
|
39
67
|
subscribe(listener: () => void): () => void;
|
|
40
68
|
dispose(): void;
|
|
41
69
|
applyRequest(requestId: string, action: TaskBoardAction): {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON body/response helpers for the host route families: one strict
|
|
3
|
+
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
4
|
+
* narrow, and one JSON writer. Previously these were copy-pasted across the
|
|
5
|
+
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
6
|
+
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
7
|
+
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
8
|
+
*
|
|
9
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
10
|
+
* edit this shared source and re-run the sync instead of editing a copy.
|
|
11
|
+
* Consumer code is migrated onto it in follow-up waves; no call site changes
|
|
12
|
+
* belong in the same change as its introduction.
|
|
13
|
+
* @module dsh-web-ui-shared/host/http
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
|
|
16
|
+
/**
|
|
17
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
18
|
+
* JSON.
|
|
19
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
20
|
+
* invalid or empty payload.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
25
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
26
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
27
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
28
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
29
|
+
* also yield null.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readJsonBody(req: IncomingMessage, opts?: {
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
objectOnly?: boolean;
|
|
34
|
+
}): Promise<unknown | null>;
|
|
35
|
+
/** Narrow a value to a JSON object, or undefined when it is not one. */
|
|
36
|
+
export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Write one JSON response. Default headers are the family defaults
|
|
39
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
40
|
+
* override them.
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -52,9 +52,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
52
52
|
return (
|
|
53
53
|
<div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
|
|
54
54
|
<header className={css.boardHeader}>
|
|
55
|
+
{/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
|
|
55
56
|
<button
|
|
56
57
|
type="button"
|
|
57
58
|
className={`${css.ghostButton} ${css.backButton}`}
|
|
59
|
+
data-dsh-center-view-back=""
|
|
58
60
|
aria-label={t('board.close')}
|
|
59
61
|
onClick={() => { controller.closeBoard() }}
|
|
60
62
|
>
|