taskchef 7.22.4 → 7.22.5
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/.codex-plugin/plugin.json +1 -1
- package/README.md +40 -28
- package/docs/dashboard-lifecycle.md +168 -0
- package/docs/spec.md +117 -61
- package/docs/workflows.md +30 -18
- package/mcp/dashboard-session.js +51 -0
- package/package.json +2 -1
- package/src/dashboard-manager.js +633 -157
- package/src/dashboard-ownership.js +124 -13
- package/src/dashboard-session-process.js +179 -0
- package/src/dashboard-session.js +91 -0
- package/src/dashboard.js +288 -8
- package/src/mcp.js +2 -2
- package/src/version.js +1 -1
|
@@ -5,8 +5,12 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
export const DASHBOARD_CONTROL_VERSION = 1;
|
|
7
7
|
export const DASHBOARD_OWNER_FILE = ".taskchef-dashboard-owner.json";
|
|
8
|
+
export const DASHBOARD_HANDOFF_FILE = ".taskchef-dashboard-handoff.json";
|
|
8
9
|
export const DASHBOARD_CONTROL_CHALLENGE_PATH = "/api/control/challenge";
|
|
9
10
|
export const DASHBOARD_CONTROL_SHUTDOWN_PATH = "/api/control/shutdown";
|
|
11
|
+
export const DASHBOARD_CONTROL_SESSION_PATH = "/api/control/session";
|
|
12
|
+
export const DASHBOARD_CONTROL_HANDOFF_PATH = "/api/control/handoff";
|
|
13
|
+
export const DASHBOARD_CONTROL_HANDOFF_COMMIT_PATH = "/api/control/handoff/commit";
|
|
10
14
|
|
|
11
15
|
const OWNER_MAX_BYTES = 4_096;
|
|
12
16
|
const SECRET_PATTERN = /^[a-f0-9]{64}$/;
|
|
@@ -17,6 +21,19 @@ function ownerPath(workspace) {
|
|
|
17
21
|
return path.join(workspace, DASHBOARD_OWNER_FILE);
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
function handoffPath(workspace) {
|
|
25
|
+
return path.join(workspace, DASHBOARD_HANDOFF_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function syncDirectory(directory) {
|
|
29
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
30
|
+
try {
|
|
31
|
+
await handle.sync();
|
|
32
|
+
} finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
function exactKeys(value, keys) {
|
|
21
38
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
22
39
|
&& Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
|
|
@@ -75,6 +92,32 @@ export function dashboardOwnerMetadata({
|
|
|
75
92
|
};
|
|
76
93
|
}
|
|
77
94
|
|
|
95
|
+
export function dashboardHandoffMetadata({
|
|
96
|
+
workspace,
|
|
97
|
+
host,
|
|
98
|
+
port,
|
|
99
|
+
taskchefVersion,
|
|
100
|
+
serverVersion,
|
|
101
|
+
id,
|
|
102
|
+
pids,
|
|
103
|
+
secret,
|
|
104
|
+
}) {
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
service: "taskchef-dashboard-handoff",
|
|
108
|
+
controlVersion: DASHBOARD_CONTROL_VERSION,
|
|
109
|
+
workspace,
|
|
110
|
+
host,
|
|
111
|
+
port,
|
|
112
|
+
taskchefVersion,
|
|
113
|
+
serverVersion,
|
|
114
|
+
launcher: "session",
|
|
115
|
+
id,
|
|
116
|
+
pids,
|
|
117
|
+
proof: dashboardControlProof(secret, `handoff-final:${JSON.stringify(pids)}`, id),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
78
121
|
function validOwner(value) {
|
|
79
122
|
const keys = [
|
|
80
123
|
"schemaVersion", "service", "controlVersion", "workspace", "host", "port",
|
|
@@ -89,41 +132,108 @@ function validOwner(value) {
|
|
|
89
132
|
&& Number.isInteger(value.port) && value.port >= 0 && value.port <= 65_535
|
|
90
133
|
&& typeof value.taskchefVersion === "string"
|
|
91
134
|
&& typeof value.serverVersion === "string"
|
|
92
|
-
&&
|
|
135
|
+
&& new Set(["mcp", "session"]).has(value.launcher)
|
|
93
136
|
&& typeof value.secret === "string" && SECRET_PATTERN.test(value.secret);
|
|
94
137
|
}
|
|
95
138
|
|
|
96
|
-
|
|
97
|
-
const
|
|
139
|
+
function validHandoff(value) {
|
|
140
|
+
const keys = [
|
|
141
|
+
"schemaVersion", "service", "controlVersion", "workspace", "host", "port",
|
|
142
|
+
"taskchefVersion", "serverVersion", "launcher", "id", "pids", "proof",
|
|
143
|
+
];
|
|
144
|
+
return exactKeys(value, keys)
|
|
145
|
+
&& value.schemaVersion === 1
|
|
146
|
+
&& value.service === "taskchef-dashboard-handoff"
|
|
147
|
+
&& value.controlVersion === DASHBOARD_CONTROL_VERSION
|
|
148
|
+
&& typeof value.workspace === "string"
|
|
149
|
+
&& LOOPBACK_HOSTS.has(value.host)
|
|
150
|
+
&& Number.isInteger(value.port) && value.port >= 1 && value.port <= 65_535
|
|
151
|
+
&& typeof value.taskchefVersion === "string"
|
|
152
|
+
&& typeof value.serverVersion === "string"
|
|
153
|
+
&& value.launcher === "session"
|
|
154
|
+
&& validDashboardControlNonce(value.id)
|
|
155
|
+
&& Array.isArray(value.pids) && value.pids.length <= 64
|
|
156
|
+
&& value.pids.every((pid) => Number.isSafeInteger(pid) && pid > 1)
|
|
157
|
+
&& new Set(value.pids).size === value.pids.length
|
|
158
|
+
&& typeof value.proof === "string" && SECRET_PATTERN.test(value.proof);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readPrivateJson(filePath) {
|
|
98
162
|
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
99
163
|
try {
|
|
100
164
|
const info = await handle.stat();
|
|
101
165
|
if (!info.isFile() || (info.mode & 0o777) !== 0o600) {
|
|
102
|
-
throw new Error("dashboard
|
|
166
|
+
throw new Error("dashboard ownership record is not a private regular file");
|
|
103
167
|
}
|
|
104
168
|
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
|
105
|
-
throw new Error("dashboard
|
|
169
|
+
throw new Error("dashboard ownership record has a different owner");
|
|
106
170
|
}
|
|
107
|
-
if (info.size > OWNER_MAX_BYTES) throw new Error("dashboard
|
|
171
|
+
if (info.size > OWNER_MAX_BYTES) throw new Error("dashboard ownership record is too large");
|
|
108
172
|
const buffer = Buffer.alloc(OWNER_MAX_BYTES + 1);
|
|
109
173
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
110
|
-
if (bytesRead > OWNER_MAX_BYTES) throw new Error("dashboard
|
|
111
|
-
|
|
112
|
-
if (!validOwner(value) || value.workspace !== workspace) {
|
|
113
|
-
throw new Error("dashboard owner record is invalid");
|
|
114
|
-
}
|
|
115
|
-
return value;
|
|
174
|
+
if (bytesRead > OWNER_MAX_BYTES) throw new Error("dashboard ownership record is too large");
|
|
175
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
116
176
|
} finally {
|
|
117
177
|
await handle.close();
|
|
118
178
|
}
|
|
119
179
|
}
|
|
120
180
|
|
|
121
|
-
export async function
|
|
181
|
+
export async function readDashboardOwner(workspace) {
|
|
182
|
+
const filePath = ownerPath(workspace);
|
|
183
|
+
const value = await readPrivateJson(filePath);
|
|
184
|
+
if (!validOwner(value) || value.workspace !== workspace) {
|
|
185
|
+
throw new Error("dashboard owner record is invalid");
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function readDashboardHandoff(workspace) {
|
|
191
|
+
const value = await readPrivateJson(handoffPath(workspace));
|
|
192
|
+
if (!validHandoff(value) || value.workspace !== workspace) {
|
|
193
|
+
throw new Error("dashboard handoff record is invalid");
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function writeDashboardOwner(workspace, value, { signal } = {}) {
|
|
122
199
|
if (!validOwner(value) || value.workspace !== workspace) {
|
|
123
200
|
throw new Error("dashboard owner record does not match its canonical workspace");
|
|
124
201
|
}
|
|
125
202
|
const filePath = ownerPath(workspace);
|
|
126
203
|
const temporary = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
204
|
+
const throwIfAborted = () => {
|
|
205
|
+
if (signal?.aborted) {
|
|
206
|
+
throw Object.assign(new Error("dashboard owner publication was cancelled"), {
|
|
207
|
+
code: "TASKCHEF_DASHBOARD_START_TIMEOUT",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
throwIfAborted();
|
|
213
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
214
|
+
try {
|
|
215
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, { encoding: "utf8" });
|
|
216
|
+
throwIfAborted();
|
|
217
|
+
await handle.sync();
|
|
218
|
+
throwIfAborted();
|
|
219
|
+
} finally {
|
|
220
|
+
await handle.close();
|
|
221
|
+
}
|
|
222
|
+
throwIfAborted();
|
|
223
|
+
await rename(temporary, filePath);
|
|
224
|
+
await syncDirectory(workspace);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await unlink(temporary).catch(() => {});
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function writeDashboardHandoff(workspace, value) {
|
|
232
|
+
if (!validHandoff(value) || value.workspace !== workspace) {
|
|
233
|
+
throw new Error("dashboard handoff record does not match its canonical workspace");
|
|
234
|
+
}
|
|
235
|
+
const filePath = handoffPath(workspace);
|
|
236
|
+
const temporary = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
127
237
|
try {
|
|
128
238
|
const handle = await open(temporary, "wx", 0o600);
|
|
129
239
|
try {
|
|
@@ -133,6 +243,7 @@ export async function writeDashboardOwner(workspace, value) {
|
|
|
133
243
|
await handle.close();
|
|
134
244
|
}
|
|
135
245
|
await rename(temporary, filePath);
|
|
246
|
+
await syncDirectory(workspace);
|
|
136
247
|
} catch (error) {
|
|
137
248
|
await unlink(temporary).catch(() => {});
|
|
138
249
|
throw error;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { createDashboardServer } from "./dashboard.js";
|
|
4
|
+
import {
|
|
5
|
+
dashboardHandoffMetadata,
|
|
6
|
+
dashboardOwnerMetadata,
|
|
7
|
+
validDashboardControlSecret,
|
|
8
|
+
writeDashboardHandoff,
|
|
9
|
+
writeDashboardOwner,
|
|
10
|
+
} from "./dashboard-ownership.js";
|
|
11
|
+
import {
|
|
12
|
+
MAX_DASHBOARD_SESSION_PIDS,
|
|
13
|
+
createDashboardSessionLease,
|
|
14
|
+
validSessionPid,
|
|
15
|
+
} from "./dashboard-session.js";
|
|
16
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
17
|
+
|
|
18
|
+
export async function runDashboardSessionProcess({
|
|
19
|
+
workspace = process.env.TASKCHEF_DASHBOARD_WORKSPACE,
|
|
20
|
+
host = process.env.TASKCHEF_DASHBOARD_HOST ?? "127.0.0.1",
|
|
21
|
+
port = Number(process.env.TASKCHEF_DASHBOARD_PORT ?? 3210),
|
|
22
|
+
secret = process.env.TASKCHEF_DASHBOARD_SECRET,
|
|
23
|
+
sessionPid = Number(process.env.TASKCHEF_DASHBOARD_SESSION_PID),
|
|
24
|
+
sessionPids,
|
|
25
|
+
taskchefVersion = TASKCHEF_VERSION,
|
|
26
|
+
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
27
|
+
createServer = createDashboardServer,
|
|
28
|
+
writeOwner = writeDashboardOwner,
|
|
29
|
+
writeHandoff = writeDashboardHandoff,
|
|
30
|
+
createLease = createDashboardSessionLease,
|
|
31
|
+
processObject = process,
|
|
32
|
+
signal,
|
|
33
|
+
checkIntervalMs = Number(process.env.TASKCHEF_DASHBOARD_CHECK_INTERVAL_MS ?? 1_000),
|
|
34
|
+
exitGraceMs = Number(process.env.TASKCHEF_DASHBOARD_EXIT_GRACE_MS ?? 15_000),
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (processObject.env) delete processObject.env.TASKCHEF_DASHBOARD_SECRET;
|
|
37
|
+
sessionPids ??= JSON.parse(process.env.TASKCHEF_DASHBOARD_SESSION_PIDS ?? "[]");
|
|
38
|
+
if (!workspace) throw new Error("dashboard session workspace is required");
|
|
39
|
+
if (!validDashboardControlSecret(secret)) {
|
|
40
|
+
throw new Error("dashboard session control credential is invalid");
|
|
41
|
+
}
|
|
42
|
+
if (!validSessionPid(sessionPid)) throw new Error("dashboard session PID is invalid");
|
|
43
|
+
if (!Array.isArray(sessionPids) || sessionPids.length > MAX_DASHBOARD_SESSION_PIDS
|
|
44
|
+
|| sessionPids.some((pid) => !validSessionPid(pid))
|
|
45
|
+
|| new Set([sessionPid, ...sessionPids]).size > MAX_DASHBOARD_SESSION_PIDS) {
|
|
46
|
+
throw new Error("dashboard transferred session PIDs are invalid");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const canonicalWorkspace = await realpath(workspace);
|
|
50
|
+
let server = null;
|
|
51
|
+
let lease = null;
|
|
52
|
+
let closePromise = null;
|
|
53
|
+
let closing = false;
|
|
54
|
+
let publishingOwner = false;
|
|
55
|
+
let deferredClose = null;
|
|
56
|
+
const abortError = () => Object.assign(new Error("dashboard session startup was cancelled"), {
|
|
57
|
+
code: "TASKCHEF_DASHBOARD_START_TIMEOUT",
|
|
58
|
+
});
|
|
59
|
+
const startupClosedError = () => Object.assign(
|
|
60
|
+
new Error("dashboard session closed during startup"),
|
|
61
|
+
{ code: "TASKCHEF_DASHBOARD_START_EXIT" },
|
|
62
|
+
);
|
|
63
|
+
const removeListeners = () => {
|
|
64
|
+
processObject.off("SIGINT", close);
|
|
65
|
+
processObject.off("SIGTERM", close);
|
|
66
|
+
};
|
|
67
|
+
const performClose = async () => {
|
|
68
|
+
let cleanupError = null;
|
|
69
|
+
try {
|
|
70
|
+
lease?.close();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
cleanupError = error;
|
|
73
|
+
}
|
|
74
|
+
await server?.close();
|
|
75
|
+
if (cleanupError) throw cleanupError;
|
|
76
|
+
};
|
|
77
|
+
const close = () => {
|
|
78
|
+
closing = true;
|
|
79
|
+
if (!closePromise && publishingOwner) {
|
|
80
|
+
closePromise = new Promise((resolve, reject) => {
|
|
81
|
+
deferredClose = { resolve, reject };
|
|
82
|
+
}).finally(removeListeners);
|
|
83
|
+
} else if (!closePromise) {
|
|
84
|
+
closePromise = performClose().finally(removeListeners);
|
|
85
|
+
}
|
|
86
|
+
return closePromise;
|
|
87
|
+
};
|
|
88
|
+
const onAbort = () => {
|
|
89
|
+
void close().catch(() => {});
|
|
90
|
+
};
|
|
91
|
+
if (signal?.aborted) throw abortError();
|
|
92
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
processObject.once("SIGINT", close);
|
|
94
|
+
processObject.once("SIGTERM", close);
|
|
95
|
+
const control = {
|
|
96
|
+
secret,
|
|
97
|
+
onShutdown: close,
|
|
98
|
+
onSession: (pid) => {
|
|
99
|
+
if (closing) {
|
|
100
|
+
throw Object.assign(new Error("dashboard session is retiring"), {
|
|
101
|
+
code: "TASKCHEF_DASHBOARD_SESSION_RETIRING",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
lease.register(pid);
|
|
105
|
+
},
|
|
106
|
+
onHandoff: (pid) => {
|
|
107
|
+
if (closing) {
|
|
108
|
+
throw Object.assign(new Error("dashboard session is retiring"), {
|
|
109
|
+
code: "TASKCHEF_DASHBOARD_SESSION_RETIRING",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
lease.register(pid);
|
|
113
|
+
return lease.snapshot();
|
|
114
|
+
},
|
|
115
|
+
onHandoffFinalized: ({ id, pids }) => writeHandoff(
|
|
116
|
+
canonicalWorkspace,
|
|
117
|
+
dashboardHandoffMetadata({
|
|
118
|
+
workspace: canonicalWorkspace,
|
|
119
|
+
host,
|
|
120
|
+
port: server.port,
|
|
121
|
+
taskchefVersion,
|
|
122
|
+
serverVersion,
|
|
123
|
+
id,
|
|
124
|
+
pids,
|
|
125
|
+
secret,
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
server = await createServer({
|
|
132
|
+
workspace: canonicalWorkspace,
|
|
133
|
+
host,
|
|
134
|
+
port,
|
|
135
|
+
taskchefVersion,
|
|
136
|
+
serverVersion,
|
|
137
|
+
launcher: "session",
|
|
138
|
+
control,
|
|
139
|
+
});
|
|
140
|
+
if (closing) {
|
|
141
|
+
closePromise = null;
|
|
142
|
+
if (signal?.aborted) throw abortError();
|
|
143
|
+
throw startupClosedError();
|
|
144
|
+
}
|
|
145
|
+
lease = createLease({ initialPid: sessionPid, checkIntervalMs, exitGraceMs, onExpire: close });
|
|
146
|
+
for (const pid of sessionPids) lease.register(pid);
|
|
147
|
+
publishingOwner = true;
|
|
148
|
+
try {
|
|
149
|
+
await writeOwner(canonicalWorkspace, dashboardOwnerMetadata({
|
|
150
|
+
workspace: canonicalWorkspace,
|
|
151
|
+
host,
|
|
152
|
+
port: server.port,
|
|
153
|
+
taskchefVersion,
|
|
154
|
+
serverVersion,
|
|
155
|
+
launcher: "session",
|
|
156
|
+
secret,
|
|
157
|
+
}), { signal });
|
|
158
|
+
} finally {
|
|
159
|
+
publishingOwner = false;
|
|
160
|
+
if (deferredClose) {
|
|
161
|
+
const pending = deferredClose;
|
|
162
|
+
deferredClose = null;
|
|
163
|
+
void performClose().then(pending.resolve, pending.reject);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (closing) {
|
|
167
|
+
await closePromise;
|
|
168
|
+
if (signal?.aborted) throw abortError();
|
|
169
|
+
throw startupClosedError();
|
|
170
|
+
}
|
|
171
|
+
if (signal?.aborted) throw abortError();
|
|
172
|
+
signal?.removeEventListener("abort", onAbort);
|
|
173
|
+
return { server, lease, close };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
signal?.removeEventListener("abort", onAbort);
|
|
176
|
+
await close().catch(() => {});
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const DEFAULT_CHECK_INTERVAL_MS = 1_000;
|
|
2
|
+
const DEFAULT_EXIT_GRACE_MS = 15_000;
|
|
3
|
+
export const MAX_DASHBOARD_SESSION_PIDS = 64;
|
|
4
|
+
export const MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS = MAX_DASHBOARD_SESSION_PIDS - 1;
|
|
5
|
+
|
|
6
|
+
export function processIsAlive(pid, processObject = process) {
|
|
7
|
+
try {
|
|
8
|
+
processObject.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return error?.code === "EPERM";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validSessionPid(pid) {
|
|
16
|
+
return Number.isSafeInteger(pid) && pid > 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDashboardSessionLease({
|
|
20
|
+
initialPid,
|
|
21
|
+
checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS,
|
|
22
|
+
exitGraceMs = DEFAULT_EXIT_GRACE_MS,
|
|
23
|
+
isAlive = processIsAlive,
|
|
24
|
+
now = Date.now,
|
|
25
|
+
onExpire,
|
|
26
|
+
} = {}) {
|
|
27
|
+
if (!validSessionPid(initialPid)) throw new Error("dashboard session PID is invalid");
|
|
28
|
+
if (!Number.isFinite(checkIntervalMs) || checkIntervalMs <= 0) {
|
|
29
|
+
throw new Error("dashboard session check interval must be positive");
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isFinite(exitGraceMs) || exitGraceMs < 0) {
|
|
32
|
+
throw new Error("dashboard session exit grace must be non-negative");
|
|
33
|
+
}
|
|
34
|
+
if (typeof onExpire !== "function") throw new Error("dashboard session expiry callback is required");
|
|
35
|
+
|
|
36
|
+
const pids = new Set([initialPid]);
|
|
37
|
+
let absentSince = null;
|
|
38
|
+
let expiryPromise = null;
|
|
39
|
+
let timer = null;
|
|
40
|
+
|
|
41
|
+
const activePids = () => {
|
|
42
|
+
let active = false;
|
|
43
|
+
for (const pid of pids) {
|
|
44
|
+
if (isAlive(pid)) active = true;
|
|
45
|
+
else pids.delete(pid);
|
|
46
|
+
}
|
|
47
|
+
return active;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const tick = () => {
|
|
51
|
+
if (expiryPromise) return;
|
|
52
|
+
const active = activePids();
|
|
53
|
+
if (active) {
|
|
54
|
+
absentSince = null;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
absentSince ??= now();
|
|
58
|
+
if (now() - absentSince < exitGraceMs) return;
|
|
59
|
+
expiryPromise = Promise.resolve().then(onExpire).catch(() => {});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
timer = setInterval(tick, checkIntervalMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
register(pid) {
|
|
67
|
+
if (!validSessionPid(pid)) throw new Error("dashboard session PID is invalid");
|
|
68
|
+
if (expiryPromise) {
|
|
69
|
+
throw Object.assign(new Error("dashboard session is expiring"), {
|
|
70
|
+
code: "TASKCHEF_DASHBOARD_SESSION_RETIRING",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
activePids();
|
|
74
|
+
if (!pids.has(pid) && pids.size >= MAX_DASHBOARD_SESSION_PIDS) {
|
|
75
|
+
throw new Error("dashboard session PID limit reached");
|
|
76
|
+
}
|
|
77
|
+
pids.add(pid);
|
|
78
|
+
absentSince = null;
|
|
79
|
+
},
|
|
80
|
+
snapshot() {
|
|
81
|
+
activePids();
|
|
82
|
+
return [...pids];
|
|
83
|
+
},
|
|
84
|
+
tick,
|
|
85
|
+
close() {
|
|
86
|
+
if (timer) clearInterval(timer);
|
|
87
|
+
timer = null;
|
|
88
|
+
},
|
|
89
|
+
get sessionCount() { return pids.size; },
|
|
90
|
+
};
|
|
91
|
+
}
|