@linxin666/dsh-client-ui-task-board 0.1.20 → 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 +574 -581
- package/lib/client.js.map +1 -1
- package/lib/index.js +2144 -5
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- 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 +6 -6
- 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 +17 -9
- package/lib/types/loopback.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 +21 -4
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- 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 +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- 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 +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
package/lib/index.js
CHANGED
|
@@ -1,5 +1,2103 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
//#region src/core/schedule.ts
|
|
9
|
+
/** Inclusive ranges per field, in cron order. */
|
|
10
|
+
const FIELD_RANGES = [
|
|
11
|
+
[0, 59],
|
|
12
|
+
[0, 23],
|
|
13
|
+
[1, 31],
|
|
14
|
+
[1, 12],
|
|
15
|
+
[0, 7]
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Parse a 5-field cron expression.
|
|
19
|
+
* @returns the match sets, or null when the expression is invalid.
|
|
20
|
+
*/
|
|
21
|
+
function parseCron(expr) {
|
|
22
|
+
const fields = expr.trim().split(/\s+/);
|
|
23
|
+
if (fields.length !== 5) return null;
|
|
24
|
+
const sets = [];
|
|
25
|
+
for (let index = 0; index < 5; index++) {
|
|
26
|
+
const [min, max] = FIELD_RANGES[index];
|
|
27
|
+
const set = /* @__PURE__ */ new Set();
|
|
28
|
+
if (!parseField(fields[index], min, max, set)) return null;
|
|
29
|
+
sets.push(set);
|
|
30
|
+
}
|
|
31
|
+
const weekdays = /* @__PURE__ */ new Set();
|
|
32
|
+
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
|
|
33
|
+
return {
|
|
34
|
+
minutes: sets[0],
|
|
35
|
+
hours: sets[1],
|
|
36
|
+
days: sets[2],
|
|
37
|
+
months: sets[3],
|
|
38
|
+
weekdays,
|
|
39
|
+
dayWildcard: fields[2] === "*",
|
|
40
|
+
weekdayWildcard: fields[4] === "*"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Whether the expression parses. */
|
|
44
|
+
function isValidCron(expr) {
|
|
45
|
+
return parseCron(expr) !== null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compute the next matching instant after `fromMs` (ms epoch), in local time,
|
|
49
|
+
* at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
|
|
50
|
+
* of the matching minute's start, or undefined when the calendar constraint
|
|
51
|
+
* can never match (for example `0 0 30 2 *`). The five-year horizon includes
|
|
52
|
+
* a full leap cycle, so a valid February 29 schedule remains reachable from
|
|
53
|
+
* every non-leap year.
|
|
54
|
+
*/
|
|
55
|
+
function nextRunAtMs(expr, fromMs) {
|
|
56
|
+
const schedule = parseCron(expr);
|
|
57
|
+
if (schedule === null) return void 0;
|
|
58
|
+
if (!hasPossibleCalendarDay(schedule)) return void 0;
|
|
59
|
+
const from = new Date(fromMs);
|
|
60
|
+
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
|
|
61
|
+
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
|
|
62
|
+
while (scan.getTime() <= limitMs) {
|
|
63
|
+
if (matches(schedule, scan)) return scan.getTime();
|
|
64
|
+
scan.setMinutes(scan.getMinutes() + 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
68
|
+
function hasPossibleCalendarDay(schedule) {
|
|
69
|
+
if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
|
|
70
|
+
const maximumDay = /* @__PURE__ */ new Map([
|
|
71
|
+
[1, 31],
|
|
72
|
+
[2, 29],
|
|
73
|
+
[3, 31],
|
|
74
|
+
[4, 30],
|
|
75
|
+
[5, 31],
|
|
76
|
+
[6, 30],
|
|
77
|
+
[7, 31],
|
|
78
|
+
[8, 31],
|
|
79
|
+
[9, 30],
|
|
80
|
+
[10, 31],
|
|
81
|
+
[11, 30],
|
|
82
|
+
[12, 31]
|
|
83
|
+
]);
|
|
84
|
+
for (const month of schedule.months) {
|
|
85
|
+
const maximum = maximumDay.get(month) ?? 0;
|
|
86
|
+
if ([...schedule.days].some((day) => day <= maximum)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
/** Parse one comma-list field into the match set. */
|
|
91
|
+
function parseField(field, min, max, out) {
|
|
92
|
+
if (field === "*") {
|
|
93
|
+
for (let value = min; value <= max; value++) out.add(value);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
for (const part of field.split(",")) {
|
|
97
|
+
if (part === "") return false;
|
|
98
|
+
const [range, stepRaw] = part.split("/");
|
|
99
|
+
let low;
|
|
100
|
+
let high;
|
|
101
|
+
if (range === "*") {
|
|
102
|
+
low = min;
|
|
103
|
+
high = max;
|
|
104
|
+
} else if (range.includes("-")) {
|
|
105
|
+
const [a, b] = range.split("-");
|
|
106
|
+
if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
|
|
107
|
+
low = Number(a);
|
|
108
|
+
high = Number(b);
|
|
109
|
+
} else if (isDigits(range)) {
|
|
110
|
+
low = Number(range);
|
|
111
|
+
high = Number(range);
|
|
112
|
+
} else return false;
|
|
113
|
+
if (low < min || high > max || low > high) return false;
|
|
114
|
+
const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
|
|
115
|
+
if (!Number.isInteger(step) || step < 1) return false;
|
|
116
|
+
for (let value = low; value <= high; value += step) out.add(value);
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
121
|
+
function matches(schedule, date) {
|
|
122
|
+
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
123
|
+
if (!schedule.hours.has(date.getHours())) return false;
|
|
124
|
+
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
125
|
+
const dayMatches = schedule.days.has(date.getDate());
|
|
126
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
127
|
+
if (schedule.dayWildcard) return weekdayMatches;
|
|
128
|
+
if (schedule.weekdayWildcard) return dayMatches;
|
|
129
|
+
return dayMatches || weekdayMatches;
|
|
130
|
+
}
|
|
131
|
+
function isDigits(value) {
|
|
132
|
+
return /^\d+$/.test(value);
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/dsh-home.ts
|
|
136
|
+
/**
|
|
137
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
138
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
139
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
140
|
+
*/
|
|
141
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
142
|
+
function expandHome(path, home = homedir()) {
|
|
143
|
+
if (path === "~") return home;
|
|
144
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
145
|
+
return path;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the DSH home directory.
|
|
149
|
+
* @param env - process environment to read DSH_HOME from.
|
|
150
|
+
* @param home - platform home directory fallback (test seam).
|
|
151
|
+
* @returns the absolute DSH home path.
|
|
152
|
+
*/
|
|
153
|
+
function resolveDshHome(env = process.env, home = homedir()) {
|
|
154
|
+
const raw = env.DSH_HOME;
|
|
155
|
+
if (raw !== void 0 && raw.trim() !== "") {
|
|
156
|
+
const expanded = expandHome(raw.trim(), home);
|
|
157
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
|
|
158
|
+
}
|
|
159
|
+
return join(home, ".dsh");
|
|
160
|
+
}
|
|
161
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
162
|
+
function dshHome() {
|
|
163
|
+
return resolveDshHome();
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/core/tasks.ts
|
|
167
|
+
/** Statuses a settled task may be archived from. */
|
|
168
|
+
const ARCHIVABLE_STATUSES = ["done", "failed"];
|
|
169
|
+
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
170
|
+
const TASK_PERMISSIONS = [
|
|
171
|
+
"read-only",
|
|
172
|
+
"workspace-write",
|
|
173
|
+
"danger-full-access"
|
|
174
|
+
];
|
|
175
|
+
/** Whether an unknown value is a known permission preset id. */
|
|
176
|
+
function isTaskPermission(value) {
|
|
177
|
+
return typeof value === "string" && TASK_PERMISSIONS.includes(value);
|
|
178
|
+
}
|
|
179
|
+
/** Statuses a user may move a card to manually (execution states are owned by the runner). */
|
|
180
|
+
const MANUAL_STATUSES = ["backlog", "todo"];
|
|
181
|
+
/** All valid statuses (closed union guard). */
|
|
182
|
+
const ALL_STATUSES = [
|
|
183
|
+
"backlog",
|
|
184
|
+
"todo",
|
|
185
|
+
"running",
|
|
186
|
+
"done",
|
|
187
|
+
"failed"
|
|
188
|
+
];
|
|
189
|
+
/** Brand an unknown string as a status; undefined when it is not one. */
|
|
190
|
+
function isTaskStatus(value) {
|
|
191
|
+
return typeof value === "string" && ALL_STATUSES.includes(value);
|
|
192
|
+
}
|
|
193
|
+
/** Whether a manual move target is allowed from the given status. */
|
|
194
|
+
function canMoveManually(from, to) {
|
|
195
|
+
return from !== "running" && MANUAL_STATUSES.includes(to);
|
|
196
|
+
}
|
|
197
|
+
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
198
|
+
function normalizeTargetId(value) {
|
|
199
|
+
const trimmed = value?.trim();
|
|
200
|
+
return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
201
|
+
}
|
|
202
|
+
/** Create a task from user input. */
|
|
203
|
+
function createTask(input, now, id) {
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
title: input.title.trim(),
|
|
207
|
+
description: input.description.trim(),
|
|
208
|
+
prompt: input.prompt.trim(),
|
|
209
|
+
status: "todo",
|
|
210
|
+
createdAt: now,
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
executions: [],
|
|
213
|
+
workspaceId: normalizeTargetId(input.workspaceId),
|
|
214
|
+
mode: normalizeTargetId(input.mode),
|
|
215
|
+
permission: isTaskPermission(input.permission) ? input.permission : void 0
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** Clone a task with an updated status and a fresh updatedAt. */
|
|
219
|
+
function withStatus(task, status, now) {
|
|
220
|
+
return {
|
|
221
|
+
...task,
|
|
222
|
+
status,
|
|
223
|
+
updatedAt: now
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Merge a schedule patch into a task's schedule rule (creating it when
|
|
228
|
+
* absent), with a fresh updatedAt. Keys present in the patch overwrite the
|
|
229
|
+
* current value — including explicit `undefined`, which clears a field (used
|
|
230
|
+
* to disarm `nextRunAt`); absent keys keep their current value.
|
|
231
|
+
*/
|
|
232
|
+
function withSchedule(task, patch, now) {
|
|
233
|
+
const current = task.schedule;
|
|
234
|
+
const schedule = {
|
|
235
|
+
enabled: current?.enabled ?? false,
|
|
236
|
+
cron: current?.cron ?? "",
|
|
237
|
+
nextRunAt: current?.nextRunAt,
|
|
238
|
+
lastTriggeredAt: current?.lastTriggeredAt
|
|
239
|
+
};
|
|
240
|
+
if ("enabled" in patch) schedule.enabled = patch.enabled ?? false;
|
|
241
|
+
if ("cron" in patch) schedule.cron = patch.cron ?? "";
|
|
242
|
+
if ("nextRunAt" in patch) schedule.nextRunAt = patch.nextRunAt;
|
|
243
|
+
if ("lastTriggeredAt" in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt;
|
|
244
|
+
return {
|
|
245
|
+
...task,
|
|
246
|
+
updatedAt: now,
|
|
247
|
+
schedule
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Open a fresh execution on a task: move it to 'running' and append a
|
|
252
|
+
* running execution record. Returns the new task and the new execution.
|
|
253
|
+
*/
|
|
254
|
+
function startExecution(task, now, executionId) {
|
|
255
|
+
const execution = {
|
|
256
|
+
id: executionId,
|
|
257
|
+
sessionId: void 0,
|
|
258
|
+
startedAt: now,
|
|
259
|
+
endedAt: void 0,
|
|
260
|
+
result: void 0,
|
|
261
|
+
error: void 0
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
task: {
|
|
265
|
+
...task,
|
|
266
|
+
status: "running",
|
|
267
|
+
updatedAt: now,
|
|
268
|
+
executions: [...task.executions, execution]
|
|
269
|
+
},
|
|
270
|
+
execution
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Settle a running execution: record the outcome and move the task into the
|
|
275
|
+
* matching column. No-op (returns the input task) when the execution is not
|
|
276
|
+
* the task's latest or is already settled.
|
|
277
|
+
*/
|
|
278
|
+
function settleExecution(task, executionId, outcome, now, error) {
|
|
279
|
+
const index = task.executions.findIndex((execution) => execution.id === executionId);
|
|
280
|
+
if (index === -1) return task;
|
|
281
|
+
const execution = task.executions[index];
|
|
282
|
+
if (execution.endedAt !== void 0) return task;
|
|
283
|
+
const settled = {
|
|
284
|
+
...execution,
|
|
285
|
+
endedAt: now,
|
|
286
|
+
result: outcome,
|
|
287
|
+
error
|
|
288
|
+
};
|
|
289
|
+
const executions = [...task.executions];
|
|
290
|
+
executions[index] = settled;
|
|
291
|
+
const status = outcome === "succeeded" ? "done" : outcome === "failed" ? "failed" : task.status === "running" ? "todo" : task.status;
|
|
292
|
+
return {
|
|
293
|
+
...task,
|
|
294
|
+
status,
|
|
295
|
+
updatedAt: now,
|
|
296
|
+
executions
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/core/store.ts
|
|
301
|
+
/**
|
|
302
|
+
* Legacy v1 browser persistence and the store seam used by pure client tests.
|
|
303
|
+
*
|
|
304
|
+
* Production v2 state is Host-authoritative. This backend is retained only to
|
|
305
|
+
* read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
|
|
306
|
+
* so it remains a read-only rollback copy after migration.
|
|
307
|
+
*
|
|
308
|
+
* The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
|
|
309
|
+
* channel later); tests run against the in-memory backend and a jsdom
|
|
310
|
+
* localStorage backend.
|
|
311
|
+
*/
|
|
312
|
+
/**
|
|
313
|
+
* Structural row check with the status left unvalidated (see {@link parseLedger}).
|
|
314
|
+
* The `schedule` field is deliberately NOT checked here: a malformed schedule
|
|
315
|
+
* never drops the task row — {@link normalizeSchedule} repairs or drops the
|
|
316
|
+
* schedule alone.
|
|
317
|
+
*/
|
|
318
|
+
function isTaskRecordShape(value) {
|
|
319
|
+
if (typeof value !== "object" || value === null) return false;
|
|
320
|
+
const record = value;
|
|
321
|
+
if (typeof record.id !== "string" || record.id === "") return false;
|
|
322
|
+
if (typeof record.title !== "string") return false;
|
|
323
|
+
if (typeof record.description !== "string") return false;
|
|
324
|
+
if (typeof record.prompt !== "string") return false;
|
|
325
|
+
if (typeof record.createdAt !== "number") return false;
|
|
326
|
+
if (typeof record.updatedAt !== "number") return false;
|
|
327
|
+
if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
|
|
328
|
+
if (record.mode !== void 0 && typeof record.mode !== "string") return false;
|
|
329
|
+
if (record.permission !== void 0 && typeof record.permission !== "string") return false;
|
|
330
|
+
if (!Array.isArray(record.executions)) return false;
|
|
331
|
+
for (const execution of record.executions) {
|
|
332
|
+
if (typeof execution !== "object" || execution === null) return false;
|
|
333
|
+
const entry = execution;
|
|
334
|
+
if (typeof entry.id !== "string") return false;
|
|
335
|
+
if (entry.sessionId !== void 0 && typeof entry.sessionId !== "string") return false;
|
|
336
|
+
if (typeof entry.startedAt !== "number") return false;
|
|
337
|
+
if (entry.endedAt !== void 0 && typeof entry.endedAt !== "number") return false;
|
|
338
|
+
if (entry.result !== void 0 && entry.result !== "succeeded" && entry.result !== "failed" && entry.result !== "cancelled") return false;
|
|
339
|
+
if (entry.error !== void 0 && typeof entry.error !== "string") return false;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
/** Normalize an unknown persisted status back into the closed status union. */
|
|
344
|
+
function normalizeStatus(status) {
|
|
345
|
+
return isTaskStatus(status) ? status : "todo";
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Repair a persisted schedule rule: drop rules without a usable cron string,
|
|
349
|
+
* coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
|
|
350
|
+
* when missing (a fresh recompute or the next tick fixes them).
|
|
351
|
+
*/
|
|
352
|
+
function normalizeSchedule(schedule) {
|
|
353
|
+
if (typeof schedule !== "object" || schedule === null) return void 0;
|
|
354
|
+
const rule = schedule;
|
|
355
|
+
if (typeof rule.cron !== "string") return void 0;
|
|
356
|
+
if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
|
|
357
|
+
return {
|
|
358
|
+
enabled: rule.enabled === true,
|
|
359
|
+
cron: rule.cron,
|
|
360
|
+
nextRunAt: typeof rule.nextRunAt === "number" ? rule.nextRunAt : void 0,
|
|
361
|
+
lastTriggeredAt: typeof rule.lastTriggeredAt === "number" ? rule.lastTriggeredAt : void 0
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/** Parse + validate a persisted ledger document; invalid rows are dropped. */
|
|
365
|
+
function parseLedger(raw) {
|
|
366
|
+
if (raw === null) return [];
|
|
367
|
+
let parsed;
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(raw);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error("[dsh-task-board] persisted task ledger is not valid JSON; starting empty", error);
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
if (!Array.isArray(parsed)) {
|
|
375
|
+
console.error("[dsh-task-board] persisted task ledger is not an array; starting empty");
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const tasks = [];
|
|
379
|
+
for (const row of parsed) {
|
|
380
|
+
if (!isTaskRecordShape(row)) {
|
|
381
|
+
console.warn("[dsh-task-board] dropping invalid task row from persisted ledger", row);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const task = {
|
|
385
|
+
...row,
|
|
386
|
+
status: normalizeStatus(row.status)
|
|
387
|
+
};
|
|
388
|
+
task.schedule = normalizeSchedule(row.schedule);
|
|
389
|
+
task.workspaceId = normalizeTargetId(row.workspaceId);
|
|
390
|
+
task.mode = normalizeTargetId(row.mode);
|
|
391
|
+
task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
|
|
392
|
+
task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
|
|
393
|
+
tasks.push(task);
|
|
394
|
+
}
|
|
395
|
+
return tasks;
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/core/use-cases/task-archive.ts
|
|
399
|
+
/**
|
|
400
|
+
* Archive one task: only settled statuses (done/failed) can be archived;
|
|
401
|
+
* a running or not-yet-settled task stays on the board (its runner still
|
|
402
|
+
* owns its lifecycle). Archiving disarms a schedule; already-archived tasks
|
|
403
|
+
* are a no-op.
|
|
404
|
+
*/
|
|
405
|
+
function applyArchiveTask(tasks, id, now) {
|
|
406
|
+
let applied = false;
|
|
407
|
+
return {
|
|
408
|
+
tasks: tasks.map((task) => {
|
|
409
|
+
if (task.id !== id || task.archivedAt !== void 0) return task;
|
|
410
|
+
if (!ARCHIVABLE_STATUSES.includes(task.status)) return task;
|
|
411
|
+
applied = true;
|
|
412
|
+
const schedule = task.schedule === void 0 ? void 0 : {
|
|
413
|
+
...task.schedule,
|
|
414
|
+
enabled: false,
|
|
415
|
+
nextRunAt: void 0
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
...task,
|
|
419
|
+
...schedule === void 0 ? {} : { schedule },
|
|
420
|
+
archivedAt: now,
|
|
421
|
+
updatedAt: now
|
|
422
|
+
};
|
|
423
|
+
}),
|
|
424
|
+
archived: applied
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** Restore one task back onto the main board (clears the archive marker). */
|
|
428
|
+
function applyRestoreTask(tasks, id, now) {
|
|
429
|
+
let applied = false;
|
|
430
|
+
return {
|
|
431
|
+
tasks: tasks.map((task) => {
|
|
432
|
+
if (task.id !== id || task.archivedAt === void 0) return task;
|
|
433
|
+
applied = true;
|
|
434
|
+
const { archivedAt: _archived, ...rest } = task;
|
|
435
|
+
return {
|
|
436
|
+
...rest,
|
|
437
|
+
updatedAt: now
|
|
438
|
+
};
|
|
439
|
+
}),
|
|
440
|
+
archived: applied
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/core/use-cases/task-create.ts
|
|
445
|
+
/**
|
|
446
|
+
* Create-task use case: mint a new task from user input, rejecting a blank
|
|
447
|
+
* title. Pure ledger transition (no persistence or notify — the controller
|
|
448
|
+
* orchestrates those), so it is unit-testable without any runtime face.
|
|
449
|
+
*/
|
|
450
|
+
/**
|
|
451
|
+
* Apply a create against the current ledger. Returns the new task and the
|
|
452
|
+
* appended ledger, or the unchanged ledger when the title is blank.
|
|
453
|
+
* @param tasks - current ledger.
|
|
454
|
+
* @param input - raw user input (title/description/prompt + optional schedule).
|
|
455
|
+
* @param now - clock instant (ms epoch).
|
|
456
|
+
* @param id - minted task id.
|
|
457
|
+
*/
|
|
458
|
+
function applyCreateTask(tasks, input, now, id) {
|
|
459
|
+
if (input.title.trim() === "") return {
|
|
460
|
+
task: void 0,
|
|
461
|
+
tasks
|
|
462
|
+
};
|
|
463
|
+
let task = createTask(input, now, id);
|
|
464
|
+
const requested = input.schedule;
|
|
465
|
+
if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
|
|
466
|
+
const cron = requested.cron.trim();
|
|
467
|
+
task = withSchedule(task, {
|
|
468
|
+
enabled: true,
|
|
469
|
+
cron,
|
|
470
|
+
nextRunAt: nextRunAtMs(cron, now)
|
|
471
|
+
}, now);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
task,
|
|
475
|
+
tasks: [...tasks, task]
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/core/use-cases/task-delete.ts
|
|
480
|
+
/**
|
|
481
|
+
* Apply a delete across the ledger. The selection (a task id) is cleared when
|
|
482
|
+
* it matches the removed task, so the UI never lingers on a vanished detail.
|
|
483
|
+
* @param tasks - current ledger.
|
|
484
|
+
* @param selectedTaskId - the currently selected task id (may be undefined).
|
|
485
|
+
* @param id - the task to remove.
|
|
486
|
+
*/
|
|
487
|
+
function applyDeleteTask(tasks, selectedTaskId, id) {
|
|
488
|
+
return {
|
|
489
|
+
tasks: tasks.filter((task) => task.id !== id),
|
|
490
|
+
selectionCleared: selectedTaskId === id
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/core/use-cases/task-schedule.ts
|
|
495
|
+
/**
|
|
496
|
+
* Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
|
|
497
|
+
* Pure ledger transitions (no persistence or notify — the controller
|
|
498
|
+
* orchestrates those). Validation and next-run computation live here, sharing
|
|
499
|
+
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* Set an on-board task's schedule rule. A blank or invalid cron, or an
|
|
503
|
+
* archived task, is rejected (state untouched); an enabled rule computes the
|
|
504
|
+
* next run instant immediately, a disabled one carries no next-run instant.
|
|
505
|
+
* @param tasks - current ledger.
|
|
506
|
+
* @param id - the task to schedule.
|
|
507
|
+
* @param patch - rule fields to change (absent fields keep their current value).
|
|
508
|
+
* @param now - clock instant (ms epoch).
|
|
509
|
+
*/
|
|
510
|
+
function applySetSchedule(tasks, id, patch, now) {
|
|
511
|
+
const task = tasks.find((candidate) => candidate.id === id);
|
|
512
|
+
if (task === void 0 || task.archivedAt !== void 0) return {
|
|
513
|
+
tasks,
|
|
514
|
+
applied: false
|
|
515
|
+
};
|
|
516
|
+
const current = task.schedule;
|
|
517
|
+
const cron = (patch.cron ?? current?.cron ?? "").trim();
|
|
518
|
+
if (cron === "" || !isValidCron(cron)) return {
|
|
519
|
+
tasks,
|
|
520
|
+
applied: false
|
|
521
|
+
};
|
|
522
|
+
const enabled = patch.enabled ?? current?.enabled ?? false;
|
|
523
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
|
|
524
|
+
if (enabled && nextRunAt === void 0) return {
|
|
525
|
+
tasks,
|
|
526
|
+
applied: false
|
|
527
|
+
};
|
|
528
|
+
return {
|
|
529
|
+
tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
|
|
530
|
+
enabled,
|
|
531
|
+
cron,
|
|
532
|
+
nextRunAt
|
|
533
|
+
}, now) : candidate),
|
|
534
|
+
applied: true
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Roll a task's schedule rule forward (scheduler callback): persist the next
|
|
539
|
+
* due instant and the trigger instant. No-op for tasks without a rule (deleted
|
|
540
|
+
* mid-tick, for example).
|
|
541
|
+
* @param tasks - current ledger.
|
|
542
|
+
* @param id - the task to roll forward.
|
|
543
|
+
* @param nextRunAt - next due instant (may be undefined to clear).
|
|
544
|
+
* @param lastTriggeredAt - the trigger instant of this run.
|
|
545
|
+
* @param now - clock instant (ms epoch).
|
|
546
|
+
*/
|
|
547
|
+
function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
|
|
548
|
+
return tasks.map((task) => task.id === id && task.archivedAt === void 0 && task.schedule !== void 0 ? withSchedule(task, {
|
|
549
|
+
nextRunAt,
|
|
550
|
+
lastTriggeredAt
|
|
551
|
+
}, now) : task);
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/core/use-cases/task-update.ts
|
|
555
|
+
/**
|
|
556
|
+
* Update-task use case: apply an editable-field patch (title/description/
|
|
557
|
+
* prompt plus the execution targets workspaceId/mode/permission) with a
|
|
558
|
+
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
559
|
+
* controller orchestrates those).
|
|
560
|
+
*
|
|
561
|
+
* An explicit `undefined` in the patch clears the field (the task falls
|
|
562
|
+
* back to the runtime default); an unknown permission string is ignored so
|
|
563
|
+
* stale UI can never persist a value the execution service rejects.
|
|
564
|
+
*/
|
|
565
|
+
/** Keep an unknown permission string from entering the ledger. */
|
|
566
|
+
function normalizePermission(current, value) {
|
|
567
|
+
if (value === void 0) return void 0;
|
|
568
|
+
return isTaskPermission(value) ? value : current;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Apply an update across the ledger. Tasks that do not match the id are left
|
|
572
|
+
* untouched; the matched task receives the patch plus a fresh updatedAt.
|
|
573
|
+
* @param tasks - current ledger.
|
|
574
|
+
* @param id - the task to update.
|
|
575
|
+
* @param patch - editable-field changes.
|
|
576
|
+
* @param now - clock instant (ms epoch).
|
|
577
|
+
*/
|
|
578
|
+
function applyUpdateTask(tasks, id, patch, now) {
|
|
579
|
+
return tasks.map((task) => {
|
|
580
|
+
if (task.id !== id) return task;
|
|
581
|
+
const workspaceId = "workspaceId" in patch ? normalizeTargetId(patch.workspaceId) : void 0;
|
|
582
|
+
const mode = "mode" in patch ? normalizeTargetId(patch.mode) : void 0;
|
|
583
|
+
const permission = "permission" in patch ? normalizePermission(task.permission, patch.permission) : void 0;
|
|
584
|
+
const next = {
|
|
585
|
+
...task,
|
|
586
|
+
...patch,
|
|
587
|
+
updatedAt: now
|
|
588
|
+
};
|
|
589
|
+
if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
|
|
590
|
+
if (mode !== void 0 || "mode" in patch) next.mode = mode;
|
|
591
|
+
if (permission !== void 0 || "permission" in patch) next.permission = permission;
|
|
592
|
+
return next;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
//#endregion
|
|
596
|
+
//#region src/protocol.ts
|
|
597
|
+
const TASK_BOARD_API_PREFIX = "/api/task-board";
|
|
598
|
+
function record(value) {
|
|
599
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
600
|
+
}
|
|
601
|
+
function exactKeys(value, allowed) {
|
|
602
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
603
|
+
}
|
|
604
|
+
function optionalString(value) {
|
|
605
|
+
return value === void 0 || typeof value === "string";
|
|
606
|
+
}
|
|
607
|
+
const FORBIDDEN_IMPORT_FIELDS = /* @__PURE__ */ new Set([
|
|
608
|
+
"args",
|
|
609
|
+
"command",
|
|
610
|
+
"executable",
|
|
611
|
+
"powershell",
|
|
612
|
+
"shell"
|
|
613
|
+
]);
|
|
614
|
+
function hasForbiddenImportField(value) {
|
|
615
|
+
if (Array.isArray(value)) return value.some(hasForbiddenImportField);
|
|
616
|
+
const row = record(value);
|
|
617
|
+
if (row === void 0) return false;
|
|
618
|
+
return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenImportField(nested));
|
|
619
|
+
}
|
|
620
|
+
function optionalFiniteNumber(value) {
|
|
621
|
+
return value === void 0 || typeof value === "number" && Number.isFinite(value);
|
|
622
|
+
}
|
|
623
|
+
function validImportedKnownFields(value) {
|
|
624
|
+
if (value.schedule !== void 0) {
|
|
625
|
+
const schedule = record(value.schedule);
|
|
626
|
+
if (schedule === void 0 || typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
|
|
627
|
+
if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
|
|
628
|
+
}
|
|
629
|
+
if (value.executions !== void 0) {
|
|
630
|
+
if (!Array.isArray(value.executions)) return false;
|
|
631
|
+
for (const item of value.executions) {
|
|
632
|
+
const execution = record(item);
|
|
633
|
+
if (execution === void 0 || typeof execution.id !== "string" || !optionalString(execution.sessionId)) return false;
|
|
634
|
+
if (typeof execution.startedAt !== "number" || !Number.isFinite(execution.startedAt)) return false;
|
|
635
|
+
if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false;
|
|
636
|
+
if (execution.result !== void 0 && ![
|
|
637
|
+
"succeeded",
|
|
638
|
+
"failed",
|
|
639
|
+
"cancelled"
|
|
640
|
+
].includes(String(execution.result))) return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
function importedTask(value) {
|
|
646
|
+
const input = record(value);
|
|
647
|
+
if (input === void 0 || hasForbiddenImportField(input) || !validImportedKnownFields(input)) return void 0;
|
|
648
|
+
const task = parseLedger(JSON.stringify([value]))[0];
|
|
649
|
+
if (task === void 0) return void 0;
|
|
650
|
+
return {
|
|
651
|
+
id: task.id,
|
|
652
|
+
title: task.title,
|
|
653
|
+
description: task.description,
|
|
654
|
+
prompt: task.prompt,
|
|
655
|
+
status: task.status,
|
|
656
|
+
createdAt: task.createdAt,
|
|
657
|
+
updatedAt: task.updatedAt,
|
|
658
|
+
executions: task.executions.map((execution) => ({
|
|
659
|
+
id: execution.id,
|
|
660
|
+
sessionId: execution.sessionId,
|
|
661
|
+
startedAt: execution.startedAt,
|
|
662
|
+
endedAt: execution.endedAt,
|
|
663
|
+
result: execution.result,
|
|
664
|
+
error: execution.error
|
|
665
|
+
})),
|
|
666
|
+
...task.schedule === void 0 ? {} : { schedule: {
|
|
667
|
+
enabled: task.schedule.enabled,
|
|
668
|
+
cron: task.schedule.cron,
|
|
669
|
+
nextRunAt: task.schedule.nextRunAt,
|
|
670
|
+
lastTriggeredAt: task.schedule.lastTriggeredAt
|
|
671
|
+
} },
|
|
672
|
+
...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
|
|
673
|
+
...task.mode === void 0 ? {} : { mode: task.mode },
|
|
674
|
+
...task.permission === void 0 ? {} : { permission: task.permission },
|
|
675
|
+
...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt }
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function createInput(value) {
|
|
679
|
+
const input = record(value);
|
|
680
|
+
if (input === void 0 || !exactKeys(input, [
|
|
681
|
+
"title",
|
|
682
|
+
"description",
|
|
683
|
+
"prompt",
|
|
684
|
+
"workspaceId",
|
|
685
|
+
"mode",
|
|
686
|
+
"permission",
|
|
687
|
+
"schedule"
|
|
688
|
+
])) return false;
|
|
689
|
+
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
690
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false;
|
|
691
|
+
if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
|
|
692
|
+
if (input.schedule !== void 0) {
|
|
693
|
+
const schedule = record(input.schedule);
|
|
694
|
+
if (schedule === void 0 || !exactKeys(schedule, ["enabled", "cron"])) return false;
|
|
695
|
+
if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
|
|
696
|
+
}
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
function updatePatch(value) {
|
|
700
|
+
const patch = record(value);
|
|
701
|
+
if (patch === void 0 || !exactKeys(patch, [
|
|
702
|
+
"title",
|
|
703
|
+
"description",
|
|
704
|
+
"prompt",
|
|
705
|
+
"workspaceId",
|
|
706
|
+
"mode",
|
|
707
|
+
"permission"
|
|
708
|
+
])) return false;
|
|
709
|
+
for (const key of [
|
|
710
|
+
"title",
|
|
711
|
+
"description",
|
|
712
|
+
"prompt",
|
|
713
|
+
"workspaceId",
|
|
714
|
+
"mode"
|
|
715
|
+
]) if (!optionalString(patch[key])) return false;
|
|
716
|
+
return patch.permission === void 0 || isTaskPermission(patch.permission);
|
|
717
|
+
}
|
|
718
|
+
function schedulePatch(value) {
|
|
719
|
+
const patch = record(value);
|
|
720
|
+
return patch !== void 0 && exactKeys(patch, ["enabled", "cron"]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string");
|
|
721
|
+
}
|
|
722
|
+
function parseActionEnvelope(value) {
|
|
723
|
+
const envelope = record(value);
|
|
724
|
+
if (envelope === void 0 || !exactKeys(envelope, ["requestId", "action"])) return void 0;
|
|
725
|
+
if (typeof envelope.requestId !== "string" || envelope.requestId.trim() === "" || envelope.requestId.length > 256) return void 0;
|
|
726
|
+
const action = record(envelope.action);
|
|
727
|
+
if (action === void 0 || typeof action.kind !== "string") return void 0;
|
|
728
|
+
const taskId = typeof action.taskId === "string" && action.taskId !== "" ? action.taskId : void 0;
|
|
729
|
+
switch (action.kind) {
|
|
730
|
+
case "import":
|
|
731
|
+
if (!exactKeys(action, [
|
|
732
|
+
"kind",
|
|
733
|
+
"sourceId",
|
|
734
|
+
"tasks"
|
|
735
|
+
])) return void 0;
|
|
736
|
+
if (typeof action.sourceId !== "string" || action.sourceId === "" || !Array.isArray(action.tasks)) return void 0;
|
|
737
|
+
{
|
|
738
|
+
const tasks = action.tasks.map(importedTask);
|
|
739
|
+
return tasks.every((task) => task !== void 0) ? {
|
|
740
|
+
requestId: envelope.requestId,
|
|
741
|
+
action: {
|
|
742
|
+
kind: "import",
|
|
743
|
+
sourceId: action.sourceId,
|
|
744
|
+
tasks
|
|
745
|
+
}
|
|
746
|
+
} : void 0;
|
|
747
|
+
}
|
|
748
|
+
case "create":
|
|
749
|
+
if (!exactKeys(action, [
|
|
750
|
+
"kind",
|
|
751
|
+
"id",
|
|
752
|
+
"input"
|
|
753
|
+
])) return void 0;
|
|
754
|
+
return typeof action.id === "string" && action.id !== "" && createInput(action.input) ? {
|
|
755
|
+
requestId: envelope.requestId,
|
|
756
|
+
action
|
|
757
|
+
} : void 0;
|
|
758
|
+
case "update":
|
|
759
|
+
if (!exactKeys(action, [
|
|
760
|
+
"kind",
|
|
761
|
+
"taskId",
|
|
762
|
+
"patch"
|
|
763
|
+
])) return void 0;
|
|
764
|
+
return taskId !== void 0 && updatePatch(action.patch) ? {
|
|
765
|
+
requestId: envelope.requestId,
|
|
766
|
+
action
|
|
767
|
+
} : void 0;
|
|
768
|
+
case "set-schedule":
|
|
769
|
+
if (!exactKeys(action, [
|
|
770
|
+
"kind",
|
|
771
|
+
"taskId",
|
|
772
|
+
"patch"
|
|
773
|
+
])) return void 0;
|
|
774
|
+
return taskId !== void 0 && schedulePatch(action.patch) ? {
|
|
775
|
+
requestId: envelope.requestId,
|
|
776
|
+
action
|
|
777
|
+
} : void 0;
|
|
778
|
+
case "move":
|
|
779
|
+
if (!exactKeys(action, [
|
|
780
|
+
"kind",
|
|
781
|
+
"taskId",
|
|
782
|
+
"status"
|
|
783
|
+
])) return void 0;
|
|
784
|
+
return taskId !== void 0 && isTaskStatus(action.status) ? {
|
|
785
|
+
requestId: envelope.requestId,
|
|
786
|
+
action
|
|
787
|
+
} : void 0;
|
|
788
|
+
case "delete":
|
|
789
|
+
case "archive":
|
|
790
|
+
case "restore":
|
|
791
|
+
case "run":
|
|
792
|
+
case "rerun":
|
|
793
|
+
if (!exactKeys(action, ["kind", "taskId"])) return void 0;
|
|
794
|
+
return taskId === void 0 ? void 0 : {
|
|
795
|
+
requestId: envelope.requestId,
|
|
796
|
+
action
|
|
797
|
+
};
|
|
798
|
+
default: return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/host-ledger.ts
|
|
803
|
+
const MAX_REQUEST_CACHE = 256;
|
|
804
|
+
function timeZone() {
|
|
805
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
|
|
806
|
+
}
|
|
807
|
+
function cloneTasks(tasks) {
|
|
808
|
+
return JSON.parse(JSON.stringify(tasks));
|
|
809
|
+
}
|
|
810
|
+
function hasOpenExecution(task) {
|
|
811
|
+
return task.executions.some((execution) => execution.endedAt === void 0);
|
|
812
|
+
}
|
|
813
|
+
function processIsAlive(pid) {
|
|
814
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
815
|
+
try {
|
|
816
|
+
process.kill(pid, 0);
|
|
817
|
+
return true;
|
|
818
|
+
} catch (error) {
|
|
819
|
+
return error.code !== "ESRCH";
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function betterExecution(a, b) {
|
|
823
|
+
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
824
|
+
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
825
|
+
return (b.endedAt ?? b.startedAt) >= (a.endedAt ?? a.startedAt) ? b : a;
|
|
826
|
+
}
|
|
827
|
+
function mergeTask(a, b) {
|
|
828
|
+
const newer = b.updatedAt > a.updatedAt ? b : a;
|
|
829
|
+
const byId = /* @__PURE__ */ new Map();
|
|
830
|
+
for (const entry of [...a.executions, ...b.executions]) {
|
|
831
|
+
const previous = byId.get(entry.id);
|
|
832
|
+
byId.set(entry.id, previous === void 0 ? entry : betterExecution(previous, entry));
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
...newer,
|
|
836
|
+
executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
function parseHostTasks(values, now) {
|
|
840
|
+
const rawById = /* @__PURE__ */ new Map();
|
|
841
|
+
for (const value of values) {
|
|
842
|
+
if (typeof value !== "object" || value === null) continue;
|
|
843
|
+
const raw = value;
|
|
844
|
+
if (typeof raw.id === "string") rawById.set(raw.id, raw);
|
|
845
|
+
}
|
|
846
|
+
return parseLedger(JSON.stringify(values)).map((task) => {
|
|
847
|
+
const rawSchedule = rawById.get(task.id)?.schedule;
|
|
848
|
+
if (typeof rawSchedule !== "object" || rawSchedule === null) return task;
|
|
849
|
+
const schedule = rawSchedule;
|
|
850
|
+
if (typeof schedule.cron !== "string" || isValidCron(schedule.cron)) return task;
|
|
851
|
+
return {
|
|
852
|
+
...task,
|
|
853
|
+
schedule: {
|
|
854
|
+
enabled: false,
|
|
855
|
+
cron: schedule.cron,
|
|
856
|
+
nextRunAt: void 0,
|
|
857
|
+
lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
var HostTaskLedger = class {
|
|
863
|
+
now;
|
|
864
|
+
document;
|
|
865
|
+
listeners = /* @__PURE__ */ new Set();
|
|
866
|
+
requestCache = /* @__PURE__ */ new Map();
|
|
867
|
+
lockToken = crypto.randomUUID();
|
|
868
|
+
lockFd;
|
|
869
|
+
file;
|
|
870
|
+
lockFile;
|
|
871
|
+
constructor(dir = join(dshHome(), "task-board"), now = Date.now) {
|
|
872
|
+
this.now = now;
|
|
873
|
+
mkdirSync(dir, { recursive: true });
|
|
874
|
+
this.file = join(dir, "ledger-v2.json");
|
|
875
|
+
this.lockFile = join(dir, "ledger-v2.lock");
|
|
876
|
+
this.lockFd = this.acquireLock();
|
|
877
|
+
try {
|
|
878
|
+
this.document = this.load(dir);
|
|
879
|
+
for (const request of this.document.recentRequests) this.requestCache.set(request.requestId, { fingerprint: request.fingerprint });
|
|
880
|
+
this.repairSchedules(true);
|
|
881
|
+
this.reconcileInterruptedStarts();
|
|
882
|
+
this.commit(false);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
this.dispose();
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
state() {
|
|
889
|
+
const { importedSources: _imports, ...scheduler } = this.document.scheduler;
|
|
890
|
+
return {
|
|
891
|
+
revision: this.document.revision,
|
|
892
|
+
tasks: cloneTasks(this.document.tasks),
|
|
893
|
+
scheduler: { ...scheduler }
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
subscribe(listener) {
|
|
897
|
+
this.listeners.add(listener);
|
|
898
|
+
return () => {
|
|
899
|
+
this.listeners.delete(listener);
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
dispose() {
|
|
903
|
+
const fd = this.lockFd;
|
|
904
|
+
if (fd === void 0) return;
|
|
905
|
+
this.lockFd = void 0;
|
|
906
|
+
closeSync(fd);
|
|
907
|
+
try {
|
|
908
|
+
if (JSON.parse(readFileSync(this.lockFile, "utf8")).token === this.lockToken) unlinkSync(this.lockFile);
|
|
909
|
+
} catch {}
|
|
910
|
+
}
|
|
911
|
+
applyRequest(requestId, action) {
|
|
912
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(action)).digest("hex");
|
|
913
|
+
const cached = this.requestCache.get(requestId);
|
|
914
|
+
if (cached !== void 0) {
|
|
915
|
+
if (cached.fingerprint !== fingerprint) throw new Error("request id was reused with a different action");
|
|
916
|
+
return { state: this.state() };
|
|
917
|
+
}
|
|
918
|
+
this.requestCache.set(requestId, { fingerprint });
|
|
919
|
+
while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value);
|
|
920
|
+
this.syncRecentRequests();
|
|
921
|
+
try {
|
|
922
|
+
return this.apply(action);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
this.requestCache.delete(requestId);
|
|
925
|
+
this.syncRecentRequests();
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
openScheduled(taskId, nextRunAt, triggeredAt) {
|
|
930
|
+
const task = this.document.tasks.find((item) => item.id === taskId);
|
|
931
|
+
if (task === void 0 || task.archivedAt !== void 0) return void 0;
|
|
932
|
+
if (task.status === "running" || hasOpenExecution(task)) {
|
|
933
|
+
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)];
|
|
934
|
+
this.commit();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const opened = startExecution(task, triggeredAt, crypto.randomUUID());
|
|
938
|
+
this.document.tasks = this.document.tasks.map((item) => item.id === taskId ? opened.task : item);
|
|
939
|
+
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)];
|
|
940
|
+
this.commit();
|
|
941
|
+
return opened;
|
|
942
|
+
}
|
|
943
|
+
skipMissed(now) {
|
|
944
|
+
let changed = false;
|
|
945
|
+
this.document.tasks = this.document.tasks.map((task) => {
|
|
946
|
+
const schedule = task.schedule;
|
|
947
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) return task;
|
|
948
|
+
changed = true;
|
|
949
|
+
return {
|
|
950
|
+
...task,
|
|
951
|
+
schedule: {
|
|
952
|
+
...schedule,
|
|
953
|
+
nextRunAt: nextRunAtMs(schedule.cron, now)
|
|
954
|
+
},
|
|
955
|
+
updatedAt: now
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
if (changed) this.commit();
|
|
959
|
+
}
|
|
960
|
+
setScheduler(patch) {
|
|
961
|
+
this.document.scheduler = {
|
|
962
|
+
...this.document.scheduler,
|
|
963
|
+
...patch
|
|
964
|
+
};
|
|
965
|
+
this.commit(false);
|
|
966
|
+
}
|
|
967
|
+
attachSession(taskId, executionId, sessionId) {
|
|
968
|
+
const now = this.now();
|
|
969
|
+
this.document.tasks = this.document.tasks.map((task) => task.id !== taskId ? task : {
|
|
970
|
+
...task,
|
|
971
|
+
updatedAt: now,
|
|
972
|
+
executions: task.executions.map((entry) => entry.id === executionId ? {
|
|
973
|
+
...entry,
|
|
974
|
+
sessionId
|
|
975
|
+
} : entry)
|
|
976
|
+
});
|
|
977
|
+
this.commit();
|
|
978
|
+
}
|
|
979
|
+
settle(taskId, executionId, outcome, error) {
|
|
980
|
+
this.document.tasks = this.document.tasks.map((task) => task.id === taskId ? settleExecution(task, executionId, outcome, this.now(), error) : task);
|
|
981
|
+
this.commit();
|
|
982
|
+
}
|
|
983
|
+
apply(action) {
|
|
984
|
+
const now = this.now();
|
|
985
|
+
let run;
|
|
986
|
+
switch (action.kind) {
|
|
987
|
+
case "import": {
|
|
988
|
+
const sources = new Set(this.document.scheduler.importedSources ?? []);
|
|
989
|
+
if (sources.has(action.sourceId)) return { state: this.state() };
|
|
990
|
+
const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && !isValidCron(task.schedule.cron)).map((task) => task.id);
|
|
991
|
+
const incoming = parseHostTasks(action.tasks, now);
|
|
992
|
+
const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
|
|
993
|
+
for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
|
|
994
|
+
this.document.tasks = [...merged.values()];
|
|
995
|
+
this.document.scheduler.importedSources = [...sources, action.sourceId];
|
|
996
|
+
this.document.scheduler.error = invalidScheduleIds.length === 0 ? void 0 : `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}`;
|
|
997
|
+
this.repairSchedules(true, false);
|
|
998
|
+
this.reconcileInterruptedStarts(false);
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
case "create": {
|
|
1002
|
+
if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
|
|
1003
|
+
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === void 0)) throw new Error("invalid schedule");
|
|
1004
|
+
const result = applyCreateTask(this.document.tasks, action.input, now, action.id);
|
|
1005
|
+
if (result.task === void 0) throw new Error("invalid task");
|
|
1006
|
+
this.document.tasks = [...result.tasks];
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
case "update": {
|
|
1010
|
+
const task = this.document.tasks.find((task) => task.id === action.taskId);
|
|
1011
|
+
if (task === void 0) throw new Error("task not found");
|
|
1012
|
+
if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
1013
|
+
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
case "delete":
|
|
1017
|
+
{
|
|
1018
|
+
const task = this.document.tasks.find((task) => task.id === action.taskId);
|
|
1019
|
+
if (task === void 0) throw new Error("task not found");
|
|
1020
|
+
if (task.status === "running" || hasOpenExecution(task)) throw new Error("running task cannot be deleted");
|
|
1021
|
+
}
|
|
1022
|
+
this.document.tasks = [...applyDeleteTask(this.document.tasks, void 0, action.taskId).tasks];
|
|
1023
|
+
break;
|
|
1024
|
+
case "move": {
|
|
1025
|
+
const task = this.document.tasks.find((item) => item.id === action.taskId);
|
|
1026
|
+
if (task === void 0) throw new Error("task not found");
|
|
1027
|
+
if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
1028
|
+
if (task.status === "running" || hasOpenExecution(task)) throw new Error("running task cannot be moved");
|
|
1029
|
+
if (!canMoveManually(task.status, action.status)) throw new Error("invalid manual status");
|
|
1030
|
+
this.document.tasks = this.document.tasks.map((item) => item.id === action.taskId ? withStatus(item, action.status, now) : item);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
case "archive": {
|
|
1034
|
+
const result = applyArchiveTask(this.document.tasks, action.taskId, now);
|
|
1035
|
+
if (!result.archived) throw new Error("task cannot be archived");
|
|
1036
|
+
this.document.tasks = [...result.tasks];
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
case "restore": {
|
|
1040
|
+
const result = applyRestoreTask(this.document.tasks, action.taskId, now);
|
|
1041
|
+
if (!result.archived) throw new Error("task is not archived");
|
|
1042
|
+
this.document.tasks = [...result.tasks];
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
case "set-schedule": {
|
|
1046
|
+
if (this.document.tasks.find((task) => task.id === action.taskId)?.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
1047
|
+
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now);
|
|
1048
|
+
if (!result.applied) throw new Error("invalid schedule");
|
|
1049
|
+
this.document.tasks = [...result.tasks];
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
case "rerun":
|
|
1053
|
+
case "run": {
|
|
1054
|
+
const task = this.document.tasks.find((item) => item.id === action.taskId);
|
|
1055
|
+
if (task?.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
1056
|
+
if (task === void 0 || task.status === "running" || hasOpenExecution(task)) throw new Error("task is already running or missing");
|
|
1057
|
+
run = startExecution(action.kind === "rerun" ? withStatus(task, "todo", now) : task, now, crypto.randomUUID());
|
|
1058
|
+
this.document.tasks = this.document.tasks.map((item) => item.id === task.id ? run.task : item);
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
this.commit();
|
|
1063
|
+
return {
|
|
1064
|
+
state: this.state(),
|
|
1065
|
+
...run === void 0 ? {} : { run }
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
repairSchedules(skipPast, persist = true) {
|
|
1069
|
+
const now = this.now();
|
|
1070
|
+
let changed = false;
|
|
1071
|
+
this.document.tasks = this.document.tasks.map((task) => {
|
|
1072
|
+
const schedule = task.schedule;
|
|
1073
|
+
if (schedule === void 0 || !schedule.enabled) return task;
|
|
1074
|
+
if (!skipPast && schedule.nextRunAt !== void 0) return task;
|
|
1075
|
+
const next = nextRunAtMs(schedule.cron, now);
|
|
1076
|
+
if (next === void 0) {
|
|
1077
|
+
changed = true;
|
|
1078
|
+
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
|
|
1079
|
+
return {
|
|
1080
|
+
...task,
|
|
1081
|
+
schedule: {
|
|
1082
|
+
...schedule,
|
|
1083
|
+
enabled: false,
|
|
1084
|
+
nextRunAt: void 0
|
|
1085
|
+
},
|
|
1086
|
+
updatedAt: now
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
if (schedule.nextRunAt === next) return task;
|
|
1090
|
+
changed = true;
|
|
1091
|
+
return {
|
|
1092
|
+
...task,
|
|
1093
|
+
schedule: {
|
|
1094
|
+
...schedule,
|
|
1095
|
+
nextRunAt: next
|
|
1096
|
+
},
|
|
1097
|
+
updatedAt: now
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
if (changed && persist) this.commit();
|
|
1101
|
+
}
|
|
1102
|
+
reconcileInterruptedStarts(persist = true) {
|
|
1103
|
+
const now = this.now();
|
|
1104
|
+
let changed = false;
|
|
1105
|
+
this.document.tasks = this.document.tasks.map((task) => {
|
|
1106
|
+
if (task.status !== "running") return task;
|
|
1107
|
+
const execution = task.executions.at(-1);
|
|
1108
|
+
if (execution === void 0 || execution.endedAt !== void 0 || execution.sessionId !== void 0) return task;
|
|
1109
|
+
changed = true;
|
|
1110
|
+
return settleExecution(task, execution.id, "cancelled", now, "host restarted before the execution session was recorded");
|
|
1111
|
+
});
|
|
1112
|
+
if (changed && persist) this.commit();
|
|
1113
|
+
}
|
|
1114
|
+
load(dir) {
|
|
1115
|
+
const existed = existsSync(this.file);
|
|
1116
|
+
try {
|
|
1117
|
+
const parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
1118
|
+
if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
|
|
1119
|
+
const tasks = parseHostTasks(parsed.tasks, this.now());
|
|
1120
|
+
const invalidScheduleIds = parsed.tasks.flatMap((value) => {
|
|
1121
|
+
if (typeof value !== "object" || value === null) return [];
|
|
1122
|
+
const row = value;
|
|
1123
|
+
if (typeof row.schedule !== "object" || row.schedule === null) return [];
|
|
1124
|
+
const cron = row.schedule.cron;
|
|
1125
|
+
return typeof cron !== "string" || !isValidCron(cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
|
|
1126
|
+
});
|
|
1127
|
+
return {
|
|
1128
|
+
schemaVersion: 2,
|
|
1129
|
+
revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
|
|
1130
|
+
tasks,
|
|
1131
|
+
scheduler: {
|
|
1132
|
+
timeZone: timeZone(),
|
|
1133
|
+
ledgerId: typeof parsed.scheduler?.ledgerId === "string" && parsed.scheduler.ledgerId !== "" ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
1134
|
+
...typeof parsed.scheduler?.lastTickAt === "number" ? { lastTickAt: parsed.scheduler.lastTickAt } : {},
|
|
1135
|
+
...typeof parsed.scheduler?.error === "string" ? { error: parsed.scheduler.error } : {},
|
|
1136
|
+
...invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}` } : {},
|
|
1137
|
+
...Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter((x) => typeof x === "string") } : {}
|
|
1138
|
+
},
|
|
1139
|
+
recentRequests: Array.isArray(parsed.recentRequests) ? parsed.recentRequests.flatMap((entry) => {
|
|
1140
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
1141
|
+
const request = entry;
|
|
1142
|
+
return typeof request.requestId === "string" && request.requestId !== "" && typeof request.fingerprint === "string" ? [{
|
|
1143
|
+
requestId: request.requestId,
|
|
1144
|
+
fingerprint: request.fingerprint
|
|
1145
|
+
}] : [];
|
|
1146
|
+
}).slice(-256) : []
|
|
1147
|
+
};
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
|
|
1150
|
+
mkdirSync(dir, { recursive: true });
|
|
1151
|
+
return {
|
|
1152
|
+
schemaVersion: 2,
|
|
1153
|
+
revision: 0,
|
|
1154
|
+
tasks: [],
|
|
1155
|
+
scheduler: {
|
|
1156
|
+
timeZone: timeZone(),
|
|
1157
|
+
ledgerId: crypto.randomUUID(),
|
|
1158
|
+
...existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}
|
|
1159
|
+
},
|
|
1160
|
+
recentRequests: []
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
syncRecentRequests() {
|
|
1165
|
+
this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({
|
|
1166
|
+
requestId,
|
|
1167
|
+
fingerprint: request.fingerprint
|
|
1168
|
+
}));
|
|
1169
|
+
}
|
|
1170
|
+
commit(bumpRevision = true) {
|
|
1171
|
+
if (bumpRevision) this.document.revision += 1;
|
|
1172
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
1173
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1174
|
+
let fd;
|
|
1175
|
+
try {
|
|
1176
|
+
fd = openSync(tmp, "w", 384);
|
|
1177
|
+
writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: "utf8" });
|
|
1178
|
+
fsyncSync(fd);
|
|
1179
|
+
closeSync(fd);
|
|
1180
|
+
fd = void 0;
|
|
1181
|
+
try {
|
|
1182
|
+
chmodSync(tmp, 384);
|
|
1183
|
+
} catch {}
|
|
1184
|
+
renameSync(tmp, this.file);
|
|
1185
|
+
try {
|
|
1186
|
+
const dirFd = openSync(dirname(this.file), "r");
|
|
1187
|
+
try {
|
|
1188
|
+
fsyncSync(dirFd);
|
|
1189
|
+
} finally {
|
|
1190
|
+
closeSync(dirFd);
|
|
1191
|
+
}
|
|
1192
|
+
} catch {}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
if (fd !== void 0) closeSync(fd);
|
|
1195
|
+
try {
|
|
1196
|
+
unlinkSync(tmp);
|
|
1197
|
+
} catch {}
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
this.notify();
|
|
1201
|
+
}
|
|
1202
|
+
notify() {
|
|
1203
|
+
for (const listener of [...this.listeners]) listener();
|
|
1204
|
+
}
|
|
1205
|
+
acquireLock() {
|
|
1206
|
+
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
1207
|
+
const fd = openSync(this.lockFile, "wx", 384);
|
|
1208
|
+
writeFileSync(fd, JSON.stringify({
|
|
1209
|
+
pid: process.pid,
|
|
1210
|
+
token: this.lockToken
|
|
1211
|
+
}), { encoding: "utf8" });
|
|
1212
|
+
fsyncSync(fd);
|
|
1213
|
+
try {
|
|
1214
|
+
chmodSync(this.lockFile, 384);
|
|
1215
|
+
} catch {}
|
|
1216
|
+
return fd;
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (error.code !== "EEXIST") throw error;
|
|
1219
|
+
let pid;
|
|
1220
|
+
try {
|
|
1221
|
+
const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
1222
|
+
if (typeof owner.pid === "number") pid = owner.pid;
|
|
1223
|
+
} catch {
|
|
1224
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
|
|
1225
|
+
}
|
|
1226
|
+
if (pid !== void 0 && processIsAlive(pid)) throw new Error(`task-board ledger is already owned by process ${pid}`);
|
|
1227
|
+
try {
|
|
1228
|
+
unlinkSync(this.lockFile);
|
|
1229
|
+
} catch (unlinkError) {
|
|
1230
|
+
if (unlinkError.code !== "ENOENT") throw unlinkError;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`);
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
//#endregion
|
|
1237
|
+
//#region src/host-runner.ts
|
|
1238
|
+
function request(payload) {
|
|
1239
|
+
return {
|
|
1240
|
+
rpcId: `task-board-${crypto.randomUUID()}`,
|
|
1241
|
+
payload
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function failure(error) {
|
|
1245
|
+
return /* @__PURE__ */ new Error(`${error.code}: ${error.message}`);
|
|
1246
|
+
}
|
|
1247
|
+
/** A post-create launch failure that still identifies the session to the ledger. */
|
|
1248
|
+
var SessionLaunchError = class extends Error {
|
|
1249
|
+
sessionId;
|
|
1250
|
+
constructor(sessionId, cause) {
|
|
1251
|
+
super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
1252
|
+
this.sessionId = sessionId;
|
|
1253
|
+
this.name = "SessionLaunchError";
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
function isErrorTurnEnd(data) {
|
|
1257
|
+
if (typeof data !== "object" || data === null) return false;
|
|
1258
|
+
const reason = data.reason;
|
|
1259
|
+
return typeof reason === "object" && reason !== null && reason.kind === "error";
|
|
1260
|
+
}
|
|
1261
|
+
var HostExecutionRunner = class {
|
|
1262
|
+
api;
|
|
1263
|
+
constructor(api) {
|
|
1264
|
+
this.api = api;
|
|
1265
|
+
}
|
|
1266
|
+
async launch(task) {
|
|
1267
|
+
if (task.workspaceId !== void 0) {
|
|
1268
|
+
const workspaces = await this.api.workspace.list(request({}));
|
|
1269
|
+
if (!workspaces.result.ok) throw failure(workspaces.result.error);
|
|
1270
|
+
if (!workspaces.result.value.items.some((item) => item.workspaceId === task.workspaceId)) throw new Error(`workspace not found: ${task.workspaceId}`);
|
|
1271
|
+
}
|
|
1272
|
+
if (task.mode !== void 0) {
|
|
1273
|
+
const presets = await this.api.agentPresets.list(request({}));
|
|
1274
|
+
if (!presets.result.ok) throw failure(presets.result.error);
|
|
1275
|
+
const preset = presets.result.value.presets.find((item) => item.id === task.mode);
|
|
1276
|
+
if (preset === void 0) throw new Error(`agent preset not found: ${task.mode}`);
|
|
1277
|
+
if (preset.broken !== void 0) throw new Error(`agent preset is unavailable: ${preset.broken}`);
|
|
1278
|
+
}
|
|
1279
|
+
const created = await this.api.sessions.create(request({
|
|
1280
|
+
...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
|
|
1281
|
+
...task.mode === void 0 ? {} : { agentPreset: task.mode }
|
|
1282
|
+
}));
|
|
1283
|
+
if (!created.result.ok) throw failure(created.result.error);
|
|
1284
|
+
const sessionId = created.result.value.sessionId;
|
|
1285
|
+
try {
|
|
1286
|
+
const renamed = await this.api.sessions.rename(request({
|
|
1287
|
+
sessionId,
|
|
1288
|
+
title: task.title
|
|
1289
|
+
}));
|
|
1290
|
+
if (!renamed.result.ok) throw failure(renamed.result.error);
|
|
1291
|
+
if (task.permission !== void 0) {
|
|
1292
|
+
const command = await this.api.sessions.prompt(request({
|
|
1293
|
+
sessionId,
|
|
1294
|
+
mode: "queue",
|
|
1295
|
+
content: [{
|
|
1296
|
+
type: "text",
|
|
1297
|
+
text: `/permission ${task.permission}`
|
|
1298
|
+
}]
|
|
1299
|
+
}));
|
|
1300
|
+
if (!command.result.ok) throw failure(command.result.error);
|
|
1301
|
+
if (command.result.value.command?.kind !== "success") throw new Error("permission command was not acknowledged");
|
|
1302
|
+
}
|
|
1303
|
+
const prompt = await this.api.sessions.prompt(request({
|
|
1304
|
+
sessionId,
|
|
1305
|
+
mode: "queue",
|
|
1306
|
+
content: [{
|
|
1307
|
+
type: "text",
|
|
1308
|
+
text: task.prompt !== "" ? task.prompt : task.title
|
|
1309
|
+
}]
|
|
1310
|
+
}));
|
|
1311
|
+
if (!prompt.result.ok) throw failure(prompt.result.error);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
throw new SessionLaunchError(sessionId, error);
|
|
1314
|
+
}
|
|
1315
|
+
return sessionId;
|
|
1316
|
+
}
|
|
1317
|
+
async listRunning() {
|
|
1318
|
+
try {
|
|
1319
|
+
const response = await this.api.sessions.list(request({}));
|
|
1320
|
+
return response.result.ok ? {
|
|
1321
|
+
known: true,
|
|
1322
|
+
count: response.result.value.items.filter((item) => item.running).length
|
|
1323
|
+
} : { known: false };
|
|
1324
|
+
} catch {
|
|
1325
|
+
return { known: false };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
async inspect(sessionId, startedAt = 0) {
|
|
1329
|
+
const sessions = await this.api.sessions.list(request({}));
|
|
1330
|
+
if (!sessions.result.ok) return { outcome: "pending" };
|
|
1331
|
+
const summary = sessions.result.value.items.find((item) => item.sessionId === sessionId);
|
|
1332
|
+
if (summary === void 0) return {
|
|
1333
|
+
outcome: "cancelled",
|
|
1334
|
+
error: "execution session no longer exists"
|
|
1335
|
+
};
|
|
1336
|
+
if (summary.running) return { outcome: "pending" };
|
|
1337
|
+
const events = [];
|
|
1338
|
+
let beforeSeq;
|
|
1339
|
+
let reachedExecutionBoundary = false;
|
|
1340
|
+
for (let page = 0; page < 100; page += 1) {
|
|
1341
|
+
const history = await this.api.sessions.history(request({
|
|
1342
|
+
sessionId: summary.sessionId,
|
|
1343
|
+
maxMessages: 100,
|
|
1344
|
+
...beforeSeq === void 0 ? {} : { beforeSeq }
|
|
1345
|
+
}));
|
|
1346
|
+
if (!history.result.ok) return { outcome: "pending" };
|
|
1347
|
+
events.push(...history.result.value.events);
|
|
1348
|
+
const oldestTime = history.result.value.events.reduce((oldest, entry) => {
|
|
1349
|
+
const time = entry.event.time;
|
|
1350
|
+
return typeof time !== "number" ? oldest : oldest === void 0 ? time : Math.min(oldest, time);
|
|
1351
|
+
}, void 0);
|
|
1352
|
+
if (!history.result.value.hasMore || oldestTime !== void 0 && oldestTime <= startedAt) {
|
|
1353
|
+
reachedExecutionBoundary = true;
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
const oldestSeq = history.result.value.events.reduce((oldest, entry) => {
|
|
1357
|
+
const seq = entry.event.seq;
|
|
1358
|
+
return typeof seq !== "number" ? oldest : oldest === void 0 ? seq : Math.min(oldest, seq);
|
|
1359
|
+
}, void 0);
|
|
1360
|
+
if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
|
|
1361
|
+
beforeSeq = oldestSeq;
|
|
1362
|
+
}
|
|
1363
|
+
if (!reachedExecutionBoundary) return { outcome: "pending" };
|
|
1364
|
+
const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || typeof entry.event.time === "number" && entry.event.time >= startedAt)).sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0];
|
|
1365
|
+
if (turnEnd === void 0) return { outcome: "pending" };
|
|
1366
|
+
return isErrorTurnEnd(turnEnd.event.data) ? {
|
|
1367
|
+
outcome: "failed",
|
|
1368
|
+
error: "agent turn ended with an error"
|
|
1369
|
+
} : { outcome: "succeeded" };
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/power-inhibitor.ts
|
|
1374
|
+
const RETRY_DELAYS = [
|
|
1375
|
+
1e3,
|
|
1376
|
+
2e3,
|
|
1377
|
+
5e3,
|
|
1378
|
+
1e4,
|
|
1379
|
+
3e4
|
|
1380
|
+
];
|
|
1381
|
+
const DARWIN_STABLE_MS = 3e4;
|
|
1382
|
+
const WINDOWS_HELPER = String.raw`
|
|
1383
|
+
$source = @'
|
|
1384
|
+
using System;
|
|
1385
|
+
using System.Runtime.InteropServices;
|
|
1386
|
+
public static class DshExecutionState {
|
|
1387
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
1388
|
+
public static extern uint SetThreadExecutionState(uint flags);
|
|
1389
|
+
}
|
|
1390
|
+
'@
|
|
1391
|
+
Add-Type -TypeDefinition $source
|
|
1392
|
+
$continuous = [Convert]::ToUInt32('80000000', 16)
|
|
1393
|
+
$systemRequired = [uint32]0x00000001
|
|
1394
|
+
try {
|
|
1395
|
+
$result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)
|
|
1396
|
+
if ($result -eq 0) { throw 'SetThreadExecutionState failed' }
|
|
1397
|
+
[Console]::Out.WriteLine('READY')
|
|
1398
|
+
[Console]::Out.Flush()
|
|
1399
|
+
while ([Console]::In.ReadLine() -ne $null) { }
|
|
1400
|
+
} finally {
|
|
1401
|
+
[void][DshExecutionState]::SetThreadExecutionState($continuous)
|
|
1402
|
+
}
|
|
1403
|
+
`;
|
|
1404
|
+
const LINUX_HELPER = String.raw`
|
|
1405
|
+
process.stdout.write('READY\n')
|
|
1406
|
+
process.stdin.resume()
|
|
1407
|
+
`;
|
|
1408
|
+
const LINUX_INHIBIT_PATHS = ["/usr/bin/systemd-inhibit", "/bin/systemd-inhibit"];
|
|
1409
|
+
var PowerInhibitor = class {
|
|
1410
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1411
|
+
enabled = false;
|
|
1412
|
+
reasons = {
|
|
1413
|
+
runningSessions: 0,
|
|
1414
|
+
armedSchedules: 0,
|
|
1415
|
+
sessionStateKnown: false
|
|
1416
|
+
};
|
|
1417
|
+
phase = "disabled";
|
|
1418
|
+
child;
|
|
1419
|
+
retry;
|
|
1420
|
+
retryReset;
|
|
1421
|
+
retryIndex = 0;
|
|
1422
|
+
lastError;
|
|
1423
|
+
stopping = false;
|
|
1424
|
+
platform;
|
|
1425
|
+
pid;
|
|
1426
|
+
env;
|
|
1427
|
+
spawn;
|
|
1428
|
+
spawnSync;
|
|
1429
|
+
exists;
|
|
1430
|
+
execPath;
|
|
1431
|
+
timer;
|
|
1432
|
+
clearTimer;
|
|
1433
|
+
linuxProbe;
|
|
1434
|
+
constructor(options = {}) {
|
|
1435
|
+
this.platform = options.platform ?? process.platform;
|
|
1436
|
+
this.pid = options.pid ?? process.pid;
|
|
1437
|
+
this.env = options.env ?? process.env;
|
|
1438
|
+
this.spawn = options.spawn ?? ((file, args, spawnOptions) => spawn(file, [...args], spawnOptions));
|
|
1439
|
+
this.spawnSync = options.spawnSync ?? ((file, args, spawnOptions) => spawnSync(file, [...args], spawnOptions));
|
|
1440
|
+
this.exists = options.exists ?? existsSync;
|
|
1441
|
+
this.execPath = options.execPath ?? process.execPath;
|
|
1442
|
+
this.timer = options.setTimeout ?? globalThis.setTimeout;
|
|
1443
|
+
this.clearTimer = options.clearTimeout ?? globalThis.clearTimeout;
|
|
1444
|
+
}
|
|
1445
|
+
setEnabled(enabled) {
|
|
1446
|
+
this.enabled = enabled;
|
|
1447
|
+
this.sync();
|
|
1448
|
+
this.emit();
|
|
1449
|
+
}
|
|
1450
|
+
updateReasons(reasons) {
|
|
1451
|
+
this.reasons = reasons;
|
|
1452
|
+
this.sync();
|
|
1453
|
+
this.emit();
|
|
1454
|
+
}
|
|
1455
|
+
subscribe(listener) {
|
|
1456
|
+
this.listeners.add(listener);
|
|
1457
|
+
return () => {
|
|
1458
|
+
this.listeners.delete(listener);
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
snapshot() {
|
|
1462
|
+
return {
|
|
1463
|
+
platform: this.platform,
|
|
1464
|
+
phase: this.phase,
|
|
1465
|
+
enabled: this.enabled,
|
|
1466
|
+
...this.reasons,
|
|
1467
|
+
...this.lastError === void 0 ? {} : { lastError: this.lastError }
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
dispose() {
|
|
1471
|
+
this.enabled = false;
|
|
1472
|
+
this.release();
|
|
1473
|
+
this.phase = "disabled";
|
|
1474
|
+
this.emit();
|
|
1475
|
+
this.listeners.clear();
|
|
1476
|
+
}
|
|
1477
|
+
desired() {
|
|
1478
|
+
return this.enabled && (!this.reasons.sessionStateKnown || this.reasons.runningSessions > 0 || this.reasons.armedSchedules > 0);
|
|
1479
|
+
}
|
|
1480
|
+
sync() {
|
|
1481
|
+
if (!this.enabled) {
|
|
1482
|
+
this.release();
|
|
1483
|
+
this.phase = "disabled";
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (this.platform !== "darwin" && this.platform !== "win32" && this.platform !== "linux") {
|
|
1487
|
+
this.release();
|
|
1488
|
+
this.phase = "unsupported";
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (this.platform === "linux" && this.linuxSystemdInhibit() === void 0) {
|
|
1492
|
+
this.release();
|
|
1493
|
+
this.phase = "unsupported";
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
if (!this.desired()) {
|
|
1497
|
+
this.release();
|
|
1498
|
+
this.phase = "idle";
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (this.child === void 0 && this.retry === void 0) this.acquire();
|
|
1502
|
+
}
|
|
1503
|
+
acquire() {
|
|
1504
|
+
this.phase = "acquiring";
|
|
1505
|
+
this.emit();
|
|
1506
|
+
this.stopping = false;
|
|
1507
|
+
try {
|
|
1508
|
+
const child = this.spawnCommand();
|
|
1509
|
+
this.child = child;
|
|
1510
|
+
let ready = false;
|
|
1511
|
+
let stderr = "";
|
|
1512
|
+
if (this.platform === "darwin") child.once("spawn", () => {
|
|
1513
|
+
ready = true;
|
|
1514
|
+
this.markReady(child);
|
|
1515
|
+
});
|
|
1516
|
+
child.stdout?.on("data", (chunk) => {
|
|
1517
|
+
if (!ready && chunk.toString("utf8").includes("READY")) {
|
|
1518
|
+
ready = true;
|
|
1519
|
+
this.markReady(child);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
child.stderr?.on("data", (chunk) => {
|
|
1523
|
+
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-2e3);
|
|
1524
|
+
});
|
|
1525
|
+
child.on("error", (error) => {
|
|
1526
|
+
this.fail(error, child);
|
|
1527
|
+
});
|
|
1528
|
+
child.on("exit", (code, signal) => {
|
|
1529
|
+
if (this.child !== child) return;
|
|
1530
|
+
this.child = void 0;
|
|
1531
|
+
if (this.stopping || !this.desired()) return;
|
|
1532
|
+
const detail = stderr.trim();
|
|
1533
|
+
this.fail(/* @__PURE__ */ new Error(`power helper exited (${String(code ?? signal ?? "unknown")})${detail === "" ? "" : `: ${detail}`}`));
|
|
1534
|
+
});
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
this.fail(error);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
markReady(child) {
|
|
1540
|
+
if (this.child !== child || !this.desired()) return;
|
|
1541
|
+
this.phase = "active";
|
|
1542
|
+
if (this.platform === "darwin") this.retryReset = this.timer(() => {
|
|
1543
|
+
this.retryReset = void 0;
|
|
1544
|
+
if (this.child === child && this.desired()) this.retryIndex = 0;
|
|
1545
|
+
}, DARWIN_STABLE_MS);
|
|
1546
|
+
else this.retryIndex = 0;
|
|
1547
|
+
this.lastError = void 0;
|
|
1548
|
+
this.emit();
|
|
1549
|
+
}
|
|
1550
|
+
fail(error, source) {
|
|
1551
|
+
if (source !== void 0 && this.child !== source) return;
|
|
1552
|
+
this.clearRetryReset();
|
|
1553
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1554
|
+
this.phase = "error";
|
|
1555
|
+
this.emit();
|
|
1556
|
+
const child = this.child;
|
|
1557
|
+
this.child = void 0;
|
|
1558
|
+
child?.stdin?.end();
|
|
1559
|
+
child?.kill();
|
|
1560
|
+
if (!this.desired() || this.retry !== void 0) return;
|
|
1561
|
+
const delay = RETRY_DELAYS[Math.min(this.retryIndex, RETRY_DELAYS.length - 1)];
|
|
1562
|
+
this.retryIndex += 1;
|
|
1563
|
+
this.retry = this.timer(() => {
|
|
1564
|
+
this.retry = void 0;
|
|
1565
|
+
if (this.desired()) this.acquire();
|
|
1566
|
+
}, delay);
|
|
1567
|
+
}
|
|
1568
|
+
release() {
|
|
1569
|
+
this.clearRetryReset();
|
|
1570
|
+
if (this.retry !== void 0) {
|
|
1571
|
+
this.clearTimer(this.retry);
|
|
1572
|
+
this.retry = void 0;
|
|
1573
|
+
}
|
|
1574
|
+
this.lastError = void 0;
|
|
1575
|
+
const child = this.child;
|
|
1576
|
+
this.child = void 0;
|
|
1577
|
+
if (child === void 0) return;
|
|
1578
|
+
this.stopping = true;
|
|
1579
|
+
if (this.platform === "win32" || this.platform === "linux") {
|
|
1580
|
+
child.stdin?.end();
|
|
1581
|
+
const force = this.timer(() => {
|
|
1582
|
+
if (child.exitCode === null) child.kill();
|
|
1583
|
+
}, 1e3);
|
|
1584
|
+
child.once("exit", () => {
|
|
1585
|
+
this.clearTimer(force);
|
|
1586
|
+
});
|
|
1587
|
+
} else child.kill("SIGTERM");
|
|
1588
|
+
}
|
|
1589
|
+
clearRetryReset() {
|
|
1590
|
+
if (this.retryReset === void 0) return;
|
|
1591
|
+
this.clearTimer(this.retryReset);
|
|
1592
|
+
this.retryReset = void 0;
|
|
1593
|
+
}
|
|
1594
|
+
windowsPowerShell() {
|
|
1595
|
+
const root = this.env.SystemRoot;
|
|
1596
|
+
if (root === void 0 || root.trim() === "") throw new Error("SystemRoot is unavailable");
|
|
1597
|
+
if (!win32.isAbsolute(root)) throw new Error("SystemRoot is not an absolute path");
|
|
1598
|
+
return win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
1599
|
+
}
|
|
1600
|
+
linuxSystemdInhibit() {
|
|
1601
|
+
if (this.linuxProbe !== void 0) return this.linuxProbe.available ? this.linuxProbe.executable : void 0;
|
|
1602
|
+
const executable = LINUX_INHIBIT_PATHS.find((path) => this.exists(path));
|
|
1603
|
+
if (executable === void 0) {
|
|
1604
|
+
this.linuxProbe = { available: false };
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const probe = this.spawnSync(executable, ["--list", "--no-pager"], {
|
|
1608
|
+
stdio: "ignore",
|
|
1609
|
+
timeout: 2e3,
|
|
1610
|
+
windowsHide: true
|
|
1611
|
+
});
|
|
1612
|
+
this.linuxProbe = {
|
|
1613
|
+
executable,
|
|
1614
|
+
available: probe.status === 0 && probe.error === void 0
|
|
1615
|
+
};
|
|
1616
|
+
return this.linuxProbe.available ? executable : void 0;
|
|
1617
|
+
}
|
|
1618
|
+
spawnCommand() {
|
|
1619
|
+
if (this.platform === "darwin") return this.spawn("/usr/bin/caffeinate", [
|
|
1620
|
+
"-i",
|
|
1621
|
+
"-w",
|
|
1622
|
+
String(this.pid)
|
|
1623
|
+
], {
|
|
1624
|
+
shell: false,
|
|
1625
|
+
windowsHide: false,
|
|
1626
|
+
stdio: [
|
|
1627
|
+
"ignore",
|
|
1628
|
+
"ignore",
|
|
1629
|
+
"ignore"
|
|
1630
|
+
]
|
|
1631
|
+
});
|
|
1632
|
+
if (this.platform === "linux") {
|
|
1633
|
+
const executable = this.linuxSystemdInhibit();
|
|
1634
|
+
if (executable === void 0) throw new Error("systemd-inhibit is unavailable");
|
|
1635
|
+
return this.spawn(executable, [
|
|
1636
|
+
"--what=idle",
|
|
1637
|
+
"--who=DeepSeek Harness task board",
|
|
1638
|
+
"--why=DSH sessions are running or schedules are armed",
|
|
1639
|
+
"--mode=block",
|
|
1640
|
+
"--",
|
|
1641
|
+
this.execPath,
|
|
1642
|
+
"-e",
|
|
1643
|
+
LINUX_HELPER
|
|
1644
|
+
], {
|
|
1645
|
+
shell: false,
|
|
1646
|
+
windowsHide: false,
|
|
1647
|
+
stdio: [
|
|
1648
|
+
"pipe",
|
|
1649
|
+
"pipe",
|
|
1650
|
+
"pipe"
|
|
1651
|
+
]
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
return this.spawn(this.windowsPowerShell(), [
|
|
1655
|
+
"-NoLogo",
|
|
1656
|
+
"-NoProfile",
|
|
1657
|
+
"-NonInteractive",
|
|
1658
|
+
"-Command",
|
|
1659
|
+
WINDOWS_HELPER
|
|
1660
|
+
], {
|
|
1661
|
+
shell: false,
|
|
1662
|
+
windowsHide: true,
|
|
1663
|
+
stdio: [
|
|
1664
|
+
"pipe",
|
|
1665
|
+
"pipe",
|
|
1666
|
+
"pipe"
|
|
1667
|
+
]
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
emit() {
|
|
1671
|
+
for (const listener of [...this.listeners]) listener();
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
//#endregion
|
|
1675
|
+
//#region src/host-service.ts
|
|
1676
|
+
const SESSION_POLL_MS = 5e3;
|
|
1677
|
+
const SCHEDULE_TICK_MS = 3e4;
|
|
1678
|
+
const RESUME_GAP_MS = 45e3;
|
|
1679
|
+
var TaskBoardHostService = class {
|
|
1680
|
+
ledger;
|
|
1681
|
+
runner;
|
|
1682
|
+
power;
|
|
1683
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1684
|
+
timers = [];
|
|
1685
|
+
lastScheduleTick;
|
|
1686
|
+
disposed = false;
|
|
1687
|
+
pollInFlight = false;
|
|
1688
|
+
tickInFlight = false;
|
|
1689
|
+
active = true;
|
|
1690
|
+
preventIdleSleep = false;
|
|
1691
|
+
now;
|
|
1692
|
+
constructor(api, options = {}) {
|
|
1693
|
+
this.ledger = options.ledger ?? new HostTaskLedger();
|
|
1694
|
+
this.runner = new HostExecutionRunner(api);
|
|
1695
|
+
this.power = options.power ?? new PowerInhibitor();
|
|
1696
|
+
this.now = options.now ?? Date.now;
|
|
1697
|
+
this.ledger.subscribe(() => {
|
|
1698
|
+
this.syncPowerReasons();
|
|
1699
|
+
this.emit();
|
|
1700
|
+
});
|
|
1701
|
+
this.power.subscribe(() => {
|
|
1702
|
+
this.emit();
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
start() {
|
|
1706
|
+
if (this.disposed || this.timers.length > 0) return;
|
|
1707
|
+
this.syncPowerReasons();
|
|
1708
|
+
this.timers.push(setInterval(() => {
|
|
1709
|
+
this.schedulePoll();
|
|
1710
|
+
}, SESSION_POLL_MS));
|
|
1711
|
+
this.timers.push(setInterval(() => {
|
|
1712
|
+
this.scheduleTick(false);
|
|
1713
|
+
}, SCHEDULE_TICK_MS));
|
|
1714
|
+
this.schedulePoll();
|
|
1715
|
+
this.scheduleTick(true);
|
|
1716
|
+
}
|
|
1717
|
+
setConfiguration(active, preventIdleSleep) {
|
|
1718
|
+
const resumed = !this.active && active;
|
|
1719
|
+
this.active = active;
|
|
1720
|
+
this.preventIdleSleep = preventIdleSleep;
|
|
1721
|
+
if (resumed) {
|
|
1722
|
+
const current = this.power.snapshot();
|
|
1723
|
+
this.power.updateReasons({
|
|
1724
|
+
runningSessions: current.runningSessions,
|
|
1725
|
+
armedSchedules: this.armedSchedules(),
|
|
1726
|
+
sessionStateKnown: false
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
this.power.setEnabled(active && preventIdleSleep);
|
|
1730
|
+
if (resumed) {
|
|
1731
|
+
this.schedulePoll();
|
|
1732
|
+
this.scheduleTick(true);
|
|
1733
|
+
}
|
|
1734
|
+
this.emit();
|
|
1735
|
+
}
|
|
1736
|
+
snapshot() {
|
|
1737
|
+
const state = this.ledger.state();
|
|
1738
|
+
return {
|
|
1739
|
+
schemaVersion: 2,
|
|
1740
|
+
revision: state.revision,
|
|
1741
|
+
tasks: state.tasks,
|
|
1742
|
+
scheduler: state.scheduler,
|
|
1743
|
+
power: this.power.snapshot()
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
subscribe(listener) {
|
|
1747
|
+
this.listeners.add(listener);
|
|
1748
|
+
return () => {
|
|
1749
|
+
this.listeners.delete(listener);
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
apply(requestId, action) {
|
|
1753
|
+
if (!this.active) throw new Error("task board is disabled");
|
|
1754
|
+
const result = this.ledger.applyRequest(requestId, action);
|
|
1755
|
+
if (result.run !== void 0) this.scheduleLaunch(result.run);
|
|
1756
|
+
return {
|
|
1757
|
+
schemaVersion: 2,
|
|
1758
|
+
revision: result.state.revision,
|
|
1759
|
+
tasks: result.state.tasks,
|
|
1760
|
+
scheduler: result.state.scheduler,
|
|
1761
|
+
power: this.power.snapshot()
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
dispose() {
|
|
1765
|
+
this.disposed = true;
|
|
1766
|
+
for (const timer of this.timers.splice(0)) clearInterval(timer);
|
|
1767
|
+
this.power.dispose();
|
|
1768
|
+
this.ledger.dispose();
|
|
1769
|
+
this.listeners.clear();
|
|
1770
|
+
}
|
|
1771
|
+
async launch(opened) {
|
|
1772
|
+
try {
|
|
1773
|
+
const sessionId = await this.runner.launch(opened.task);
|
|
1774
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
|
|
1777
|
+
this.ledger.settle(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
async pollSessions() {
|
|
1781
|
+
if (this.disposed) return;
|
|
1782
|
+
if (!this.active && !this.hasOpenExecutions()) return;
|
|
1783
|
+
const running = await this.runner.listRunning();
|
|
1784
|
+
const previous = this.power.snapshot();
|
|
1785
|
+
this.power.updateReasons({
|
|
1786
|
+
runningSessions: running.known ? running.count : previous.runningSessions,
|
|
1787
|
+
armedSchedules: this.armedSchedules(),
|
|
1788
|
+
sessionStateKnown: running.known
|
|
1789
|
+
});
|
|
1790
|
+
if (running.known) await this.reconcileExecutions();
|
|
1791
|
+
this.emit();
|
|
1792
|
+
}
|
|
1793
|
+
async reconcileExecutions() {
|
|
1794
|
+
for (const task of this.ledger.state().tasks) for (const execution of task.executions) {
|
|
1795
|
+
if (execution.sessionId === void 0 || execution.endedAt !== void 0) continue;
|
|
1796
|
+
try {
|
|
1797
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt);
|
|
1798
|
+
if (result.outcome === "pending") continue;
|
|
1799
|
+
this.ledger.settle(task.id, execution.id, result.outcome, "error" in result ? result.error : void 0);
|
|
1800
|
+
} catch {}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
async tickSchedule(first) {
|
|
1804
|
+
if (this.disposed || !this.active) return;
|
|
1805
|
+
const now = this.now();
|
|
1806
|
+
const recovered = first || this.lastScheduleTick !== void 0 && now - this.lastScheduleTick > RESUME_GAP_MS;
|
|
1807
|
+
this.lastScheduleTick = now;
|
|
1808
|
+
this.ledger.setScheduler({ lastTickAt: now });
|
|
1809
|
+
if (recovered) {
|
|
1810
|
+
this.ledger.skipMissed(now);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
for (const task of this.ledger.state().tasks) {
|
|
1814
|
+
if (task.archivedAt !== void 0) continue;
|
|
1815
|
+
const schedule = task.schedule;
|
|
1816
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
1817
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
|
|
1818
|
+
const opened = this.ledger.openScheduled(task.id, next, now);
|
|
1819
|
+
if (opened !== void 0) this.scheduleLaunch(opened);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
armedSchedules() {
|
|
1823
|
+
return this.ledger.state().tasks.filter((task) => task.archivedAt === void 0 && task.schedule?.enabled === true).length;
|
|
1824
|
+
}
|
|
1825
|
+
hasOpenExecutions() {
|
|
1826
|
+
return this.ledger.state().tasks.some((task) => task.executions.some((execution) => execution.endedAt === void 0));
|
|
1827
|
+
}
|
|
1828
|
+
scheduleLaunch(opened) {
|
|
1829
|
+
this.launch(opened).catch((error) => {
|
|
1830
|
+
console.error("[dsh-task-board] execution launch settlement failed", error);
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
schedulePoll() {
|
|
1834
|
+
if (this.pollInFlight || this.disposed) return;
|
|
1835
|
+
this.pollInFlight = true;
|
|
1836
|
+
this.pollSessions().catch((error) => {
|
|
1837
|
+
console.error("[dsh-task-board] session polling failed", error);
|
|
1838
|
+
}).finally(() => {
|
|
1839
|
+
this.pollInFlight = false;
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
scheduleTick(first) {
|
|
1843
|
+
if (this.tickInFlight || this.disposed) return;
|
|
1844
|
+
this.tickInFlight = true;
|
|
1845
|
+
this.tickSchedule(first).catch((error) => {
|
|
1846
|
+
console.error("[dsh-task-board] scheduler tick failed", error);
|
|
1847
|
+
}).finally(() => {
|
|
1848
|
+
this.tickInFlight = false;
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
syncPowerReasons() {
|
|
1852
|
+
const current = this.power.snapshot();
|
|
1853
|
+
this.power.updateReasons({
|
|
1854
|
+
runningSessions: current.runningSessions,
|
|
1855
|
+
armedSchedules: this.armedSchedules(),
|
|
1856
|
+
sessionStateKnown: current.sessionStateKnown
|
|
1857
|
+
});
|
|
1858
|
+
this.power.setEnabled(this.active && this.preventIdleSleep);
|
|
1859
|
+
}
|
|
1860
|
+
emit() {
|
|
1861
|
+
for (const listener of [...this.listeners]) listener();
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region src/loopback.ts
|
|
1866
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
1867
|
+
function isIPv4Loopback(v4) {
|
|
1868
|
+
const parts = v4.split(".");
|
|
1869
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1870
|
+
}
|
|
1871
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
1872
|
+
function isLoopbackAddress(address) {
|
|
1873
|
+
if (address === void 0) return false;
|
|
1874
|
+
const normalized = address.toLowerCase();
|
|
1875
|
+
if (normalized === "::1") return true;
|
|
1876
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
1877
|
+
return isIPv4Loopback(normalized);
|
|
1878
|
+
}
|
|
1879
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
1880
|
+
function isLoopbackHostname(hostname) {
|
|
1881
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
1882
|
+
return isIPv4Loopback(hostname);
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
1886
|
+
* header, plus browser same-origin markers. The socket address is
|
|
1887
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
1888
|
+
*/
|
|
1889
|
+
function isLoopbackRequest(request) {
|
|
1890
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
1891
|
+
const host = request.headers.host;
|
|
1892
|
+
if (typeof host !== "string") return false;
|
|
1893
|
+
let hostUrl;
|
|
1894
|
+
try {
|
|
1895
|
+
hostUrl = new URL("http://" + host);
|
|
1896
|
+
} catch {
|
|
1897
|
+
return false;
|
|
1898
|
+
}
|
|
1899
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
1900
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1901
|
+
const origin = request.headers.origin;
|
|
1902
|
+
if (origin === void 0) return true;
|
|
1903
|
+
try {
|
|
1904
|
+
return new URL(origin).host === hostUrl.host;
|
|
1905
|
+
} catch {
|
|
1906
|
+
return false;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
//#endregion
|
|
1910
|
+
//#region src/host-routes.ts
|
|
1911
|
+
const ACTION_LIMIT = 64 * 1024;
|
|
1912
|
+
const IMPORT_LIMIT = 2 * 1024 * 1024;
|
|
1913
|
+
const HEARTBEAT_MS = 15e3;
|
|
1914
|
+
/** Header replaced by an authenticated same-host reverse proxy. */
|
|
1915
|
+
const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
|
|
1916
|
+
function json(res, status, body) {
|
|
1917
|
+
res.writeHead(status, {
|
|
1918
|
+
"content-type": "application/json; charset=utf-8",
|
|
1919
|
+
"cache-control": "no-store"
|
|
1920
|
+
});
|
|
1921
|
+
res.end(JSON.stringify(body));
|
|
1922
|
+
}
|
|
1923
|
+
function parseAuthority(authority) {
|
|
1924
|
+
if (authority.trim() !== authority) return void 0;
|
|
1925
|
+
const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
|
|
1926
|
+
if (match === null) return void 0;
|
|
1927
|
+
try {
|
|
1928
|
+
const url = new URL(`http://${authority}`);
|
|
1929
|
+
if (url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") return void 0;
|
|
1930
|
+
const rawPort = match[1];
|
|
1931
|
+
if (rawPort !== void 0 && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65535)) return void 0;
|
|
1932
|
+
return {
|
|
1933
|
+
canonical: url.hostname.toLowerCase() + (rawPort === void 0 ? "" : `:${rawPort}`),
|
|
1934
|
+
url
|
|
1935
|
+
};
|
|
1936
|
+
} catch {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function resolveAccess(access) {
|
|
1941
|
+
const trustedProxyHosts = /* @__PURE__ */ new Set();
|
|
1942
|
+
for (const authority of access.trustedProxyHosts ?? []) {
|
|
1943
|
+
const parsed = parseAuthority(authority);
|
|
1944
|
+
if (parsed === void 0 || parsed.canonical !== authority.toLowerCase()) throw new Error(`task-board: trustedProxyHosts entry ${JSON.stringify(authority)} is not a canonical host[:port] authority`);
|
|
1945
|
+
trustedProxyHosts.add(parsed.canonical);
|
|
1946
|
+
}
|
|
1947
|
+
if (trustedProxyHosts.size > 0 && (access.proxyToken === void 0 || access.proxyToken === "")) throw new Error("task-board: authenticated proxy hosts require a non-empty proxy token");
|
|
1948
|
+
return {
|
|
1949
|
+
trustedProxyHosts,
|
|
1950
|
+
...access.proxyToken === void 0 ? {} : { proxyToken: access.proxyToken }
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
function browserSameOriginMarker(req) {
|
|
1954
|
+
return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
|
|
1955
|
+
}
|
|
1956
|
+
function sameAuthority(req, host) {
|
|
1957
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1958
|
+
const origin = req.headers.origin;
|
|
1959
|
+
if (origin === void 0) return req.headers["sec-fetch-site"] === "same-origin";
|
|
1960
|
+
try {
|
|
1961
|
+
return new URL(origin).host === host.host;
|
|
1962
|
+
} catch {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function matchesToken(candidate, expected) {
|
|
1967
|
+
if (typeof candidate !== "string" || expected === void 0 || candidate === "" || expected === "") return false;
|
|
1968
|
+
const actual = Buffer.from(candidate);
|
|
1969
|
+
const wanted = Buffer.from(expected);
|
|
1970
|
+
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Task-board route fence. Direct desktop access uses the repository-wide
|
|
1974
|
+
* loopback socket + Host guard and additionally requires a browser same-origin
|
|
1975
|
+
* marker, so a bare local curl cannot exercise the agent control plane.
|
|
1976
|
+
* Authenticated proxies must be explicitly allowlisted and replace the
|
|
1977
|
+
* internal token header after their own authentication step.
|
|
1978
|
+
*/
|
|
1979
|
+
function isTrustedTaskBoardRequest(req, access) {
|
|
1980
|
+
if (!browserSameOriginMarker(req)) return false;
|
|
1981
|
+
if (isLoopbackRequest(req)) return true;
|
|
1982
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
|
|
1983
|
+
const host = req.headers.host;
|
|
1984
|
+
if (typeof host !== "string") return false;
|
|
1985
|
+
const parsed = parseAuthority(host);
|
|
1986
|
+
if (parsed === void 0 || parsed.canonical !== host.toLowerCase()) return false;
|
|
1987
|
+
if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false;
|
|
1988
|
+
return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken);
|
|
1989
|
+
}
|
|
1990
|
+
async function readBody(req) {
|
|
1991
|
+
const chunks = [];
|
|
1992
|
+
let size = 0;
|
|
1993
|
+
for await (const chunk of req) {
|
|
1994
|
+
const buffer = chunk;
|
|
1995
|
+
size += buffer.length;
|
|
1996
|
+
if (size > IMPORT_LIMIT) throw new Error("body-too-large");
|
|
1997
|
+
chunks.push(buffer);
|
|
1998
|
+
}
|
|
1999
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2000
|
+
return {
|
|
2001
|
+
raw,
|
|
2002
|
+
value: JSON.parse(raw)
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
function makeTaskBoardRoutes(service, access = {}) {
|
|
2006
|
+
const resolvedAccess = resolveAccess(access);
|
|
2007
|
+
const guard = (req, res) => {
|
|
2008
|
+
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
|
|
2009
|
+
json(res, 403, {
|
|
2010
|
+
ok: false,
|
|
2011
|
+
error: "forbidden"
|
|
2012
|
+
});
|
|
2013
|
+
return false;
|
|
2014
|
+
};
|
|
2015
|
+
return [
|
|
2016
|
+
{
|
|
2017
|
+
kind: "exact",
|
|
2018
|
+
path: `${TASK_BOARD_API_PREFIX}/state`,
|
|
2019
|
+
handler: (req, res) => {
|
|
2020
|
+
if (req.method !== "GET") return json(res, 405, {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
error: "method-not-allowed"
|
|
2023
|
+
});
|
|
2024
|
+
if (!guard(req, res)) return;
|
|
2025
|
+
json(res, 200, service.snapshot());
|
|
2026
|
+
}
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
kind: "exact",
|
|
2030
|
+
path: `${TASK_BOARD_API_PREFIX}/action`,
|
|
2031
|
+
handler: async (req, res) => {
|
|
2032
|
+
if (req.method !== "POST") return json(res, 405, {
|
|
2033
|
+
ok: false,
|
|
2034
|
+
error: "method-not-allowed"
|
|
2035
|
+
});
|
|
2036
|
+
if (!guard(req, res)) return;
|
|
2037
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return json(res, 415, {
|
|
2038
|
+
ok: false,
|
|
2039
|
+
error: "json-required"
|
|
2040
|
+
});
|
|
2041
|
+
try {
|
|
2042
|
+
const body = await readBody(req);
|
|
2043
|
+
const parsed = parseActionEnvelope(body.value);
|
|
2044
|
+
if (parsed === void 0) return json(res, 400, {
|
|
2045
|
+
ok: false,
|
|
2046
|
+
error: "invalid-action"
|
|
2047
|
+
});
|
|
2048
|
+
if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return json(res, 413, {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
error: "body-too-large"
|
|
2051
|
+
});
|
|
2052
|
+
json(res, 200, service.apply(parsed.requestId, parsed.action));
|
|
2053
|
+
} catch (error) {
|
|
2054
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2055
|
+
json(res, message === "body-too-large" ? 413 : 400, {
|
|
2056
|
+
ok: false,
|
|
2057
|
+
error: message
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
},
|
|
2062
|
+
{
|
|
2063
|
+
kind: "exact",
|
|
2064
|
+
path: `${TASK_BOARD_API_PREFIX}/events`,
|
|
2065
|
+
handler: (req, res) => {
|
|
2066
|
+
if (req.method !== "GET") {
|
|
2067
|
+
res.writeHead(405);
|
|
2068
|
+
res.end();
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (!guard(req, res)) return;
|
|
2072
|
+
res.writeHead(200, {
|
|
2073
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2074
|
+
"cache-control": "no-cache",
|
|
2075
|
+
connection: "keep-alive"
|
|
2076
|
+
});
|
|
2077
|
+
const push = () => {
|
|
2078
|
+
const snapshot = service.snapshot();
|
|
2079
|
+
res.write(`data: ${JSON.stringify({
|
|
2080
|
+
revision: snapshot.revision,
|
|
2081
|
+
scheduler: snapshot.scheduler,
|
|
2082
|
+
power: snapshot.power
|
|
2083
|
+
})}\n\n`);
|
|
2084
|
+
};
|
|
2085
|
+
const unsubscribe = service.subscribe(push);
|
|
2086
|
+
const heartbeat = setInterval(() => {
|
|
2087
|
+
res.write(": ping\n\n");
|
|
2088
|
+
}, HEARTBEAT_MS);
|
|
2089
|
+
const close = () => {
|
|
2090
|
+
clearInterval(heartbeat);
|
|
2091
|
+
unsubscribe();
|
|
2092
|
+
};
|
|
2093
|
+
req.once("close", close);
|
|
2094
|
+
res.once("close", close);
|
|
2095
|
+
push();
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
];
|
|
2099
|
+
}
|
|
2100
|
+
//#endregion
|
|
3
2101
|
//#region src/mount-once.ts
|
|
4
2102
|
/**
|
|
5
2103
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
@@ -44,9 +2142,15 @@ function mountOnce(packageName, fn) {
|
|
|
44
2142
|
//#region src/index.ts
|
|
45
2143
|
/** Order of the announcement section within the tool-guidance band. */
|
|
46
2144
|
const SECTION_ORDER = 200;
|
|
47
|
-
|
|
2145
|
+
/** Default environment variable holding the authenticated proxy token. */
|
|
2146
|
+
const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
2147
|
+
const inject = [
|
|
2148
|
+
"systemPrompt",
|
|
2149
|
+
"apiProxy",
|
|
2150
|
+
"webServer"
|
|
2151
|
+
];
|
|
48
2152
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
49
|
-
const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all
|
|
2153
|
+
const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
|
|
50
2154
|
/**
|
|
51
2155
|
* Settings namespace of the board's announcement capability — the section the
|
|
52
2156
|
* web settings surface edits. Spelled here rather than imported: the browser
|
|
@@ -55,8 +2159,24 @@ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI
|
|
|
55
2159
|
const TASK_BOARD_SETTINGS_NAMESPACE = settingsNamespace("task-board");
|
|
56
2160
|
const Config = z.object({
|
|
57
2161
|
announceToAgent: z.boolean().default(true),
|
|
58
|
-
enabled: z.boolean().default(true)
|
|
2162
|
+
enabled: z.boolean().default(true),
|
|
2163
|
+
preventIdleSleep: z.boolean().default(false),
|
|
2164
|
+
trustedProxyHosts: z.array(z.string()).default([]),
|
|
2165
|
+
proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV)
|
|
59
2166
|
});
|
|
2167
|
+
/** Resolve proxy access without ever placing the token value in plugin config. */
|
|
2168
|
+
function resolveProxyAccess(config, env = process.env) {
|
|
2169
|
+
const trustedProxyHosts = config?.trustedProxyHosts ?? [];
|
|
2170
|
+
if (trustedProxyHosts.length === 0) return { trustedProxyHosts };
|
|
2171
|
+
const proxyTokenEnv = config?.proxyTokenEnv ?? "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
2172
|
+
if (proxyTokenEnv.trim() === "") throw new Error("task-board: proxyTokenEnv must not be empty");
|
|
2173
|
+
const proxyToken = env[proxyTokenEnv];
|
|
2174
|
+
if (proxyToken === void 0 || proxyToken === "") throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`);
|
|
2175
|
+
return {
|
|
2176
|
+
trustedProxyHosts,
|
|
2177
|
+
proxyToken
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
60
2180
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
61
2181
|
const DEFAULT_ANNOUNCE = true;
|
|
62
2182
|
/**
|
|
@@ -69,6 +2189,23 @@ const DEFAULT_ANNOUNCE = true;
|
|
|
69
2189
|
*/
|
|
70
2190
|
const apply = mountOnce("@linxin666/dsh-client-ui-task-board", applyImpl);
|
|
71
2191
|
function applyImpl(ctx, config) {
|
|
2192
|
+
const host = new TaskBoardHostService(ctx.apiProxy);
|
|
2193
|
+
host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false);
|
|
2194
|
+
host.start();
|
|
2195
|
+
ctx.effect(() => {
|
|
2196
|
+
const disposers = [];
|
|
2197
|
+
try {
|
|
2198
|
+
for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route));
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
for (const dispose of disposers) dispose();
|
|
2201
|
+
host.dispose();
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
return () => {
|
|
2205
|
+
for (const dispose of disposers) dispose();
|
|
2206
|
+
host.dispose();
|
|
2207
|
+
};
|
|
2208
|
+
}, "task-board: host ledger, scheduler, and routes");
|
|
72
2209
|
let current = () => config ?? {};
|
|
73
2210
|
let disposeSection;
|
|
74
2211
|
const sync = () => {
|
|
@@ -76,7 +2213,9 @@ function applyImpl(ctx, config) {
|
|
|
76
2213
|
disposeSection();
|
|
77
2214
|
disposeSection = void 0;
|
|
78
2215
|
}
|
|
79
|
-
|
|
2216
|
+
const active = current().enabled ?? true;
|
|
2217
|
+
host.setConfiguration(active, current().preventIdleSleep ?? false);
|
|
2218
|
+
if (!active) return;
|
|
80
2219
|
if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return;
|
|
81
2220
|
disposeSection = ctx.systemPrompt.section({
|
|
82
2221
|
name: "plugin:task-board",
|
|
@@ -93,4 +2232,4 @@ function applyImpl(ctx, config) {
|
|
|
93
2232
|
sync();
|
|
94
2233
|
}
|
|
95
2234
|
//#endregion
|
|
96
|
-
export { Config, TASK_BOARD_GUIDANCE, TASK_BOARD_SETTINGS_NAMESPACE, apply, inject };
|
|
2235
|
+
export { Config, DEFAULT_PROXY_TOKEN_ENV, TASK_BOARD_GUIDANCE, TASK_BOARD_SETTINGS_NAMESPACE, apply, inject, resolveProxyAccess };
|