@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +349 -73
- package/lib/client.js.map +1 -1
- package/lib/index.js +161 -31
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +3 -3
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +9 -2
- package/lib/types/core/schedule.d.ts +7 -0
- package/lib/types/host-ledger.d.ts +10 -0
- package/lib/types/host-routes.d.ts +3 -1
- package/lib/types/host-runner.d.ts +12 -1
- package/lib/types/host-service.d.ts +5 -1
- package/lib/types/protocol.d.ts +6 -0
- package/package.json +1 -2
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/TaskBoardSettingsCard.tsx +10 -10
- package/src/client/board/TaskBoard.tsx +5 -5
- package/src/client/board/TaskCard.tsx +1 -0
- package/src/client/board/TaskDetail.tsx +1 -1
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +12 -3
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +20 -3
- package/src/core/execution.ts +1 -2
- package/src/core/schedule.ts +57 -10
- package/src/host-ledger.ts +67 -6
- package/src/host-routes.ts +11 -3
- package/src/host-runner.ts +24 -6
- package/src/host-service.ts +24 -7
- package/src/protocol.ts +7 -0
package/lib/index.js
CHANGED
|
@@ -51,19 +51,65 @@ function isValidCron(expr) {
|
|
|
51
51
|
* can never match (for example `0 0 30 2 *`). The five-year horizon includes
|
|
52
52
|
* a full leap cycle, so a valid February 29 schedule remains reachable from
|
|
53
53
|
* every non-leap year.
|
|
54
|
+
*
|
|
55
|
+
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
56
|
+
* field sets instead of scanning every minute: a sparse expression such as
|
|
57
|
+
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
58
|
+
* next leap day. Wall-clock field construction + the final `matches` re-check
|
|
59
|
+
* preserve the old minute scan's DST semantics exactly (nonexistent spring
|
|
60
|
+
* minutes normalize forward and the repeated fall-back hour is never visited).
|
|
54
61
|
*/
|
|
55
62
|
function nextRunAtMs(expr, fromMs) {
|
|
56
63
|
const schedule = parseCron(expr);
|
|
57
64
|
if (schedule === null) return void 0;
|
|
58
65
|
if (!hasPossibleCalendarDay(schedule)) return void 0;
|
|
59
66
|
const from = new Date(fromMs);
|
|
60
|
-
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
|
|
61
67
|
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b);
|
|
69
|
+
const sortedHours = [...schedule.hours].sort((a, b) => a - b);
|
|
70
|
+
const sortedMonths = [...schedule.months].sort((a, b) => a - b);
|
|
71
|
+
let year = from.getFullYear();
|
|
72
|
+
let month = from.getMonth() + 1;
|
|
73
|
+
let day = from.getDate();
|
|
74
|
+
let hour = from.getHours();
|
|
75
|
+
let minute = from.getMinutes() + 1;
|
|
76
|
+
while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
|
|
77
|
+
for (const candidateMonth of sortedMonths) {
|
|
78
|
+
if (candidateMonth < month) continue;
|
|
79
|
+
const daysInMonth = new Date(year, candidateMonth, 0).getDate();
|
|
80
|
+
const dayStart = candidateMonth === month ? day : 1;
|
|
81
|
+
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
82
|
+
if (!dayCandidate(schedule, new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0))) continue;
|
|
83
|
+
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0;
|
|
84
|
+
for (const candidateHour of sortedHours) {
|
|
85
|
+
if (candidateHour < hourStart) continue;
|
|
86
|
+
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0;
|
|
87
|
+
for (const candidateMinute of sortedMinutes) {
|
|
88
|
+
if (candidateMinute < minuteStart) continue;
|
|
89
|
+
const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0);
|
|
90
|
+
const time = candidate.getTime();
|
|
91
|
+
if (time <= fromMs) continue;
|
|
92
|
+
if (time > limitMs) return void 0;
|
|
93
|
+
if (matches(schedule, candidate)) return time;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
year += 1;
|
|
99
|
+
month = 1;
|
|
100
|
+
day = 1;
|
|
101
|
+
hour = 0;
|
|
102
|
+
minute = 0;
|
|
65
103
|
}
|
|
66
104
|
}
|
|
105
|
+
/** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
|
|
106
|
+
function dayCandidate(schedule, date) {
|
|
107
|
+
const dayMatches = schedule.days.has(date.getDate());
|
|
108
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
109
|
+
if (schedule.dayWildcard) return weekdayMatches;
|
|
110
|
+
if (schedule.weekdayWildcard) return dayMatches;
|
|
111
|
+
return dayMatches || weekdayMatches;
|
|
112
|
+
}
|
|
67
113
|
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
68
114
|
function hasPossibleCalendarDay(schedule) {
|
|
69
115
|
if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
|
|
@@ -122,11 +168,7 @@ function matches(schedule, date) {
|
|
|
122
168
|
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
123
169
|
if (!schedule.hours.has(date.getHours())) return false;
|
|
124
170
|
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
125
|
-
|
|
126
|
-
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
127
|
-
if (schedule.dayWildcard) return weekdayMatches;
|
|
128
|
-
if (schedule.weekdayWildcard) return dayMatches;
|
|
129
|
-
return dayMatches || weekdayMatches;
|
|
171
|
+
return dayCandidate(schedule, date);
|
|
130
172
|
}
|
|
131
173
|
function isDigits(value) {
|
|
132
174
|
return /^\d+$/.test(value);
|
|
@@ -836,7 +878,7 @@ function mergeTask(a, b) {
|
|
|
836
878
|
executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
|
|
837
879
|
};
|
|
838
880
|
}
|
|
839
|
-
function parseHostTasks(values
|
|
881
|
+
function parseHostTasks(values) {
|
|
840
882
|
const rawById = /* @__PURE__ */ new Map();
|
|
841
883
|
for (const value of values) {
|
|
842
884
|
if (typeof value !== "object" || value === null) continue;
|
|
@@ -868,11 +910,14 @@ var HostTaskLedger = class {
|
|
|
868
910
|
lockFd;
|
|
869
911
|
file;
|
|
870
912
|
lockFile;
|
|
913
|
+
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
914
|
+
schedulerFile;
|
|
871
915
|
constructor(dir = join(dshHome(), "task-board"), now = Date.now) {
|
|
872
916
|
this.now = now;
|
|
873
917
|
mkdirSync(dir, { recursive: true });
|
|
874
918
|
this.file = join(dir, "ledger-v2.json");
|
|
875
919
|
this.lockFile = join(dir, "ledger-v2.lock");
|
|
920
|
+
this.schedulerFile = join(dir, "scheduler-v2.json");
|
|
876
921
|
this.lockFd = this.acquireLock();
|
|
877
922
|
try {
|
|
878
923
|
this.document = this.load(dir);
|
|
@@ -885,14 +930,22 @@ var HostTaskLedger = class {
|
|
|
885
930
|
throw error;
|
|
886
931
|
}
|
|
887
932
|
}
|
|
888
|
-
|
|
933
|
+
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
934
|
+
summary() {
|
|
889
935
|
const { importedSources: _imports, ...scheduler } = this.document.scheduler;
|
|
890
936
|
return {
|
|
891
937
|
revision: this.document.revision,
|
|
892
|
-
tasks: cloneTasks(this.document.tasks),
|
|
893
938
|
scheduler: { ...scheduler }
|
|
894
939
|
};
|
|
895
940
|
}
|
|
941
|
+
state() {
|
|
942
|
+
const { revision, scheduler } = this.summary();
|
|
943
|
+
return {
|
|
944
|
+
revision,
|
|
945
|
+
tasks: cloneTasks(this.document.tasks),
|
|
946
|
+
scheduler
|
|
947
|
+
};
|
|
948
|
+
}
|
|
896
949
|
subscribe(listener) {
|
|
897
950
|
this.listeners.add(listener);
|
|
898
951
|
return () => {
|
|
@@ -962,6 +1015,10 @@ var HostTaskLedger = class {
|
|
|
962
1015
|
...this.document.scheduler,
|
|
963
1016
|
...patch
|
|
964
1017
|
};
|
|
1018
|
+
if (patch.lastTickAt !== void 0 && Object.keys(patch).every((key) => key === "lastTickAt")) {
|
|
1019
|
+
this.writeSchedulerSidecar();
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
965
1022
|
this.commit(false);
|
|
966
1023
|
}
|
|
967
1024
|
attachSession(taskId, executionId, sessionId) {
|
|
@@ -988,7 +1045,7 @@ var HostTaskLedger = class {
|
|
|
988
1045
|
const sources = new Set(this.document.scheduler.importedSources ?? []);
|
|
989
1046
|
if (sources.has(action.sourceId)) return { state: this.state() };
|
|
990
1047
|
const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && !isValidCron(task.schedule.cron)).map((task) => task.id);
|
|
991
|
-
const incoming = parseHostTasks(action.tasks
|
|
1048
|
+
const incoming = parseHostTasks(action.tasks);
|
|
992
1049
|
const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
|
|
993
1050
|
for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
|
|
994
1051
|
this.document.tasks = [...merged.values()];
|
|
@@ -1116,7 +1173,7 @@ var HostTaskLedger = class {
|
|
|
1116
1173
|
try {
|
|
1117
1174
|
const parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
1118
1175
|
if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
|
|
1119
|
-
const tasks = parseHostTasks(parsed.tasks
|
|
1176
|
+
const tasks = parseHostTasks(parsed.tasks);
|
|
1120
1177
|
const invalidScheduleIds = parsed.tasks.flatMap((value) => {
|
|
1121
1178
|
if (typeof value !== "object" || value === null) return [];
|
|
1122
1179
|
const row = value;
|
|
@@ -1124,6 +1181,9 @@ var HostTaskLedger = class {
|
|
|
1124
1181
|
const cron = row.schedule.cron;
|
|
1125
1182
|
return typeof cron !== "string" || !isValidCron(cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
|
|
1126
1183
|
});
|
|
1184
|
+
const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === "number" ? parsed.scheduler.lastTickAt : void 0;
|
|
1185
|
+
const sidecarLastTickAt = this.readSchedulerSidecar();
|
|
1186
|
+
const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
|
|
1127
1187
|
return {
|
|
1128
1188
|
schemaVersion: 2,
|
|
1129
1189
|
revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
|
|
@@ -1131,7 +1191,7 @@ var HostTaskLedger = class {
|
|
|
1131
1191
|
scheduler: {
|
|
1132
1192
|
timeZone: timeZone(),
|
|
1133
1193
|
ledgerId: typeof parsed.scheduler?.ledgerId === "string" && parsed.scheduler.ledgerId !== "" ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
1134
|
-
...
|
|
1194
|
+
...lastTickAt === void 0 ? {} : { lastTickAt },
|
|
1135
1195
|
...typeof parsed.scheduler?.error === "string" ? { error: parsed.scheduler.error } : {},
|
|
1136
1196
|
...invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}` } : {},
|
|
1137
1197
|
...Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter((x) => typeof x === "string") } : {}
|
|
@@ -1167,6 +1227,47 @@ var HostTaskLedger = class {
|
|
|
1167
1227
|
fingerprint: request.fingerprint
|
|
1168
1228
|
}));
|
|
1169
1229
|
}
|
|
1230
|
+
readSchedulerSidecar() {
|
|
1231
|
+
try {
|
|
1232
|
+
const parsed = JSON.parse(readFileSync(this.schedulerFile, "utf8"));
|
|
1233
|
+
return typeof parsed.lastTickAt === "number" && Number.isFinite(parsed.lastTickAt) ? parsed.lastTickAt : void 0;
|
|
1234
|
+
} catch {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
|
|
1239
|
+
writeSchedulerSidecar() {
|
|
1240
|
+
const payload = JSON.stringify({ lastTickAt: this.document.scheduler.lastTickAt });
|
|
1241
|
+
mkdirSync(dirname(this.schedulerFile), { recursive: true });
|
|
1242
|
+
const tmp = `${this.schedulerFile}.tmp-${process.pid}`;
|
|
1243
|
+
let fd;
|
|
1244
|
+
try {
|
|
1245
|
+
fd = openSync(tmp, "w", 384);
|
|
1246
|
+
writeFileSync(fd, payload, { encoding: "utf8" });
|
|
1247
|
+
fsyncSync(fd);
|
|
1248
|
+
closeSync(fd);
|
|
1249
|
+
fd = void 0;
|
|
1250
|
+
try {
|
|
1251
|
+
chmodSync(tmp, 384);
|
|
1252
|
+
} catch {}
|
|
1253
|
+
renameSync(tmp, this.schedulerFile);
|
|
1254
|
+
try {
|
|
1255
|
+
const dirFd = openSync(dirname(this.schedulerFile), "r");
|
|
1256
|
+
try {
|
|
1257
|
+
fsyncSync(dirFd);
|
|
1258
|
+
} finally {
|
|
1259
|
+
closeSync(dirFd);
|
|
1260
|
+
}
|
|
1261
|
+
} catch {}
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
if (fd !== void 0) closeSync(fd);
|
|
1264
|
+
try {
|
|
1265
|
+
unlinkSync(tmp);
|
|
1266
|
+
} catch {}
|
|
1267
|
+
throw error;
|
|
1268
|
+
}
|
|
1269
|
+
this.notify();
|
|
1270
|
+
}
|
|
1170
1271
|
commit(bumpRevision = true) {
|
|
1171
1272
|
if (bumpRevision) this.document.revision += 1;
|
|
1172
1273
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
@@ -1319,16 +1420,28 @@ var HostExecutionRunner = class {
|
|
|
1319
1420
|
const response = await this.api.sessions.list(request({}));
|
|
1320
1421
|
return response.result.ok ? {
|
|
1321
1422
|
known: true,
|
|
1322
|
-
count: response.result.value.items.filter((item) => item.running).length
|
|
1423
|
+
count: response.result.value.items.filter((item) => item.running).length,
|
|
1424
|
+
items: response.result.value.items
|
|
1323
1425
|
} : { known: false };
|
|
1324
1426
|
} catch {
|
|
1325
1427
|
return { known: false };
|
|
1326
1428
|
}
|
|
1327
1429
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1430
|
+
/**
|
|
1431
|
+
* Resolve one execution's outcome. The caller may pass the session list it
|
|
1432
|
+
* already fetched this poll tick; otherwise inspect lists sessions itself.
|
|
1433
|
+
* Sharing the list keeps a poll with E open executions at one list RPC
|
|
1434
|
+
* instead of 1 + E.
|
|
1435
|
+
*/
|
|
1436
|
+
async inspect(sessionId, startedAt = 0, sessions) {
|
|
1437
|
+
let items;
|
|
1438
|
+
if (sessions !== void 0) items = sessions;
|
|
1439
|
+
else {
|
|
1440
|
+
const response = await this.api.sessions.list(request({}));
|
|
1441
|
+
if (!response.result.ok) return { outcome: "pending" };
|
|
1442
|
+
items = response.result.value.items;
|
|
1443
|
+
}
|
|
1444
|
+
const summary = items.find((item) => item.sessionId === sessionId);
|
|
1332
1445
|
if (summary === void 0) return {
|
|
1333
1446
|
outcome: "cancelled",
|
|
1334
1447
|
error: "execution session no longer exists"
|
|
@@ -1688,6 +1801,7 @@ var TaskBoardHostService = class {
|
|
|
1688
1801
|
tickInFlight = false;
|
|
1689
1802
|
active = true;
|
|
1690
1803
|
preventIdleSleep = false;
|
|
1804
|
+
lastPowerJson = "";
|
|
1691
1805
|
now;
|
|
1692
1806
|
constructor(api, options = {}) {
|
|
1693
1807
|
this.ledger = options.ledger ?? new HostTaskLedger();
|
|
@@ -1699,6 +1813,9 @@ var TaskBoardHostService = class {
|
|
|
1699
1813
|
this.emit();
|
|
1700
1814
|
});
|
|
1701
1815
|
this.power.subscribe(() => {
|
|
1816
|
+
const json = JSON.stringify(this.power.snapshot());
|
|
1817
|
+
if (json === this.lastPowerJson) return;
|
|
1818
|
+
this.lastPowerJson = json;
|
|
1702
1819
|
this.emit();
|
|
1703
1820
|
});
|
|
1704
1821
|
}
|
|
@@ -1743,6 +1860,15 @@ var TaskBoardHostService = class {
|
|
|
1743
1860
|
power: this.power.snapshot()
|
|
1744
1861
|
};
|
|
1745
1862
|
}
|
|
1863
|
+
/** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
|
|
1864
|
+
eventPayload() {
|
|
1865
|
+
const { revision, scheduler } = this.ledger.summary();
|
|
1866
|
+
return {
|
|
1867
|
+
revision,
|
|
1868
|
+
scheduler,
|
|
1869
|
+
power: this.power.snapshot()
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1746
1872
|
subscribe(listener) {
|
|
1747
1873
|
this.listeners.add(listener);
|
|
1748
1874
|
return () => {
|
|
@@ -1787,14 +1913,14 @@ var TaskBoardHostService = class {
|
|
|
1787
1913
|
armedSchedules: this.armedSchedules(),
|
|
1788
1914
|
sessionStateKnown: running.known
|
|
1789
1915
|
});
|
|
1790
|
-
if (running.known) await this.reconcileExecutions();
|
|
1791
|
-
this.emit();
|
|
1916
|
+
if (running.known) await this.reconcileExecutions(running.items);
|
|
1792
1917
|
}
|
|
1793
|
-
|
|
1918
|
+
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
1919
|
+
async reconcileExecutions(sessions) {
|
|
1794
1920
|
for (const task of this.ledger.state().tasks) for (const execution of task.executions) {
|
|
1795
1921
|
if (execution.sessionId === void 0 || execution.endedAt !== void 0) continue;
|
|
1796
1922
|
try {
|
|
1797
|
-
const result = await this.runner.inspect(execution.sessionId, execution.startedAt);
|
|
1923
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
|
|
1798
1924
|
if (result.outcome === "pending") continue;
|
|
1799
1925
|
this.ledger.settle(task.id, execution.id, result.outcome, "error" in result ? result.error : void 0);
|
|
1800
1926
|
} catch {}
|
|
@@ -1950,6 +2076,12 @@ function resolveAccess(access) {
|
|
|
1950
2076
|
...access.proxyToken === void 0 ? {} : { proxyToken: access.proxyToken }
|
|
1951
2077
|
};
|
|
1952
2078
|
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
2081
|
+
* header and is refused, but a curl with a forged Origin passes this too.
|
|
2082
|
+
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
2083
|
+
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
2084
|
+
*/
|
|
1953
2085
|
function browserSameOriginMarker(req) {
|
|
1954
2086
|
return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
|
|
1955
2087
|
}
|
|
@@ -1972,7 +2104,9 @@ function matchesToken(candidate, expected) {
|
|
|
1972
2104
|
/**
|
|
1973
2105
|
* Task-board route fence. Direct desktop access uses the repository-wide
|
|
1974
2106
|
* loopback socket + Host guard and additionally requires a browser same-origin
|
|
1975
|
-
* marker
|
|
2107
|
+
* marker: a bare local curl without any browser signal cannot exercise the
|
|
2108
|
+
* agent control plane (a forged Origin does pass the marker — it is a
|
|
2109
|
+
* tripwire, the socket/Host/origin-equality checks carry the authority).
|
|
1976
2110
|
* Authenticated proxies must be explicitly allowlisted and replace the
|
|
1977
2111
|
* internal token header after their own authentication step.
|
|
1978
2112
|
*/
|
|
@@ -2075,12 +2209,8 @@ function makeTaskBoardRoutes(service, access = {}) {
|
|
|
2075
2209
|
connection: "keep-alive"
|
|
2076
2210
|
});
|
|
2077
2211
|
const push = () => {
|
|
2078
|
-
const
|
|
2079
|
-
res.write(`data: ${JSON.stringify(
|
|
2080
|
-
revision: snapshot.revision,
|
|
2081
|
-
scheduler: snapshot.scheduler,
|
|
2082
|
-
power: snapshot.power
|
|
2083
|
-
})}\n\n`);
|
|
2212
|
+
const payload = service.eventPayload();
|
|
2213
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
2084
2214
|
};
|
|
2085
2215
|
const unsubscribe = service.subscribe(push);
|
|
2086
2216
|
const heartbeat = setInterval(() => {
|
|
@@ -88,6 +88,27 @@ export declare function ValueField(props: FieldProps & {
|
|
|
88
88
|
/** Placeholder shown while the draft is empty. */
|
|
89
89
|
placeholder?: string;
|
|
90
90
|
}): import("react").JSX.Element;
|
|
91
|
+
interface SelectOption {
|
|
92
|
+
value: string;
|
|
93
|
+
label: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
97
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
98
|
+
* selectors keep working; under the default appearance it renders a
|
|
99
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
100
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
101
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
102
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
103
|
+
*/
|
|
104
|
+
export declare function SelectField(props: {
|
|
105
|
+
id: string;
|
|
106
|
+
options: ReadonlyArray<SelectOption>;
|
|
107
|
+
value: string;
|
|
108
|
+
disabled: boolean;
|
|
109
|
+
invalid: boolean;
|
|
110
|
+
onEdit: (text: string) => void;
|
|
111
|
+
}): import("react").JSX.Element;
|
|
91
112
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
92
113
|
export declare function BooleanField(props: FieldProps & {
|
|
93
114
|
/** Copy for the inherit option. */
|
|
@@ -107,3 +128,4 @@ export declare function ChoiceField(props: FieldProps & {
|
|
|
107
128
|
label: string;
|
|
108
129
|
}>;
|
|
109
130
|
}): import("react").JSX.Element;
|
|
131
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Task-board settings for availability, agent announcement, and optional Host
|
|
3
|
-
* idle-sleep protection. Registers into the `
|
|
4
|
-
* plugin
|
|
3
|
+
* idle-sleep protection. Registers into the `web-ui.plugin.item` child slot
|
|
4
|
+
* the Web UI plugin group renders, bound to the `task-board` namespace.
|
|
5
5
|
*/
|
|
6
6
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
7
7
|
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { TaskRecord } from '../core/tasks.ts';
|
|
2
|
-
import { type TaskBoardAction, type TaskBoardSnapshot } from '../protocol.ts';
|
|
2
|
+
import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from '../protocol.ts';
|
|
3
3
|
export interface TaskBoardHostTransport {
|
|
4
4
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
|
|
5
5
|
state(): Promise<TaskBoardSnapshot>;
|
|
6
6
|
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
|
|
7
|
-
subscribe(listener: () => void): () => void;
|
|
7
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
8
8
|
}
|
|
9
9
|
export declare class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
10
10
|
private readonly storage;
|
|
@@ -14,5 +14,5 @@ export declare class HttpTaskBoardHostTransport implements TaskBoardHostTranspor
|
|
|
14
14
|
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
|
|
15
15
|
private post;
|
|
16
16
|
private request;
|
|
17
|
-
subscribe(listener: () => void): () => void;
|
|
17
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
18
18
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sidebar entry injection core.
|
|
3
|
+
*
|
|
4
|
+
* dsh's sidebar shell exposes no slot an external plugin can register into,
|
|
5
|
+
* so the entry row is injected between the shell's New Session button and the
|
|
6
|
+
* workspace browser. The injection self-heals: a MutationObserver watches the
|
|
7
|
+
* sidebar root and re-inserts the row whenever a React re-render displaces it
|
|
8
|
+
* (re-insertion happens in the same frame, before paint, so no flicker).
|
|
9
|
+
*
|
|
10
|
+
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
11
|
+
* reconciliation; the view it toggles is a separate root owned by the caller.
|
|
12
|
+
*
|
|
13
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
14
|
+
* edit the shared source and re-run the sync instead of editing a copy.
|
|
15
|
+
*/
|
|
16
|
+
/** Per-package configuration for one sidebar entry row. */
|
|
17
|
+
export interface SidebarEntryOptions {
|
|
18
|
+
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
19
|
+
rowAttribute: string;
|
|
20
|
+
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
|
21
|
+
rowSelector: string;
|
|
22
|
+
/**
|
|
23
|
+
* L2 semantic-attribute plugin id (issue #506, enum table:
|
|
24
|
+
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
|
25
|
+
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
26
|
+
* leaves the row without semantic attributes.
|
|
27
|
+
*/
|
|
28
|
+
plugin?: string;
|
|
29
|
+
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
30
|
+
icon: string;
|
|
31
|
+
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
32
|
+
css: Record<string, string>;
|
|
33
|
+
/** Localized row label (aria-label + visible text). */
|
|
34
|
+
label(): string;
|
|
35
|
+
/** Optional localized tooltip (title attribute). */
|
|
36
|
+
tooltip?(): string;
|
|
37
|
+
/** Click action (open/toggle the owning panel). */
|
|
38
|
+
onToggle(): void;
|
|
39
|
+
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
40
|
+
position: 'before' | 'after';
|
|
41
|
+
/**
|
|
42
|
+
* Selectors of the sibling plugin entry rows this package orders against
|
|
43
|
+
* (its own row included — the placement guard excludes a row that is
|
|
44
|
+
* already inside the root). Each package passes the same list it used
|
|
45
|
+
* before the consolidation so the rendered order stays stable.
|
|
46
|
+
*/
|
|
47
|
+
familySelectors: readonly string[];
|
|
48
|
+
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
49
|
+
active?: {
|
|
50
|
+
subscribe(listener: () => void): () => void;
|
|
51
|
+
isOpen(): boolean;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
56
|
+
* on later React re-renders.
|
|
57
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
58
|
+
* @returns disposer removing the entry and its observers.
|
|
59
|
+
*/
|
|
60
|
+
export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar entry injection.
|
|
2
|
+
* Sidebar entry injection — package-specific wiring over the shared core.
|
|
3
3
|
*
|
|
4
4
|
* dsh's sidebar shell exposes no slot an external plugin can register into
|
|
5
5
|
* (`sidebar.workspaces` / `sidebar.settings` are single-occupant and already
|
|
6
|
-
* taken), so
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* reconciliation; the board view it toggles is a separate React root mounted
|
|
14
|
-
* in the center column (see board-mount.ts).
|
|
6
|
+
* taken), so the entry row is injected between the shell's New Session button
|
|
7
|
+
* and the workspace browser. The DOM injection / self-healing / idempotency
|
|
8
|
+
* logic lives exactly once in shared/client/sidebar-entry-core.ts (synced
|
|
9
|
+
* copy); this wrapper supplies the task-board icon, copy, CSS module, and the
|
|
10
|
+
* board toggle. The row is plain DOM (no React tree); the board view it
|
|
11
|
+
* toggles is a separate React root mounted in the center column
|
|
12
|
+
* (see board-mount.ts).
|
|
15
13
|
*/
|
|
16
14
|
import type { BoardController } from '../core/controller.ts';
|
|
17
15
|
/** Stable data attribute identifying the injected entry row. */
|
|
@@ -16,12 +16,12 @@ import type { ExecutionService } from './execution.ts';
|
|
|
16
16
|
import type { TaskStore } from './store.ts';
|
|
17
17
|
import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
|
|
18
18
|
import { type TaskUpdatePatch } from './use-cases/task-update.ts';
|
|
19
|
-
import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts';
|
|
19
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts';
|
|
20
20
|
export interface TaskBoardTransport {
|
|
21
21
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
|
|
22
22
|
state(): Promise<TaskBoardSnapshot>;
|
|
23
23
|
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
|
|
24
|
-
subscribe(listener: () => void): () => void;
|
|
24
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
|
|
25
25
|
}
|
|
26
26
|
/** The sessions face the controller needs for navigation awareness. */
|
|
27
27
|
export interface SessionsControllerFace {
|
|
@@ -209,6 +209,13 @@ export declare class BoardController {
|
|
|
209
209
|
private performRemote;
|
|
210
210
|
private initializeRemote;
|
|
211
211
|
private doInitializeRemote;
|
|
212
|
+
/**
|
|
213
|
+
* SSE frames carry revision/scheduler/power. When the revision matches the
|
|
214
|
+
* one already applied, apply the frame's scheduler/power in place and skip
|
|
215
|
+
* the full /state fetch; otherwise the 5 s heartbeat would re-clone and
|
|
216
|
+
* re-serialize the whole ledger per tab even while nothing changes.
|
|
217
|
+
*/
|
|
218
|
+
private onRemoteEvent;
|
|
212
219
|
private refreshRemote;
|
|
213
220
|
private acceptRemote;
|
|
214
221
|
private notify;
|
|
@@ -38,5 +38,12 @@ export declare function isValidCron(expr: string): boolean;
|
|
|
38
38
|
* can never match (for example `0 0 30 2 *`). The five-year horizon includes
|
|
39
39
|
* a full leap cycle, so a valid February 29 schedule remains reachable from
|
|
40
40
|
* every non-leap year.
|
|
41
|
+
*
|
|
42
|
+
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
43
|
+
* field sets instead of scanning every minute: a sparse expression such as
|
|
44
|
+
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
45
|
+
* next leap day. Wall-clock field construction + the final `matches` re-check
|
|
46
|
+
* preserve the old minute scan's DST semantics exactly (nonexistent spring
|
|
47
|
+
* minutes normalize forward and the repeated fall-back hour is never visited).
|
|
41
48
|
*/
|
|
42
49
|
export declare function nextRunAtMs(expr: string, fromMs: number): number | undefined;
|
|
@@ -18,7 +18,14 @@ export declare class HostTaskLedger {
|
|
|
18
18
|
private lockFd;
|
|
19
19
|
readonly file: string;
|
|
20
20
|
readonly lockFile: string;
|
|
21
|
+
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
22
|
+
readonly schedulerFile: string;
|
|
21
23
|
constructor(dir?: string, now?: () => number);
|
|
24
|
+
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
25
|
+
summary(): {
|
|
26
|
+
revision: number;
|
|
27
|
+
scheduler: TaskBoardSchedulerSnapshot;
|
|
28
|
+
};
|
|
22
29
|
state(): LedgerState;
|
|
23
30
|
subscribe(listener: () => void): () => void;
|
|
24
31
|
dispose(): void;
|
|
@@ -36,6 +43,9 @@ export declare class HostTaskLedger {
|
|
|
36
43
|
private reconcileInterruptedStarts;
|
|
37
44
|
private load;
|
|
38
45
|
private syncRecentRequests;
|
|
46
|
+
private readSchedulerSidecar;
|
|
47
|
+
/** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
|
|
48
|
+
private writeSchedulerSidecar;
|
|
39
49
|
private commit;
|
|
40
50
|
private notify;
|
|
41
51
|
private acquireLock;
|
|
@@ -15,7 +15,9 @@ interface ResolvedRouteAccess {
|
|
|
15
15
|
/**
|
|
16
16
|
* Task-board route fence. Direct desktop access uses the repository-wide
|
|
17
17
|
* loopback socket + Host guard and additionally requires a browser same-origin
|
|
18
|
-
* marker
|
|
18
|
+
* marker: a bare local curl without any browser signal cannot exercise the
|
|
19
|
+
* agent control plane (a forged Origin does pass the marker — it is a
|
|
20
|
+
* tripwire, the socket/Host/origin-equality checks carry the authority).
|
|
19
21
|
* Authenticated proxies must be explicitly allowlisted and replace the
|
|
20
22
|
* internal token header after their own authentication step.
|
|
21
23
|
*/
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
|
|
2
2
|
import type { TaskRecord } from './core/tasks.ts';
|
|
3
|
+
/** One session-list row, extracted from the sessions.list RPC result. */
|
|
4
|
+
export type SessionSummary = Extract<Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'], {
|
|
5
|
+
ok: true;
|
|
6
|
+
}>['value']['items'][number];
|
|
3
7
|
export type ExecutionInspection = {
|
|
4
8
|
outcome: 'pending';
|
|
5
9
|
} | {
|
|
@@ -23,8 +27,15 @@ export declare class HostExecutionRunner {
|
|
|
23
27
|
listRunning(): Promise<{
|
|
24
28
|
known: true;
|
|
25
29
|
count: number;
|
|
30
|
+
items: SessionSummary[];
|
|
26
31
|
} | {
|
|
27
32
|
known: false;
|
|
28
33
|
}>;
|
|
29
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Resolve one execution's outcome. The caller may pass the session list it
|
|
36
|
+
* already fetched this poll tick; otherwise inspect lists sessions itself.
|
|
37
|
+
* Sharing the list keeps a poll with E open executions at one list RPC
|
|
38
|
+
* instead of 1 + E.
|
|
39
|
+
*/
|
|
40
|
+
inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
|
|
30
41
|
}
|
|
@@ -2,7 +2,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
|
|
|
2
2
|
import { HostTaskLedger } from './host-ledger.ts';
|
|
3
3
|
import { HostExecutionRunner } from './host-runner.ts';
|
|
4
4
|
import { PowerInhibitor } from './power-inhibitor.ts';
|
|
5
|
-
import type { TaskBoardAction, TaskBoardSnapshot } from './protocol.ts';
|
|
5
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts';
|
|
6
6
|
export declare class TaskBoardHostService {
|
|
7
7
|
readonly ledger: HostTaskLedger;
|
|
8
8
|
readonly runner: HostExecutionRunner;
|
|
@@ -15,6 +15,7 @@ export declare class TaskBoardHostService {
|
|
|
15
15
|
private tickInFlight;
|
|
16
16
|
private active;
|
|
17
17
|
private preventIdleSleep;
|
|
18
|
+
private lastPowerJson;
|
|
18
19
|
private readonly now;
|
|
19
20
|
constructor(api: ApiProxy, options?: {
|
|
20
21
|
ledger?: HostTaskLedger;
|
|
@@ -24,11 +25,14 @@ export declare class TaskBoardHostService {
|
|
|
24
25
|
start(): void;
|
|
25
26
|
setConfiguration(active: boolean, preventIdleSleep: boolean): void;
|
|
26
27
|
snapshot(): TaskBoardSnapshot;
|
|
28
|
+
/** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
|
|
29
|
+
eventPayload(): TaskBoardEventPayload;
|
|
27
30
|
subscribe(listener: () => void): () => void;
|
|
28
31
|
apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot;
|
|
29
32
|
dispose(): void;
|
|
30
33
|
private launch;
|
|
31
34
|
private pollSessions;
|
|
35
|
+
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
32
36
|
private reconcileExecutions;
|
|
33
37
|
private tickSchedule;
|
|
34
38
|
private armedSchedules;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface TaskBoardSnapshot {
|
|
|
26
26
|
scheduler: TaskBoardSchedulerSnapshot;
|
|
27
27
|
power: TaskBoardPowerSnapshot;
|
|
28
28
|
}
|
|
29
|
+
/** SSE event frame: revision/scheduler/power only, never the task list. */
|
|
30
|
+
export interface TaskBoardEventPayload {
|
|
31
|
+
revision: number;
|
|
32
|
+
scheduler: TaskBoardSchedulerSnapshot;
|
|
33
|
+
power: TaskBoardPowerSnapshot;
|
|
34
|
+
}
|
|
29
35
|
export type TaskBoardAction = {
|
|
30
36
|
kind: 'import';
|
|
31
37
|
sourceId: string;
|