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.
@@ -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
- && value.launcher === "mcp"
135
+ && new Set(["mcp", "session"]).has(value.launcher)
93
136
  && typeof value.secret === "string" && SECRET_PATTERN.test(value.secret);
94
137
  }
95
138
 
96
- export async function readDashboardOwner(workspace) {
97
- const filePath = ownerPath(workspace);
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 owner record is not a private regular file");
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 owner record has a different owner");
169
+ throw new Error("dashboard ownership record has a different owner");
106
170
  }
107
- if (info.size > OWNER_MAX_BYTES) throw new Error("dashboard owner record is too large");
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 owner record is too large");
111
- const value = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
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 writeDashboardOwner(workspace, value) {
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
+ }