@linxin666/dsh-client-ui-task-board 0.2.5 → 0.2.6
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/lib/client.js +1 -1
- package/lib/index.js +82 -4
- package/package.json +1 -1
- package/src/client/settings-card.module.css +2 -0
- package/src/host-ledger.ts +73 -4
package/lib/client.js
CHANGED
|
@@ -2908,7 +2908,7 @@ window.__ModuleLoader__.load({
|
|
|
2908
2908
|
}
|
|
2909
2909
|
//#endregion
|
|
2910
2910
|
//#region \0dsh-css:packages/dsh-task-board/src/client/settings-card.module.css.mjs
|
|
2911
|
-
const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
|
|
2911
|
+
const css = ".Jh0q7G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Jh0q7G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Jh0q7G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Jh0q7G_headerStatic{border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Jh0q7G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Jh0q7G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Jh0q7G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Jh0q7G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_chevronOpen{transform:rotate(180deg)}.Jh0q7G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Jh0q7G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Jh0q7G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Jh0q7G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Jh0q7G_discard,.Jh0q7G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Jh0q7G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Jh0q7G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Jh0q7G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Jh0q7G_discard:disabled,.Jh0q7G_save:disabled{opacity:.4;cursor:default}.Jh0q7G_discard:focus-visible,.Jh0q7G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Jh0q7G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Jh0q7G_field+.Jh0q7G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Jh0q7G_head{align-items:center;gap:8px;display:flex}.Jh0q7G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Jh0q7G_badges{align-items:center;gap:8px;display:inline-flex}.Jh0q7G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Jh0q7G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Jh0q7G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Jh0q7G_reset:disabled{cursor:default}.Jh0q7G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Jh0q7G_input,.Jh0q7G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_input:focus-visible,.Jh0q7G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Jh0q7G_input:disabled,.Jh0q7G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Jh0q7G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Jh0q7G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Jh0q7G_selectWrap{position:relative}.Jh0q7G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Jh0q7G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Jh0q7G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Jh0q7G_selectChevronOpen{transform:rotate(180deg)}.Jh0q7G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Jh0q7G_selectPopupOpen{opacity:1;transform:none}.Jh0q7G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Jh0q7G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Jh0q7G_selectOption:hover,.Jh0q7G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Jh0q7G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Jh0q7G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Jh0q7G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Jh0q7G_card,.Jh0q7G_header,.Jh0q7G_chevron,.Jh0q7G_chevronOpen,.Jh0q7G_discard,.Jh0q7G_save,.Jh0q7G_selectChevron,.Jh0q7G_selectChevronOpen,.Jh0q7G_selectPopup{transition:none}}";
|
|
2912
2912
|
const tagId = "@linxin666/dsh-client-ui-task-board/settings-card.module.css";
|
|
2913
2913
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
2914
2914
|
const tag = document.createElement("style");
|
package/lib/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
//#region src/core/schedule.ts
|
|
9
9
|
/** Inclusive ranges per field, in cron order. */
|
|
10
10
|
const FIELD_RANGES = [
|
|
@@ -861,6 +861,69 @@ function processIsAlive(pid) {
|
|
|
861
861
|
return error.code !== "ESRCH";
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
|
+
const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
865
|
+
let ownStartTime;
|
|
866
|
+
let ownStartTimeResolved = false;
|
|
867
|
+
/**
|
|
868
|
+
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
869
|
+
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
870
|
+
* crash leftover whose PID was reused by an unrelated process (issue #786)
|
|
871
|
+
* is detected as stale instead of blocking startup forever. Returns
|
|
872
|
+
* undefined when the platform probe is unavailable; callers fail closed.
|
|
873
|
+
*/
|
|
874
|
+
function processStartTimeMs(pid) {
|
|
875
|
+
if (process.platform === "win32") {
|
|
876
|
+
const probe = spawnSync("powershell", [
|
|
877
|
+
"-NoProfile",
|
|
878
|
+
"-NonInteractive",
|
|
879
|
+
"-Command",
|
|
880
|
+
"[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()"
|
|
881
|
+
], {
|
|
882
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
883
|
+
windowsHide: true
|
|
884
|
+
});
|
|
885
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
886
|
+
const started = Number(probe.stdout.toString("utf8").trim());
|
|
887
|
+
return Number.isFinite(started) ? started : void 0;
|
|
888
|
+
}
|
|
889
|
+
const env = {
|
|
890
|
+
...process.env,
|
|
891
|
+
LC_ALL: "C"
|
|
892
|
+
};
|
|
893
|
+
const probe = spawnSync("ps", [
|
|
894
|
+
"-o",
|
|
895
|
+
"lstart=",
|
|
896
|
+
"-p",
|
|
897
|
+
String(pid)
|
|
898
|
+
], {
|
|
899
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
900
|
+
env
|
|
901
|
+
});
|
|
902
|
+
if (probe.status === 0 && probe.stdout.length > 0) {
|
|
903
|
+
const started = Date.parse(probe.stdout.toString("utf8").trim());
|
|
904
|
+
if (Number.isFinite(started)) return started;
|
|
905
|
+
}
|
|
906
|
+
const elapsed = spawnSync("ps", [
|
|
907
|
+
"-o",
|
|
908
|
+
"etimes=",
|
|
909
|
+
"-p",
|
|
910
|
+
String(pid)
|
|
911
|
+
], {
|
|
912
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
913
|
+
env
|
|
914
|
+
});
|
|
915
|
+
if (elapsed.status !== 0 || elapsed.stdout.length === 0) return void 0;
|
|
916
|
+
const seconds = Number(elapsed.stdout.toString("utf8").trim());
|
|
917
|
+
if (!Number.isFinite(seconds)) return void 0;
|
|
918
|
+
return Date.now() - seconds * 1e3;
|
|
919
|
+
}
|
|
920
|
+
function ownProcessStartTimeMs() {
|
|
921
|
+
if (!ownStartTimeResolved) {
|
|
922
|
+
ownStartTimeResolved = true;
|
|
923
|
+
ownStartTime = processStartTimeMs(process.pid);
|
|
924
|
+
}
|
|
925
|
+
return ownStartTime;
|
|
926
|
+
}
|
|
864
927
|
function betterExecution(a, b) {
|
|
865
928
|
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
866
929
|
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
@@ -1308,7 +1371,8 @@ var HostTaskLedger = class {
|
|
|
1308
1371
|
const fd = openSync(this.lockFile, "wx", 384);
|
|
1309
1372
|
writeFileSync(fd, JSON.stringify({
|
|
1310
1373
|
pid: process.pid,
|
|
1311
|
-
token: this.lockToken
|
|
1374
|
+
token: this.lockToken,
|
|
1375
|
+
startedAt: ownProcessStartTimeMs()
|
|
1312
1376
|
}), { encoding: "utf8" });
|
|
1313
1377
|
fsyncSync(fd);
|
|
1314
1378
|
try {
|
|
@@ -1318,13 +1382,27 @@ var HostTaskLedger = class {
|
|
|
1318
1382
|
} catch (error) {
|
|
1319
1383
|
if (error.code !== "EEXIST") throw error;
|
|
1320
1384
|
let pid;
|
|
1385
|
+
let ownerStartedAt;
|
|
1321
1386
|
try {
|
|
1322
1387
|
const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
1323
1388
|
if (typeof owner.pid === "number") pid = owner.pid;
|
|
1389
|
+
if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
|
|
1324
1390
|
} catch {
|
|
1325
1391
|
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
|
|
1326
1392
|
}
|
|
1327
|
-
if (pid !== void 0 && processIsAlive(pid))
|
|
1393
|
+
if (pid !== void 0 && processIsAlive(pid)) {
|
|
1394
|
+
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
|
|
1395
|
+
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt !== ownerStartedAt : (() => {
|
|
1396
|
+
try {
|
|
1397
|
+
return statSync(this.lockFile).mtimeMs < actualStartedAt;
|
|
1398
|
+
} catch {
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
})()))) {
|
|
1402
|
+
const hint = ownerStartedAt !== void 0 && actualStartedAt === ownerStartedAt ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
|
|
1403
|
+
throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1328
1406
|
try {
|
|
1329
1407
|
unlinkSync(this.lockFile);
|
|
1330
1408
|
} catch (unlinkError) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/host-ledger.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
4
|
import { dirname, join } from 'node:path'
|
|
4
5
|
import { dshHome } from './dsh-home.ts'
|
|
5
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
@@ -68,6 +69,53 @@ function processIsAlive(pid: number): boolean {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
73
|
+
|
|
74
|
+
let ownStartTime: number | undefined
|
|
75
|
+
let ownStartTimeResolved = false
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
79
|
+
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
80
|
+
* crash leftover whose PID was reused by an unrelated process (issue #786)
|
|
81
|
+
* is detected as stale instead of blocking startup forever. Returns
|
|
82
|
+
* undefined when the platform probe is unavailable; callers fail closed.
|
|
83
|
+
*/
|
|
84
|
+
function processStartTimeMs(pid: number): number | undefined {
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
const probe = spawnSync(
|
|
87
|
+
'powershell',
|
|
88
|
+
['-NoProfile', '-NonInteractive', '-Command',
|
|
89
|
+
'[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
|
|
90
|
+
{ timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
|
|
91
|
+
)
|
|
92
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
93
|
+
const started = Number(probe.stdout.toString('utf8').trim())
|
|
94
|
+
return Number.isFinite(started) ? started : undefined
|
|
95
|
+
}
|
|
96
|
+
// POSIX: ps lstart with a forced English locale, falling back to the
|
|
97
|
+
// elapsed-seconds column when lstart cannot be parsed.
|
|
98
|
+
const env = { ...process.env, LC_ALL: 'C' }
|
|
99
|
+
const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
|
|
100
|
+
if (probe.status === 0 && probe.stdout.length > 0) {
|
|
101
|
+
const started = Date.parse(probe.stdout.toString('utf8').trim())
|
|
102
|
+
if (Number.isFinite(started)) return started
|
|
103
|
+
}
|
|
104
|
+
const elapsed = spawnSync('ps', ['-o', 'etimes=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
|
|
105
|
+
if (elapsed.status !== 0 || elapsed.stdout.length === 0) return undefined
|
|
106
|
+
const seconds = Number(elapsed.stdout.toString('utf8').trim())
|
|
107
|
+
if (!Number.isFinite(seconds)) return undefined
|
|
108
|
+
return Date.now() - seconds * 1000
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ownProcessStartTimeMs(): number | undefined {
|
|
112
|
+
if (!ownStartTimeResolved) {
|
|
113
|
+
ownStartTimeResolved = true
|
|
114
|
+
ownStartTime = processStartTimeMs(process.pid)
|
|
115
|
+
}
|
|
116
|
+
return ownStartTime
|
|
117
|
+
}
|
|
118
|
+
|
|
71
119
|
function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
|
|
72
120
|
if (a.endedAt === undefined && b.endedAt !== undefined) return b
|
|
73
121
|
if (b.endedAt === undefined && a.endedAt !== undefined) return a
|
|
@@ -512,7 +560,7 @@ export class HostTaskLedger {
|
|
|
512
560
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
513
561
|
try {
|
|
514
562
|
const fd = openSync(this.lockFile, 'wx', 0o600)
|
|
515
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken }), { encoding: 'utf8' })
|
|
563
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt: ownProcessStartTimeMs() }), { encoding: 'utf8' })
|
|
516
564
|
fsyncSync(fd)
|
|
517
565
|
try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
|
|
518
566
|
return fd
|
|
@@ -520,14 +568,35 @@ export class HostTaskLedger {
|
|
|
520
568
|
const code = (error as NodeJS.ErrnoException).code
|
|
521
569
|
if (code !== 'EEXIST') throw error
|
|
522
570
|
let pid: number | undefined
|
|
571
|
+
let ownerStartedAt: number | undefined
|
|
523
572
|
try {
|
|
524
|
-
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown }
|
|
573
|
+
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
|
|
525
574
|
if (typeof owner.pid === 'number') pid = owner.pid
|
|
575
|
+
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
526
576
|
} catch {
|
|
527
577
|
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
|
|
528
578
|
}
|
|
529
579
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
530
|
-
|
|
580
|
+
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
|
581
|
+
// A reused PID is exposed when the live process identity no longer
|
|
582
|
+
// matches the recorded one: either the recorded start time differs,
|
|
583
|
+
// or (legacy locks without one) the lock file predates the live
|
|
584
|
+
// process and therefore cannot have been written by it. Takeover is
|
|
585
|
+
// safe in both cases — the original owner is gone.
|
|
586
|
+
const staleReuse = actualStartedAt !== undefined && (
|
|
587
|
+
ownerStartedAt !== undefined
|
|
588
|
+
? actualStartedAt !== ownerStartedAt
|
|
589
|
+
: (() => {
|
|
590
|
+
try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
|
|
591
|
+
})()
|
|
592
|
+
)
|
|
593
|
+
if (!staleReuse) {
|
|
594
|
+
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt === ownerStartedAt
|
|
595
|
+
const hint = confirmedOwner
|
|
596
|
+
? ''
|
|
597
|
+
: `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
|
|
598
|
+
throw new Error(`task-board ledger is already owned by process ${pid}${hint}`)
|
|
599
|
+
}
|
|
531
600
|
}
|
|
532
601
|
try { unlinkSync(this.lockFile) } catch (unlinkError) {
|
|
533
602
|
if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
|