taskchef 8.3.0 → 8.3.1
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 +45 -49
- package/docs/dashboard-lifecycle.md +108 -194
- package/docs/spec.md +62 -88
- package/docs/workflows.md +29 -22
- package/package.json +1 -1
- package/src/dashboard-control.js +34 -0
- package/src/dashboard-manager.js +183 -842
- package/src/dashboard-session-process.js +35 -126
- package/src/dashboard-session.js +0 -5
- package/src/dashboard.js +28 -281
- package/src/mcp.js +3 -3
- package/src/dashboard-ownership.js +0 -251
package/src/dashboard-manager.js
CHANGED
|
@@ -4,67 +4,38 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
dashboardAuthority,
|
|
11
|
-
} from "./dashboard.js";
|
|
12
|
-
import {
|
|
13
|
-
DASHBOARD_CONTROL_CHALLENGE_PATH,
|
|
14
|
-
DASHBOARD_CONTROL_HANDOFF_COMMIT_PATH,
|
|
15
|
-
DASHBOARD_CONTROL_HANDOFF_PATH,
|
|
16
|
-
DASHBOARD_CONTROL_SESSION_PATH,
|
|
17
|
-
DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
18
|
-
createDashboardControlNonce,
|
|
19
|
-
createDashboardControlSecret,
|
|
20
|
-
dashboardControlProof,
|
|
21
|
-
readDashboardHandoff,
|
|
22
|
-
readDashboardOwner,
|
|
23
|
-
validDashboardControlNonce,
|
|
24
|
-
verifyDashboardControlProof,
|
|
25
|
-
} from "./dashboard-ownership.js";
|
|
7
|
+
import { DASHBOARD_HEALTH_MAX_BYTES, DASHBOARD_HEALTH_PATH, dashboardAuthority } from "./dashboard.js";
|
|
8
|
+
import { DASHBOARD_CONTROL_HEADER, DASHBOARD_CONTROL_SESSION_PATH,
|
|
9
|
+
DASHBOARD_CONTROL_SHUTDOWN_PATH, exactDashboardIdentity } from "./dashboard-control.js";
|
|
26
10
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
27
|
-
import {
|
|
28
|
-
MAX_DASHBOARD_SESSION_PIDS,
|
|
29
|
-
MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
|
|
30
|
-
validSessionPid,
|
|
31
|
-
} from "./dashboard-session.js";
|
|
32
11
|
|
|
33
12
|
const DEFAULT_HOST = "127.0.0.1";
|
|
34
13
|
const DEFAULT_PORT = 3210;
|
|
35
14
|
const HEALTH_TIMEOUT_MS = 750;
|
|
36
15
|
const SESSION_START_ATTEMPTS = 80;
|
|
37
16
|
const SESSION_START_INTERVAL_MS = 25;
|
|
38
|
-
const HANDOFF_CONVERGENCE_ATTEMPTS = SESSION_START_ATTEMPTS * 3;
|
|
39
17
|
const SESSION_READY_TIMEOUT_MS = 15_000;
|
|
40
|
-
const
|
|
18
|
+
const SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
41
19
|
const MAX_VERSION_REPLACEMENT_ATTEMPTS = 8;
|
|
42
20
|
const SESSION_PROCESS_PATH = fileURLToPath(new URL("../mcp/dashboard-session.js", import.meta.url));
|
|
43
21
|
|
|
44
|
-
export function launchDashboardSession({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
port,
|
|
48
|
-
secret,
|
|
49
|
-
sessionPid,
|
|
50
|
-
sessionPids = [],
|
|
51
|
-
processPath = process.execPath,
|
|
52
|
-
spawnProcess = spawn,
|
|
53
|
-
readyTimeoutMs = SESSION_READY_TIMEOUT_MS,
|
|
54
|
-
} = {}) {
|
|
22
|
+
export function launchDashboardSession({ workspace, host, port, sessionPid,
|
|
23
|
+
processPath = process.execPath, spawnProcess = spawn,
|
|
24
|
+
readyTimeoutMs = SESSION_READY_TIMEOUT_MS } = {}) {
|
|
55
25
|
return new Promise((resolve, reject) => {
|
|
26
|
+
const childEnvironment = {
|
|
27
|
+
...process.env,
|
|
28
|
+
TASKCHEF_DASHBOARD_WORKSPACE: workspace,
|
|
29
|
+
TASKCHEF_DASHBOARD_HOST: host,
|
|
30
|
+
TASKCHEF_DASHBOARD_PORT: String(port),
|
|
31
|
+
TASKCHEF_DASHBOARD_SESSION_PID: String(sessionPid),
|
|
32
|
+
};
|
|
33
|
+
delete childEnvironment.TASKCHEF_DASHBOARD_SECRET;
|
|
34
|
+
delete childEnvironment.TASKCHEF_DASHBOARD_SESSION_PIDS;
|
|
56
35
|
const child = spawnProcess(processPath, [SESSION_PROCESS_PATH], {
|
|
57
36
|
detached: true,
|
|
58
37
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
59
|
-
env:
|
|
60
|
-
...process.env,
|
|
61
|
-
TASKCHEF_DASHBOARD_WORKSPACE: workspace,
|
|
62
|
-
TASKCHEF_DASHBOARD_HOST: host,
|
|
63
|
-
TASKCHEF_DASHBOARD_PORT: String(port),
|
|
64
|
-
TASKCHEF_DASHBOARD_SECRET: secret,
|
|
65
|
-
TASKCHEF_DASHBOARD_SESSION_PID: String(sessionPid),
|
|
66
|
-
TASKCHEF_DASHBOARD_SESSION_PIDS: JSON.stringify(sessionPids),
|
|
67
|
-
},
|
|
38
|
+
env: childEnvironment,
|
|
68
39
|
});
|
|
69
40
|
let settled = false;
|
|
70
41
|
const finish = (callback, value, { disconnect = true } = {}) => {
|
|
@@ -90,9 +61,7 @@ export function launchDashboardSession({
|
|
|
90
61
|
finish(resolve, { pid: child.pid, port: message.port });
|
|
91
62
|
} else if (message?.type === "error") {
|
|
92
63
|
const error = new Error("TaskChef dashboard session failed before becoming ready");
|
|
93
|
-
error.code = typeof message.code === "string"
|
|
94
|
-
? message.code
|
|
95
|
-
: "TASKCHEF_DASHBOARD_START_FAILED";
|
|
64
|
+
error.code = typeof message.code === "string" ? message.code : "TASKCHEF_DASHBOARD_START_FAILED";
|
|
96
65
|
finish(reject, error);
|
|
97
66
|
}
|
|
98
67
|
};
|
|
@@ -101,13 +70,8 @@ export function launchDashboardSession({
|
|
|
101
70
|
error.code = "TASKCHEF_DASHBOARD_START_TIMEOUT";
|
|
102
71
|
finish(reject, error, { disconnect: false });
|
|
103
72
|
if (child.connected) {
|
|
104
|
-
try {
|
|
105
|
-
|
|
106
|
-
if (child.connected) child.disconnect();
|
|
107
|
-
});
|
|
108
|
-
} catch {
|
|
109
|
-
if (child.connected) child.disconnect();
|
|
110
|
-
}
|
|
73
|
+
try { child.send({ type: "cancel" }, () => { if (child.connected) child.disconnect(); }); }
|
|
74
|
+
catch { if (child.connected) child.disconnect(); }
|
|
111
75
|
}
|
|
112
76
|
}, readyTimeoutMs);
|
|
113
77
|
child.once("error", onError);
|
|
@@ -117,38 +81,23 @@ export function launchDashboardSession({
|
|
|
117
81
|
}
|
|
118
82
|
|
|
119
83
|
function expectedIdentity(workspace, taskchefVersion, serverVersion, launcher) {
|
|
120
|
-
return {
|
|
121
|
-
|
|
122
|
-
service: "taskchef-dashboard",
|
|
123
|
-
taskchefVersion,
|
|
124
|
-
serverVersion,
|
|
125
|
-
workspace,
|
|
126
|
-
launcher,
|
|
127
|
-
};
|
|
84
|
+
return { schemaVersion: 1, service: "taskchef-dashboard", taskchefVersion,
|
|
85
|
+
serverVersion, workspace, launcher };
|
|
128
86
|
}
|
|
129
87
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
&& expectedKeys.every((key) => value[key] === expected[key]);
|
|
88
|
+
function isRecognizedDashboardIdentity(value, workspace) {
|
|
89
|
+
return value?.schemaVersion === 1 && value?.service === "taskchef-dashboard"
|
|
90
|
+
&& typeof value?.taskchefVersion === "string" && typeof value?.serverVersion === "string"
|
|
91
|
+
&& value?.workspace === workspace && new Set(["mcp", "session", "standalone"]).has(value?.launcher)
|
|
92
|
+
&& exactDashboardIdentity(value, expectedIdentity(workspace, value.taskchefVersion,
|
|
93
|
+
value.serverVersion, value.launcher));
|
|
137
94
|
}
|
|
138
95
|
|
|
139
|
-
function listenerConflict(url, detail, {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
retryAuthentication = false,
|
|
143
|
-
} = {}) {
|
|
144
|
-
const error = new Error(
|
|
145
|
-
`TaskChef dashboard port conflict at ${url} ${detail} `
|
|
146
|
-
+ "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.",
|
|
147
|
-
);
|
|
96
|
+
function listenerConflict(url, detail, { staleTaskchefVersion } = {}) {
|
|
97
|
+
const error = new Error(`TaskChef dashboard port conflict at ${url} ${detail} `
|
|
98
|
+
+ "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.");
|
|
148
99
|
error.code = "TASKCHEF_DASHBOARD_CONFLICT";
|
|
149
100
|
if (staleTaskchefVersion) error.staleTaskchefVersion = staleTaskchefVersion;
|
|
150
|
-
if (handoffRaceEligible) error.handoffRaceEligible = true;
|
|
151
|
-
if (retryAuthentication) error.retryAuthentication = true;
|
|
152
101
|
return error;
|
|
153
102
|
}
|
|
154
103
|
|
|
@@ -157,842 +106,234 @@ export function priorCompatibleVersion(candidate, current) {
|
|
|
157
106
|
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
|
|
158
107
|
if (!match) return null;
|
|
159
108
|
const prerelease = match[4]?.split(".") ?? null;
|
|
160
|
-
if (prerelease?.some((
|
|
161
|
-
&& identifier.length > 1 && identifier.startsWith("0"))) return null;
|
|
109
|
+
if (prerelease?.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null;
|
|
162
110
|
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
|
163
111
|
};
|
|
164
|
-
const left = parse(candidate);
|
|
165
|
-
const right = parse(current);
|
|
112
|
+
const left = parse(candidate); const right = parse(current);
|
|
166
113
|
if (!left || !right) return false;
|
|
167
114
|
for (let index = 0; index < left.core.length; index += 1) {
|
|
168
115
|
if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index];
|
|
169
116
|
}
|
|
170
|
-
if (left.prerelease === null || right.prerelease === null)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
const leftNumeric = /^\d+$/.test(leftPart);
|
|
180
|
-
const rightNumeric = /^\d+$/.test(rightPart);
|
|
181
|
-
if (leftNumeric && rightNumeric) return BigInt(leftPart) < BigInt(rightPart);
|
|
182
|
-
if (leftNumeric !== rightNumeric) return leftNumeric;
|
|
183
|
-
return leftPart < rightPart;
|
|
117
|
+
if (left.prerelease === null || right.prerelease === null) return left.prerelease !== null && right.prerelease === null;
|
|
118
|
+
for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index += 1) {
|
|
119
|
+
const l = left.prerelease[index]; const r = right.prerelease[index];
|
|
120
|
+
if (l === undefined || r === undefined) return l === undefined;
|
|
121
|
+
if (l === r) continue;
|
|
122
|
+
const ln = /^\d+$/.test(l); const rn = /^\d+$/.test(r);
|
|
123
|
+
if (ln && rn) return BigInt(l) < BigInt(r);
|
|
124
|
+
if (ln !== rn) return ln;
|
|
125
|
+
return l < r;
|
|
184
126
|
}
|
|
185
127
|
return false;
|
|
186
128
|
}
|
|
187
129
|
|
|
188
|
-
function
|
|
189
|
-
return {
|
|
190
|
-
schemaVersion: 1,
|
|
191
|
-
service: "taskchef-dashboard",
|
|
192
|
-
taskchefVersion: owner.taskchefVersion,
|
|
193
|
-
serverVersion: owner.serverVersion,
|
|
194
|
-
workspace: owner.workspace,
|
|
195
|
-
launcher: owner.launcher,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function requestDashboardJson({ host, port, path: requestPath, method = "GET", body, timeoutMs }) {
|
|
130
|
+
export function requestDashboardJson({ host, port, path: requestPath, action, body, timeoutMs }) {
|
|
200
131
|
return new Promise((resolve, reject) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}, (response) => {
|
|
216
|
-
const chunks = [];
|
|
217
|
-
let total = 0;
|
|
132
|
+
let settled = false;
|
|
133
|
+
let deadline;
|
|
134
|
+
const finish = (error, value) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(deadline);
|
|
138
|
+
if (error) reject(error); else resolve(value);
|
|
139
|
+
};
|
|
140
|
+
const encoded = Buffer.from(`${JSON.stringify(body ?? {})}\n`);
|
|
141
|
+
const authority = dashboardAuthority(host, port);
|
|
142
|
+
const request = http.request({ host, port, path: requestPath, method: "POST", headers: {
|
|
143
|
+
Accept: "application/json", Host: authority, Origin: `http://${authority}`,
|
|
144
|
+
"Content-Type": "application/json", "Content-Length": encoded.length,
|
|
145
|
+
[DASHBOARD_CONTROL_HEADER]: action,
|
|
146
|
+
} }, (response) => {
|
|
147
|
+
const chunks = []; let total = 0;
|
|
218
148
|
response.on("data", (chunk) => {
|
|
219
149
|
total += chunk.length;
|
|
220
|
-
if (total > DASHBOARD_HEALTH_MAX_BYTES)
|
|
221
|
-
new Error("dashboard control response exceeds the identity limit")
|
|
222
|
-
|
|
150
|
+
if (total > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
151
|
+
const error = new Error("dashboard control response exceeds the identity limit");
|
|
152
|
+
finish(error); request.destroy(error);
|
|
153
|
+
}
|
|
223
154
|
else chunks.push(chunk);
|
|
224
155
|
});
|
|
225
156
|
response.on("end", () => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
resolve({ statusCode: response.statusCode, value });
|
|
157
|
+
try { finish(null, { statusCode: response.statusCode,
|
|
158
|
+
value: JSON.parse(Buffer.concat(chunks, total).toString("utf8")) }); }
|
|
159
|
+
catch { finish(new Error("dashboard control returned invalid JSON")); }
|
|
232
160
|
});
|
|
161
|
+
const interrupted = () => finish(new Error("dashboard control response was interrupted"));
|
|
162
|
+
response.on("aborted", interrupted);
|
|
163
|
+
response.on("error", interrupted);
|
|
164
|
+
response.on("close", () => { if (!response.complete) interrupted(); });
|
|
233
165
|
});
|
|
234
|
-
|
|
235
|
-
new Error("dashboard control request timed out")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
request.on("error",
|
|
239
|
-
|
|
240
|
-
request.end();
|
|
166
|
+
deadline = setTimeout(() => {
|
|
167
|
+
const error = new Error("dashboard control request timed out");
|
|
168
|
+
finish(error); request.destroy(error);
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
request.on("error", (error) => finish(error));
|
|
171
|
+
request.end(encoded);
|
|
241
172
|
});
|
|
242
173
|
}
|
|
243
174
|
|
|
244
|
-
export function readDashboardIdentity({
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
|
|
248
|
-
timeoutMs = HEALTH_TIMEOUT_MS,
|
|
249
|
-
deadlineAt = Date.now() + timeoutMs,
|
|
250
|
-
} = {}) {
|
|
175
|
+
export function readDashboardIdentity({ host = DEFAULT_HOST, port = DEFAULT_PORT,
|
|
176
|
+
maximumBytes = DASHBOARD_HEALTH_MAX_BYTES, timeoutMs = HEALTH_TIMEOUT_MS,
|
|
177
|
+
deadlineAt = Date.now() + timeoutMs } = {}) {
|
|
251
178
|
return new Promise((resolve, reject) => {
|
|
252
179
|
let settled = false;
|
|
253
|
-
const finish = (error, value) => {
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
host,
|
|
262
|
-
port,
|
|
263
|
-
path: DASHBOARD_HEALTH_PATH,
|
|
264
|
-
headers: {
|
|
265
|
-
Accept: "application/json",
|
|
266
|
-
Host: dashboardAuthority(host, port),
|
|
267
|
-
},
|
|
268
|
-
}, (response) => {
|
|
269
|
-
const chunks = [];
|
|
270
|
-
let total = 0;
|
|
271
|
-
response.on("data", (chunk) => {
|
|
272
|
-
total += chunk.length;
|
|
273
|
-
if (total > maximumBytes) {
|
|
274
|
-
const error = new Error("dashboard health response exceeds the identity limit");
|
|
275
|
-
finish(error);
|
|
276
|
-
request.destroy(error);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
chunks.push(chunk);
|
|
280
|
-
});
|
|
180
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(deadline);
|
|
181
|
+
if (error) reject(error); else resolve(value); };
|
|
182
|
+
const request = http.get({ host, port, path: DASHBOARD_HEALTH_PATH,
|
|
183
|
+
headers: { Accept: "application/json", Host: dashboardAuthority(host, port) } }, (response) => {
|
|
184
|
+
const chunks = []; let total = 0;
|
|
185
|
+
response.on("data", (chunk) => { total += chunk.length;
|
|
186
|
+
if (total > maximumBytes) { const error = new Error("dashboard health response exceeds the identity limit");
|
|
187
|
+
finish(error); request.destroy(error); } else chunks.push(chunk); });
|
|
281
188
|
response.on("end", () => {
|
|
282
189
|
if (response.statusCode === 503 && deadlineAt - Date.now() > 10) {
|
|
283
|
-
settled = true;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
setTimeout(() => {
|
|
287
|
-
readDashboardIdentity({
|
|
288
|
-
host,
|
|
289
|
-
port,
|
|
290
|
-
maximumBytes,
|
|
291
|
-
timeoutMs: Math.max(1, remaining),
|
|
292
|
-
deadlineAt,
|
|
293
|
-
}).then(resolve, reject);
|
|
294
|
-
}, 10);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (response.statusCode !== 200) {
|
|
298
|
-
finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
try {
|
|
302
|
-
finish(null, JSON.parse(Buffer.concat(chunks, total).toString("utf8")));
|
|
303
|
-
} catch {
|
|
304
|
-
finish(new Error("dashboard health returned invalid JSON"));
|
|
190
|
+
settled = true; clearTimeout(deadline); const remaining = deadlineAt - Date.now();
|
|
191
|
+
setTimeout(() => readDashboardIdentity({ host, port, maximumBytes,
|
|
192
|
+
timeoutMs: Math.max(1, remaining), deadlineAt }).then(resolve, reject), 10); return;
|
|
305
193
|
}
|
|
194
|
+
if (response.statusCode !== 200) { finish(new Error(`dashboard health returned HTTP ${response.statusCode}`)); return; }
|
|
195
|
+
try { finish(null, JSON.parse(Buffer.concat(chunks, total).toString("utf8"))); }
|
|
196
|
+
catch { finish(new Error("dashboard health returned invalid JSON")); }
|
|
306
197
|
});
|
|
307
198
|
});
|
|
308
|
-
const deadline = setTimeout(() => {
|
|
309
|
-
|
|
310
|
-
finish(error);
|
|
311
|
-
request.destroy(error);
|
|
312
|
-
}, timeoutMs);
|
|
199
|
+
const deadline = setTimeout(() => { const error = new Error("dashboard health request timed out");
|
|
200
|
+
finish(error); request.destroy(error); }, timeoutMs);
|
|
313
201
|
request.on("error", (error) => finish(error));
|
|
314
202
|
});
|
|
315
203
|
}
|
|
316
204
|
|
|
317
|
-
function listenerAbsent(error) {
|
|
318
|
-
return error?.code === "ECONNREFUSED" || error?.code === "EHOSTUNREACH";
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function createDashboardManager({
|
|
322
|
-
workspace,
|
|
323
|
-
host = DEFAULT_HOST,
|
|
324
|
-
port = DEFAULT_PORT,
|
|
325
|
-
taskchefVersion = TASKCHEF_VERSION,
|
|
326
|
-
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
327
|
-
launcher = "session",
|
|
328
|
-
sessionPid = process.ppid,
|
|
329
|
-
launchSession = launchDashboardSession,
|
|
330
|
-
readIdentity = readDashboardIdentity,
|
|
331
|
-
readOwner = readDashboardOwner,
|
|
332
|
-
readHandoff = readDashboardHandoff,
|
|
333
|
-
requestJson = requestDashboardJson,
|
|
334
|
-
ownerAuthTimeoutMs = SESSION_READY_TIMEOUT_MS,
|
|
335
|
-
handoffConvergenceAttempts = HANDOFF_CONVERGENCE_ATTEMPTS,
|
|
336
|
-
} = {}) {
|
|
337
|
-
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
338
|
-
throw new Error("dashboard manager port must be an integer from 1 to 65535");
|
|
339
|
-
}
|
|
340
|
-
if (!Number.isFinite(ownerAuthTimeoutMs) || ownerAuthTimeoutMs <= 0) {
|
|
341
|
-
throw new Error("dashboard owner authentication timeout must be positive");
|
|
342
|
-
}
|
|
343
|
-
let canonicalWorkspace;
|
|
344
|
-
let ensurePromise = null;
|
|
345
|
-
let closePromise = null;
|
|
346
|
-
let handoffSessionPids = [];
|
|
347
|
-
|
|
348
|
-
const settleBeforeDeadline = (promise, deadline) => {
|
|
349
|
-
const operation = Promise.resolve(promise);
|
|
350
|
-
const remaining = deadline - Date.now();
|
|
351
|
-
if (remaining <= 0) {
|
|
352
|
-
operation.catch(() => {});
|
|
353
|
-
return Promise.reject(Object.assign(new Error("dashboard ownership deadline expired"), {
|
|
354
|
-
code: "TASKCHEF_DASHBOARD_OWNER_TIMEOUT",
|
|
355
|
-
}));
|
|
356
|
-
}
|
|
357
|
-
return new Promise((resolve, reject) => {
|
|
358
|
-
const timer = setTimeout(() => reject(Object.assign(
|
|
359
|
-
new Error("dashboard ownership operation timed out"),
|
|
360
|
-
{ code: "TASKCHEF_DASHBOARD_OWNER_TIMEOUT" },
|
|
361
|
-
)), remaining);
|
|
362
|
-
operation.then(
|
|
363
|
-
(value) => { clearTimeout(timer); resolve(value); },
|
|
364
|
-
(error) => { clearTimeout(timer); reject(error); },
|
|
365
|
-
);
|
|
366
|
-
});
|
|
367
|
-
};
|
|
205
|
+
function listenerAbsent(error) { return error?.code === "ECONNREFUSED" || error?.code === "EHOSTUNREACH"; }
|
|
368
206
|
|
|
369
|
-
|
|
207
|
+
export function createDashboardManager({ workspace, host = DEFAULT_HOST, port = DEFAULT_PORT,
|
|
208
|
+
taskchefVersion = TASKCHEF_VERSION, serverVersion = DASHBOARD_SERVER_VERSION,
|
|
209
|
+
launcher = "session", sessionPid = process.ppid, launchSession = launchDashboardSession,
|
|
210
|
+
readIdentity = readDashboardIdentity, requestJson = requestDashboardJson,
|
|
211
|
+
shutdownTimeoutMs = SHUTDOWN_TIMEOUT_MS } = {}) {
|
|
212
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("dashboard manager port must be an integer from 1 to 65535");
|
|
213
|
+
if (!Number.isFinite(shutdownTimeoutMs) || shutdownTimeoutMs <= 0) throw new Error("dashboard shutdown timeout must be positive");
|
|
214
|
+
let canonicalWorkspace; let ensurePromise = null; let closePromise = null;
|
|
215
|
+
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
216
|
+
const expected = () => expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
|
|
217
|
+
const publicResult = (action, observedLauncher = launcher) => ({
|
|
370
218
|
action,
|
|
371
|
-
launcher,
|
|
372
|
-
url
|
|
219
|
+
launcher: observedLauncher,
|
|
220
|
+
url,
|
|
373
221
|
workspace: canonicalWorkspace,
|
|
374
222
|
taskchefVersion,
|
|
375
223
|
serverVersion,
|
|
376
224
|
});
|
|
377
225
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
&& isExactIdentity(identity, publicOwnerIdentity(candidate));
|
|
386
|
-
const currentIdentity = expectedIdentity(
|
|
387
|
-
canonicalWorkspace, taskchefVersion, serverVersion, launcher,
|
|
388
|
-
);
|
|
389
|
-
const recognizedStartingSession = identity?.schemaVersion === 1
|
|
390
|
-
&& identity?.service === "taskchef-dashboard"
|
|
391
|
-
&& identity?.workspace === canonicalWorkspace
|
|
392
|
-
&& identity?.launcher === "session"
|
|
393
|
-
&& (isExactIdentity(identity, currentIdentity)
|
|
394
|
-
|| priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion));
|
|
395
|
-
const deadline = Date.now() + (recognizedStartingSession
|
|
396
|
-
? ownerAuthTimeoutMs
|
|
397
|
-
: Math.min(ownerAuthTimeoutMs, OWNER_AUTH_DEFAULT_TIMEOUT_MS));
|
|
398
|
-
do {
|
|
399
|
-
try {
|
|
400
|
-
const owner = await settleBeforeDeadline(readOwner(canonicalWorkspace), deadline);
|
|
401
|
-
observedOwner = owner;
|
|
402
|
-
if (ownerMatches(owner)) {
|
|
403
|
-
matchingOwner = owner;
|
|
404
|
-
const challengeNonce = createDashboardControlNonce();
|
|
405
|
-
lastChallenge = await requestJson({
|
|
406
|
-
host,
|
|
407
|
-
port,
|
|
408
|
-
path: `${DASHBOARD_CONTROL_CHALLENGE_PATH}?nonce=${challengeNonce}`,
|
|
409
|
-
timeoutMs: Math.max(1, Math.min(HEALTH_TIMEOUT_MS, deadline - Date.now())),
|
|
410
|
-
}).catch((error) => {
|
|
411
|
-
if (Date.now() >= deadline || /timed out/i.test(error?.message ?? "")) {
|
|
412
|
-
ownershipDeadlineExpired = true;
|
|
413
|
-
} else {
|
|
414
|
-
challengeUnavailable = true;
|
|
415
|
-
}
|
|
416
|
-
return null;
|
|
417
|
-
});
|
|
418
|
-
if (lastChallenge?.statusCode === 200
|
|
419
|
-
&& lastChallenge.value?.schemaVersion === 1
|
|
420
|
-
&& lastChallenge.value?.nonce === challengeNonce
|
|
421
|
-
&& verifyDashboardControlProof(
|
|
422
|
-
owner.secret,
|
|
423
|
-
"challenge",
|
|
424
|
-
challengeNonce,
|
|
425
|
-
lastChallenge.value?.proof,
|
|
426
|
-
)) {
|
|
427
|
-
return owner;
|
|
428
|
-
}
|
|
429
|
-
if (challengeUnavailable) break;
|
|
430
|
-
} else if (owner.host === host && owner.port === port
|
|
431
|
-
&& priorCompatibleVersion(taskchefVersion, owner.taskchefVersion)) {
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
} catch (error) {
|
|
435
|
-
if (error?.code === "TASKCHEF_DASHBOARD_OWNER_TIMEOUT") {
|
|
436
|
-
ownershipDeadlineExpired = true;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const remaining = deadline - Date.now();
|
|
440
|
-
if (remaining <= 0) break;
|
|
441
|
-
await new Promise((resolve) => setTimeout(
|
|
442
|
-
resolve,
|
|
443
|
-
Math.min(SESSION_START_INTERVAL_MS, remaining),
|
|
444
|
-
));
|
|
445
|
-
} while (Date.now() < deadline);
|
|
446
|
-
if (!matchingOwner) {
|
|
447
|
-
const priorVersion = identity.taskchefVersion !== taskchefVersion;
|
|
448
|
-
if (observedOwner) {
|
|
449
|
-
throw listenerConflict(
|
|
450
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
451
|
-
"has ownership metadata that does not exactly match the listener identity.",
|
|
452
|
-
{ staleTaskchefVersion: priorVersion ? identity.taskchefVersion : undefined },
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
throw listenerConflict(
|
|
456
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
457
|
-
priorVersion
|
|
458
|
-
? `is a verified older TaskChef ${identity.taskchefVersion} listener without usable authenticated handoff metadata.`
|
|
459
|
-
: `is a verified TaskChef ${identity.taskchefVersion} session listener without usable authenticated ownership metadata.`,
|
|
460
|
-
{
|
|
461
|
-
staleTaskchefVersion: priorVersion ? identity.taskchefVersion : undefined,
|
|
462
|
-
},
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
throw listenerConflict(
|
|
466
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
467
|
-
"did not prove control of its private TaskChef ownership credential.",
|
|
468
|
-
{
|
|
469
|
-
staleTaskchefVersion: identity.taskchefVersion !== taskchefVersion
|
|
470
|
-
? identity.taskchefVersion
|
|
471
|
-
: undefined,
|
|
472
|
-
handoffRaceEligible: !ownershipDeadlineExpired && lastChallenge === null,
|
|
473
|
-
},
|
|
474
|
-
);
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
const recoverFinalHandoff = async (
|
|
478
|
-
owner,
|
|
479
|
-
expectedId,
|
|
480
|
-
deadline = Date.now() + ownerAuthTimeoutMs,
|
|
481
|
-
) => {
|
|
482
|
-
let handoff;
|
|
483
|
-
try {
|
|
484
|
-
handoff = await settleBeforeDeadline(readHandoff(canonicalWorkspace), deadline);
|
|
485
|
-
} catch {
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
const pids = handoff?.pids;
|
|
489
|
-
if (handoff?.workspace !== canonicalWorkspace || handoff?.host !== host
|
|
490
|
-
|| handoff?.port !== port || handoff?.launcher !== "session"
|
|
491
|
-
|| handoff?.taskchefVersion !== owner.taskchefVersion
|
|
492
|
-
|| handoff?.serverVersion !== owner.serverVersion
|
|
493
|
-
|| (expectedId !== undefined && handoff?.id !== expectedId)
|
|
494
|
-
|| !Array.isArray(pids) || pids.length > MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS
|
|
495
|
-
|| pids.some((pid) => !validSessionPid(pid))
|
|
496
|
-
|| new Set(pids).size !== pids.length
|
|
497
|
-
|| !verifyDashboardControlProof(
|
|
498
|
-
owner.secret,
|
|
499
|
-
`handoff-final:${JSON.stringify(pids)}`,
|
|
500
|
-
handoff?.id,
|
|
501
|
-
handoff?.proof,
|
|
502
|
-
)) return null;
|
|
503
|
-
return pids;
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
const registerSession = async (identity) => {
|
|
507
|
-
const owner = await authenticateOwner(identity);
|
|
508
|
-
const nonce = createDashboardControlNonce();
|
|
509
|
-
const registration = await requestJson({
|
|
510
|
-
host,
|
|
511
|
-
port,
|
|
512
|
-
path: DASHBOARD_CONTROL_SESSION_PATH,
|
|
513
|
-
method: "POST",
|
|
514
|
-
body: {
|
|
515
|
-
pid: sessionPid,
|
|
516
|
-
nonce,
|
|
517
|
-
proof: dashboardControlProof(owner.secret, `session:${sessionPid}`, nonce),
|
|
518
|
-
},
|
|
519
|
-
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
520
|
-
}).catch(() => null);
|
|
521
|
-
if (registration?.statusCode !== 200 || registration.value?.accepted !== true
|
|
522
|
-
|| registration.value?.nonce !== nonce
|
|
523
|
-
|| !verifyDashboardControlProof(
|
|
524
|
-
owner.secret,
|
|
525
|
-
`session-accepted:${sessionPid}`,
|
|
526
|
-
nonce,
|
|
527
|
-
registration.value?.proof,
|
|
528
|
-
)) {
|
|
529
|
-
throw listenerConflict(
|
|
530
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
531
|
-
"refused authenticated Codex-session registration.",
|
|
532
|
-
{
|
|
533
|
-
handoffRaceEligible: registration === null
|
|
534
|
-
|| registration?.value?.reason === "retiring",
|
|
535
|
-
},
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
return owner;
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
const retirePriorOwner = async (identity) => {
|
|
542
|
-
const owner = await authenticateOwner(identity);
|
|
543
|
-
let sessionPids = [];
|
|
544
|
-
if (identity.launcher === "session") {
|
|
545
|
-
let handoff = null;
|
|
546
|
-
let handoffNonce;
|
|
547
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
548
|
-
handoffNonce = createDashboardControlNonce();
|
|
549
|
-
handoff = await requestJson({
|
|
550
|
-
host,
|
|
551
|
-
port,
|
|
552
|
-
path: DASHBOARD_CONTROL_HANDOFF_PATH,
|
|
553
|
-
method: "POST",
|
|
554
|
-
body: {
|
|
555
|
-
pid: sessionPid,
|
|
556
|
-
nonce: handoffNonce,
|
|
557
|
-
proof: dashboardControlProof(owner.secret, `handoff:${sessionPid}`, handoffNonce),
|
|
558
|
-
},
|
|
559
|
-
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
560
|
-
}).catch(() => null);
|
|
561
|
-
if (handoff !== null) break;
|
|
562
|
-
}
|
|
563
|
-
const pids = handoff?.value?.pids;
|
|
564
|
-
const handoffId = handoff?.value?.id;
|
|
565
|
-
if (handoff?.statusCode !== 200 || handoff.value?.accepted !== true
|
|
566
|
-
|| !validDashboardControlNonce(handoffId)
|
|
567
|
-
|| !Array.isArray(pids)
|
|
568
|
-
|| pids.length > MAX_DASHBOARD_SESSION_PIDS
|
|
569
|
-
|| pids.some((pid) => !validSessionPid(pid))
|
|
570
|
-
|| new Set(pids).size !== pids.length
|
|
571
|
-
|| !pids.includes(sessionPid)
|
|
572
|
-
|| handoff.value?.nonce !== handoffNonce
|
|
573
|
-
|| !verifyDashboardControlProof(
|
|
574
|
-
owner.secret,
|
|
575
|
-
`handoff-prepared:${sessionPid}:${handoffId}:${JSON.stringify(pids)}`,
|
|
576
|
-
handoffNonce,
|
|
577
|
-
handoff.value?.proof,
|
|
578
|
-
)) {
|
|
579
|
-
throw listenerConflict(
|
|
580
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
581
|
-
"refused authenticated TaskChef session handoff.",
|
|
582
|
-
{
|
|
583
|
-
staleTaskchefVersion: identity.taskchefVersion,
|
|
584
|
-
handoffRaceEligible: handoff === null || handoff?.value?.reason === "retiring",
|
|
585
|
-
},
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
sessionPids = pids;
|
|
589
|
-
let committed = false;
|
|
590
|
-
let commitResponseLost = false;
|
|
591
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
592
|
-
const commitNonce = createDashboardControlNonce();
|
|
593
|
-
const commit = await requestJson({
|
|
594
|
-
host,
|
|
595
|
-
port,
|
|
596
|
-
path: DASHBOARD_CONTROL_HANDOFF_COMMIT_PATH,
|
|
597
|
-
method: "POST",
|
|
598
|
-
body: {
|
|
599
|
-
id: handoffId,
|
|
600
|
-
nonce: commitNonce,
|
|
601
|
-
proof: dashboardControlProof(owner.secret, `handoff-commit:${handoffId}`, commitNonce),
|
|
602
|
-
},
|
|
603
|
-
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
604
|
-
}).catch(() => null);
|
|
605
|
-
const committedPids = commit?.value?.pids;
|
|
606
|
-
if (commit?.statusCode === 202 && commit.value?.accepted === true
|
|
607
|
-
&& commit.value?.id === handoffId && commit.value?.nonce === commitNonce
|
|
608
|
-
&& Array.isArray(committedPids)
|
|
609
|
-
&& committedPids.length <= MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS
|
|
610
|
-
&& committedPids.every((pid) => validSessionPid(pid))
|
|
611
|
-
&& new Set(committedPids).size === committedPids.length
|
|
612
|
-
&& committedPids.includes(sessionPid)
|
|
613
|
-
&& verifyDashboardControlProof(
|
|
614
|
-
owner.secret,
|
|
615
|
-
`handoff-committed:${handoffId}:${JSON.stringify(committedPids)}`,
|
|
616
|
-
commitNonce,
|
|
617
|
-
commit.value?.proof,
|
|
618
|
-
)) {
|
|
619
|
-
sessionPids = committedPids;
|
|
620
|
-
committed = true;
|
|
621
|
-
break;
|
|
622
|
-
}
|
|
623
|
-
if (commit === null) {
|
|
624
|
-
commitResponseLost = true;
|
|
625
|
-
try {
|
|
626
|
-
await readIdentity({ host, port, timeoutMs: 100 });
|
|
627
|
-
} catch (error) {
|
|
628
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
629
|
-
const recovered = await recoverFinalHandoff(owner, handoffId);
|
|
630
|
-
if (recovered) return recovered;
|
|
631
|
-
throw listenerConflict(
|
|
632
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
633
|
-
"closed before its authenticated final lease snapshot could be recovered.",
|
|
634
|
-
{ staleTaskchefVersion: identity.taskchefVersion },
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
throw error;
|
|
638
|
-
}
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
break;
|
|
642
|
-
}
|
|
643
|
-
if (!committed && commitResponseLost) {
|
|
644
|
-
const recoveryDeadline = Date.now() + ownerAuthTimeoutMs;
|
|
645
|
-
let recovered = null;
|
|
646
|
-
do {
|
|
647
|
-
recovered ??= await recoverFinalHandoff(owner, handoffId, recoveryDeadline);
|
|
648
|
-
try {
|
|
649
|
-
await readIdentity({ host, port, timeoutMs: 100 });
|
|
650
|
-
} catch (error) {
|
|
651
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
652
|
-
recovered ??= await recoverFinalHandoff(owner, handoffId, recoveryDeadline);
|
|
653
|
-
if (recovered) return recovered;
|
|
654
|
-
break;
|
|
655
|
-
}
|
|
656
|
-
throw error;
|
|
657
|
-
}
|
|
658
|
-
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
659
|
-
} while (Date.now() < recoveryDeadline);
|
|
660
|
-
}
|
|
661
|
-
if (!committed) {
|
|
662
|
-
throw listenerConflict(
|
|
663
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
664
|
-
"refused authenticated TaskChef session handoff commit.",
|
|
665
|
-
{ staleTaskchefVersion: identity.taskchefVersion },
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
} else {
|
|
669
|
-
const shutdownNonce = createDashboardControlNonce();
|
|
670
|
-
const shutdown = await requestJson({
|
|
671
|
-
host,
|
|
672
|
-
port,
|
|
673
|
-
path: DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
674
|
-
method: "POST",
|
|
675
|
-
body: {
|
|
676
|
-
nonce: shutdownNonce,
|
|
677
|
-
proof: dashboardControlProof(owner.secret, "shutdown", shutdownNonce),
|
|
678
|
-
},
|
|
679
|
-
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
680
|
-
}).catch(() => null);
|
|
681
|
-
if (shutdown?.statusCode !== 202 || shutdown.value?.accepted !== true) {
|
|
682
|
-
throw listenerConflict(
|
|
683
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
684
|
-
"refused authenticated TaskChef version handoff.",
|
|
685
|
-
{
|
|
686
|
-
staleTaskchefVersion: identity.taskchefVersion,
|
|
687
|
-
handoffRaceEligible: shutdown === null || shutdown?.statusCode === 409,
|
|
688
|
-
},
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
693
|
-
try {
|
|
694
|
-
await readIdentity({ host, port, timeoutMs: 100 });
|
|
695
|
-
} catch (error) {
|
|
696
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") return sessionPids;
|
|
697
|
-
}
|
|
698
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
699
|
-
}
|
|
700
|
-
throw listenerConflict(
|
|
701
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
702
|
-
"accepted authenticated handoff but did not release the listener.",
|
|
703
|
-
{
|
|
704
|
-
staleTaskchefVersion: identity.taskchefVersion,
|
|
705
|
-
handoffRaceEligible: true,
|
|
706
|
-
retryAuthentication: true,
|
|
707
|
-
},
|
|
708
|
-
);
|
|
226
|
+
const registerAndVerify = async (identity) => {
|
|
227
|
+
const registration = await requestJson({ host, port, path: DASHBOARD_CONTROL_SESSION_PATH,
|
|
228
|
+
action: "session", body: { pid: sessionPid, expectedIdentity: identity },
|
|
229
|
+
timeoutMs: HEALTH_TIMEOUT_MS }).catch(() => null);
|
|
230
|
+
if (registration?.statusCode !== 200 || registration.value?.accepted !== true) return false;
|
|
231
|
+
const verified = await readIdentity({ host, port, timeoutMs: HEALTH_TIMEOUT_MS }).catch(() => null);
|
|
232
|
+
return exactDashboardIdentity(verified, identity);
|
|
709
233
|
};
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const ownerIsPrior = priorCompatibleVersion(owner.taskchefVersion, taskchefVersion);
|
|
723
|
-
const ownerIsNewer = priorCompatibleVersion(taskchefVersion, owner.taskchefVersion);
|
|
724
|
-
if (owner.host === host && owner.port === port && owner.launcher === "session"
|
|
725
|
-
&& ownerIsNewer) {
|
|
726
|
-
throw listenerConflict(
|
|
727
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
728
|
-
`has retained ownership from newer TaskChef ${owner.taskchefVersion}; refusing to downgrade it.`,
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
if (owner.host !== host || owner.port !== port || owner.launcher !== "session"
|
|
732
|
-
|| (!ownerIsCurrent && !ownerIsPrior)) return false;
|
|
733
|
-
let finalizedCurrentOwner = false;
|
|
734
|
-
let recoveredPriorOwner = false;
|
|
735
|
-
const recoveredAtStart = await recoverFinalHandoff(owner, undefined, deadline);
|
|
736
|
-
if (recoveredAtStart) {
|
|
737
|
-
if (ownerIsPrior) {
|
|
738
|
-
handoffSessionPids = recoveredAtStart;
|
|
739
|
-
recoveredPriorOwner = true;
|
|
234
|
+
const stopOlder = async (identity) => {
|
|
235
|
+
const shutdown = await requestJson({ host, port, path: DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
236
|
+
action: "shutdown", body: { reason: "version-replacement", expectedIdentity: identity },
|
|
237
|
+
timeoutMs: HEALTH_TIMEOUT_MS }).catch(() => null);
|
|
238
|
+
if (shutdown?.statusCode !== 202 || shutdown.value?.accepted !== true) {
|
|
239
|
+
const live = await readIdentity({ host, port, timeoutMs: HEALTH_TIMEOUT_MS }).catch((error) => {
|
|
240
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return null;
|
|
241
|
+
throw listenerConflict(url, "changed to an unexpected listener before graceful restart.");
|
|
242
|
+
});
|
|
243
|
+
if (live === null) return;
|
|
244
|
+
if (!exactDashboardIdentity(live, identity)) {
|
|
245
|
+
return probe();
|
|
740
246
|
}
|
|
741
|
-
|
|
247
|
+
throw listenerConflict(url, `is a recognized older TaskChef ${identity.taskchefVersion} listener that refused graceful shutdown.`,
|
|
248
|
+
{ staleTaskchefVersion: identity.taskchefVersion });
|
|
742
249
|
}
|
|
743
|
-
|
|
250
|
+
const deadline = Date.now() + shutdownTimeoutMs;
|
|
744
251
|
do {
|
|
745
|
-
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
746
|
-
let replacement;
|
|
747
252
|
try {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (ownerIsPrior) {
|
|
752
|
-
const recovered = await recoverFinalHandoff(owner, undefined, deadline);
|
|
753
|
-
if (recovered) {
|
|
754
|
-
handoffSessionPids = recovered;
|
|
755
|
-
recoveredPriorOwner = true;
|
|
756
|
-
return false;
|
|
757
|
-
}
|
|
758
|
-
} else if (ownerIsCurrent) {
|
|
759
|
-
finalizedCurrentOwner ||= Boolean(
|
|
760
|
-
await recoverFinalHandoff(owner, undefined, deadline),
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
continue;
|
|
764
|
-
}
|
|
765
|
-
throw error;
|
|
766
|
-
}
|
|
767
|
-
if (isExactIdentity(replacement, expected)) {
|
|
768
|
-
await registerSession(replacement);
|
|
769
|
-
return true;
|
|
770
|
-
}
|
|
771
|
-
if (isExactIdentity(replacement, priorIdentity)) {
|
|
772
|
-
if (ownerIsCurrent) {
|
|
773
|
-
await registerSession(replacement);
|
|
774
|
-
return true;
|
|
253
|
+
const live = await readIdentity({ host, port, timeoutMs: Math.min(100, shutdownTimeoutMs) });
|
|
254
|
+
if (!exactDashboardIdentity(live, identity)) {
|
|
255
|
+
return probe();
|
|
775
256
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
const replacementIsNewer = replacement?.schemaVersion === 1
|
|
781
|
-
&& replacement?.service === "taskchef-dashboard"
|
|
782
|
-
&& replacement?.workspace === canonicalWorkspace
|
|
783
|
-
&& replacement?.launcher === "session"
|
|
784
|
-
&& priorCompatibleVersion(taskchefVersion, replacement?.taskchefVersion);
|
|
785
|
-
if (replacementIsNewer) {
|
|
786
|
-
throw listenerConflict(
|
|
787
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
788
|
-
`belongs to newer TaskChef ${replacement.taskchefVersion}; refusing to downgrade it.`,
|
|
789
|
-
);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return;
|
|
259
|
+
if (error?.code === "TASKCHEF_DASHBOARD_CONFLICT") throw error;
|
|
790
260
|
}
|
|
791
|
-
|
|
792
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
793
|
-
"changed to an unexpected listener during authenticated version handoff.",
|
|
794
|
-
);
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
795
262
|
} while (Date.now() < deadline);
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
799
|
-
`TaskChef ${taskchefVersion} has already finalized retirement; refusing to relaunch it.`,
|
|
800
|
-
);
|
|
801
|
-
}
|
|
802
|
-
if (recoveredPriorOwner) {
|
|
803
|
-
throw listenerConflict(
|
|
804
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
805
|
-
"has a verified final handoff snapshot but did not release the listener.",
|
|
806
|
-
{ staleTaskchefVersion: owner.taskchefVersion },
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
return false;
|
|
263
|
+
throw listenerConflict(url, `accepted graceful shutdown but did not release the port within ${shutdownTimeoutMs}ms.`,
|
|
264
|
+
{ staleTaskchefVersion: identity.taskchefVersion });
|
|
810
265
|
};
|
|
811
|
-
|
|
812
266
|
const probe = async () => {
|
|
813
|
-
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
814
267
|
let identity;
|
|
815
|
-
try {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
819
|
-
return waitForPriorSessionReplacement();
|
|
820
|
-
}
|
|
268
|
+
try { identity = await readIdentity({ host, port }); }
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return false;
|
|
821
271
|
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
822
272
|
}
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
let lastError = registrationError;
|
|
831
|
-
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
832
|
-
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
833
|
-
let replacement;
|
|
834
|
-
try {
|
|
835
|
-
replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
836
|
-
} catch (error) {
|
|
837
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") return false;
|
|
838
|
-
throw error;
|
|
839
|
-
}
|
|
840
|
-
if (!isExactIdentity(replacement, expected)) throw registrationError;
|
|
841
|
-
try {
|
|
842
|
-
await registerSession(replacement);
|
|
843
|
-
return true;
|
|
844
|
-
} catch (error) {
|
|
845
|
-
if (!error?.handoffRaceEligible) throw error;
|
|
846
|
-
lastError = error;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
throw lastError;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
const compatiblePrior = identity?.schemaVersion === 1
|
|
853
|
-
&& identity?.service === "taskchef-dashboard"
|
|
854
|
-
&& identity?.workspace === canonicalWorkspace
|
|
855
|
-
&& new Set(["mcp", "session"]).has(identity?.launcher)
|
|
856
|
-
&& priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion);
|
|
857
|
-
if (compatiblePrior) {
|
|
858
|
-
try {
|
|
859
|
-
handoffSessionPids = await retirePriorOwner(identity);
|
|
860
|
-
return false;
|
|
861
|
-
} catch (handoffError) {
|
|
862
|
-
if (!handoffError?.handoffRaceEligible) throw handoffError;
|
|
863
|
-
let raceError = handoffError;
|
|
864
|
-
try {
|
|
865
|
-
const reused = await waitForPriorSessionReplacement();
|
|
866
|
-
if (reused || handoffSessionPids.length > 0) return reused;
|
|
867
|
-
} catch (error) {
|
|
868
|
-
if (!error?.handoffRaceEligible) throw error;
|
|
869
|
-
raceError = error;
|
|
870
|
-
}
|
|
871
|
-
let sawAbsent = false;
|
|
872
|
-
for (let attempt = 0; attempt < handoffConvergenceAttempts; attempt += 1) {
|
|
873
|
-
try {
|
|
874
|
-
const replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
875
|
-
if (isExactIdentity(replacement, expected)) {
|
|
876
|
-
await registerSession(replacement);
|
|
877
|
-
return true;
|
|
878
|
-
}
|
|
879
|
-
if (!isExactIdentity(replacement, identity)) throw raceError;
|
|
880
|
-
try {
|
|
881
|
-
handoffSessionPids = await retirePriorOwner(identity);
|
|
882
|
-
return false;
|
|
883
|
-
} catch (error) {
|
|
884
|
-
if (!error?.handoffRaceEligible) throw error;
|
|
885
|
-
raceError = error;
|
|
886
|
-
}
|
|
887
|
-
} catch (error) {
|
|
888
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
889
|
-
sawAbsent = true;
|
|
890
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
throw error;
|
|
894
|
-
}
|
|
895
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
896
|
-
}
|
|
897
|
-
if (sawAbsent) return false;
|
|
898
|
-
throw raceError;
|
|
273
|
+
const exactCurrent = isRecognizedDashboardIdentity(identity, canonicalWorkspace)
|
|
274
|
+
&& identity.taskchefVersion === taskchefVersion
|
|
275
|
+
&& identity.serverVersion === serverVersion;
|
|
276
|
+
if (exactCurrent) {
|
|
277
|
+
if (identity.launcher === "session") {
|
|
278
|
+
if (await registerAndVerify(identity)) return identity;
|
|
279
|
+
return null;
|
|
899
280
|
}
|
|
281
|
+
const verified = await readIdentity({ host, port, timeoutMs: HEALTH_TIMEOUT_MS }).catch(() => null);
|
|
282
|
+
return exactDashboardIdentity(verified, identity) ? identity : null;
|
|
900
283
|
}
|
|
901
|
-
if (
|
|
902
|
-
|
|
903
|
-
url,
|
|
904
|
-
|
|
905
|
-
);
|
|
284
|
+
if (isRecognizedDashboardIdentity(identity, canonicalWorkspace)) {
|
|
285
|
+
if (priorCompatibleVersion(taskchefVersion, identity.taskchefVersion))
|
|
286
|
+
throw listenerConflict(url, `belongs to newer TaskChef ${identity.taskchefVersion}; refusing to downgrade it.`);
|
|
287
|
+
if (priorCompatibleVersion(identity.taskchefVersion, taskchefVersion)) return stopOlder(identity);
|
|
906
288
|
}
|
|
907
|
-
|
|
289
|
+
throw listenerConflict(url, "belongs to an unknown, malformed, different-workspace, standalone, or incompatible service.");
|
|
908
290
|
};
|
|
909
|
-
|
|
910
291
|
const ensureOnce = async () => {
|
|
911
292
|
canonicalWorkspace ??= await realpath(path.resolve(workspace));
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
await launchSession({
|
|
919
|
-
workspace: canonicalWorkspace,
|
|
920
|
-
host,
|
|
921
|
-
port,
|
|
922
|
-
secret,
|
|
923
|
-
sessionPid,
|
|
924
|
-
sessionPids: handoffSessionPids,
|
|
925
|
-
taskchefVersion,
|
|
926
|
-
serverVersion,
|
|
927
|
-
});
|
|
928
|
-
} catch (error) {
|
|
929
|
-
// Concurrent activations can both observe a free port before one binds it.
|
|
930
|
-
// The loser must authenticate and reuse or upgrade the winner.
|
|
293
|
+
const existing = await probe();
|
|
294
|
+
if (existing) return publicResult("reused", existing.launcher);
|
|
295
|
+
for (let replacementAttempt = 0; replacementAttempt < MAX_VERSION_REPLACEMENT_ATTEMPTS; replacementAttempt += 1) {
|
|
296
|
+
try { await launchSession({ workspace: canonicalWorkspace, host, port, sessionPid,
|
|
297
|
+
taskchefVersion, serverVersion }); }
|
|
298
|
+
catch (error) {
|
|
931
299
|
if (error?.code !== "EADDRINUSE") throw error;
|
|
300
|
+
const winner = await probe();
|
|
301
|
+
if (winner) return publicResult("reused", winner.launcher);
|
|
302
|
+
continue;
|
|
932
303
|
}
|
|
933
|
-
let lastError = null;
|
|
934
|
-
let retryAfterLowerVersion = false;
|
|
935
304
|
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
936
305
|
try {
|
|
937
306
|
const identity = await readIdentity({ host, port, timeoutMs: 100 });
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
)
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
&& priorCompatibleVersion(
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
throw listenerConflict(
|
|
953
|
-
`http://${dashboardAuthority(host, port)}/`,
|
|
954
|
-
"changed to an unexpected listener while the dashboard session was starting.",
|
|
955
|
-
);
|
|
956
|
-
}
|
|
957
|
-
const owner = await registerSession(identity);
|
|
958
|
-
return publicResult(owner.secret === secret ? "started" : "reused");
|
|
959
|
-
} catch (error) {
|
|
960
|
-
if (!listenerAbsent(error) && error?.code !== "ECONNRESET"
|
|
961
|
-
&& error?.code !== "ENOENT" && !error?.handoffRaceEligible) throw error;
|
|
962
|
-
lastError = error;
|
|
963
|
-
}
|
|
307
|
+
if (exactDashboardIdentity(identity, expected())) {
|
|
308
|
+
if (await registerAndVerify(identity)) return publicResult("started");
|
|
309
|
+
} else if (isRecognizedDashboardIdentity(identity, canonicalWorkspace)
|
|
310
|
+
&& priorCompatibleVersion(identity.taskchefVersion, taskchefVersion)) {
|
|
311
|
+
const replacement = await stopOlder(identity);
|
|
312
|
+
if (replacement) return publicResult("reused", replacement.launcher);
|
|
313
|
+
break;
|
|
314
|
+
} else if (isRecognizedDashboardIdentity(identity, canonicalWorkspace)
|
|
315
|
+
&& priorCompatibleVersion(taskchefVersion, identity.taskchefVersion)) {
|
|
316
|
+
throw listenerConflict(url, `belongs to newer TaskChef ${identity.taskchefVersion}; refusing to downgrade it.`);
|
|
317
|
+
} else throw listenerConflict(url, "changed to an unexpected listener while the dashboard session was starting.");
|
|
318
|
+
} catch (error) { if (!listenerAbsent(error) && error?.code !== "ECONNRESET") throw error; }
|
|
964
319
|
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
965
320
|
}
|
|
966
|
-
if (retryAfterLowerVersion) continue;
|
|
967
|
-
const error = new Error(
|
|
968
|
-
`TaskChef dashboard session did not become available at http://${dashboardAuthority(host, port)}/`,
|
|
969
|
-
{ cause: lastError },
|
|
970
|
-
);
|
|
971
|
-
error.code = "TASKCHEF_DASHBOARD_START_TIMEOUT";
|
|
972
|
-
throw error;
|
|
973
321
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
"changed versions too many times during concurrent authenticated startup.",
|
|
977
|
-
);
|
|
322
|
+
const error = new Error(`TaskChef dashboard session did not become available at ${url}`);
|
|
323
|
+
error.code = "TASKCHEF_DASHBOARD_START_TIMEOUT"; throw error;
|
|
978
324
|
};
|
|
979
|
-
|
|
980
325
|
return {
|
|
981
326
|
async ensure() {
|
|
982
327
|
if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
|
|
983
328
|
if (ensurePromise) {
|
|
984
329
|
await ensurePromise;
|
|
985
|
-
|
|
330
|
+
const existing = await probe();
|
|
331
|
+
if (existing) return publicResult("reused", existing.launcher);
|
|
986
332
|
}
|
|
987
333
|
ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
|
|
988
334
|
return ensurePromise;
|
|
989
335
|
},
|
|
990
|
-
async close() {
|
|
991
|
-
closePromise ??= (async () => {
|
|
992
|
-
await ensurePromise?.catch(() => {});
|
|
993
|
-
})();
|
|
994
|
-
return closePromise;
|
|
995
|
-
},
|
|
336
|
+
async close() { closePromise ??= (async () => { await ensurePromise?.catch(() => {}); })(); return closePromise; },
|
|
996
337
|
get owned() { return false; },
|
|
997
338
|
};
|
|
998
339
|
}
|