@task-handoff/node-agent 0.0.12 → 0.0.14
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/bin/task-handoff-node-update-worker +136 -32
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/runtime-artifacts/{controlled-instance-runtime-0.0.12-linux-universal.manifest.json → controlled-instance-runtime-0.0.14-linux-universal.manifest.json} +2 -2
- package/runtime-artifacts/controlled-instance-runtime-0.0.14-linux-universal.tar.gz +0 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.14-linux-universal.tar.gz.sha256 +1 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.12-linux-universal.tar.gz +0 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.12-linux-universal.tar.gz.sha256 +0 -1
|
@@ -12,6 +12,19 @@ function parseExactVersion(value) {
|
|
|
12
12
|
return value;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function parseControlPlaneHealthUrl(value) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new InvalidArgumentError("must be a valid loopback HTTP URL");
|
|
21
|
+
}
|
|
22
|
+
if (!["http:", "https:"].includes(parsed.protocol) || !["127.0.0.1", "localhost", "::1"].includes(parsed.hostname)) {
|
|
23
|
+
throw new InvalidArgumentError("must be a valid loopback HTTP URL");
|
|
24
|
+
}
|
|
25
|
+
return parsed.toString();
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
const options = new Command()
|
|
16
29
|
.name("task-handoff-node-update-worker")
|
|
17
30
|
.description("Apply a detached TaskHandoff node update.")
|
|
@@ -19,6 +32,7 @@ const options = new Command()
|
|
|
19
32
|
.requiredOption("--target-version <version>", "exact semantic version", parseExactVersion)
|
|
20
33
|
.option("--service <name>", "systemd service to restart", "task-handoff-node-agent.service")
|
|
21
34
|
.option("--npm-command <path>", "npm executable", process.env.TASK_HANDOFF_NPM_COMMAND || "npm")
|
|
35
|
+
.option("--control-plane-health-url <url>", "local control-plane health endpoint", parseControlPlaneHealthUrl)
|
|
22
36
|
.parse(process.argv)
|
|
23
37
|
.opts();
|
|
24
38
|
|
|
@@ -26,8 +40,9 @@ const jobFile = options.jobFile;
|
|
|
26
40
|
const targetVersion = options.targetVersion;
|
|
27
41
|
const service = options.service;
|
|
28
42
|
const npmCommand = options.npmCommand;
|
|
29
|
-
const
|
|
43
|
+
const controlPlaneHealthUrl = options.controlPlaneHealthUrl;
|
|
30
44
|
const terminalStatuses = new Set(["succeeded", "degraded", "failed"]);
|
|
45
|
+
const supportedPackages = new Set(["@task-handoff/node-agent", "@task-handoff/server"]);
|
|
31
46
|
|
|
32
47
|
function updateJob(expectedStatuses, createPatch) {
|
|
33
48
|
const observed = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
@@ -58,35 +73,99 @@ function run(command, args) {
|
|
|
58
73
|
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`);
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
function
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
function installedVersion(packageName, globalRoot, nestedUnder) {
|
|
77
|
+
const manifest = nestedUnder
|
|
78
|
+
? path.join(globalRoot, ...nestedUnder.split("/"), "node_modules", ...packageName.split("/"), "package.json")
|
|
79
|
+
: path.join(globalRoot, ...packageName.split("/"), "package.json");
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === "ENOENT") return undefined;
|
|
84
|
+
throw error;
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
function
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
function verifyInstalledVersion(packageName, globalRoot) {
|
|
89
|
+
const actualVersion = installedVersion(packageName, globalRoot);
|
|
90
|
+
if (actualVersion !== targetVersion) {
|
|
91
|
+
throw new Error(`Updated ${packageName} verification failed: expected ${targetVersion}, found ${actualVersion || "unknown"}.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function verifyServerDistributionVersions(globalRoot) {
|
|
96
|
+
for (const packageName of ["@task-handoff/control-plane", "@task-handoff/node-agent", "@task-handoff/controlled-instance"]) {
|
|
97
|
+
const actualVersion = installedVersion(packageName, globalRoot, "@task-handoff/server")
|
|
98
|
+
|| installedVersion(packageName, globalRoot);
|
|
99
|
+
if (actualVersion !== targetVersion) {
|
|
100
|
+
throw new Error(`Updated @task-handoff/server does not provide ${packageName} ${targetVersion}; found ${actualVersion || "unknown"}.`);
|
|
101
|
+
}
|
|
73
102
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function npmArtifactIntegrity(packageName) {
|
|
106
|
+
const result = spawnSync(npmCommand, ["view", `${packageName}@${targetVersion}`, "dist.integrity", "--json"], { encoding: "utf8" });
|
|
107
|
+
if (result.status !== 0) throw new Error(`Could not verify the ${packageName} npm artifact integrity.`);
|
|
108
|
+
let integrity;
|
|
78
109
|
try {
|
|
79
|
-
|
|
110
|
+
integrity = JSON.parse(result.stdout);
|
|
80
111
|
} catch {
|
|
81
|
-
throw new Error(
|
|
112
|
+
throw new Error(`npm returned invalid ${packageName} artifact integrity metadata.`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof integrity !== "string" || !/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)) {
|
|
115
|
+
throw new Error(`npm returned invalid ${packageName} artifact integrity metadata.`);
|
|
82
116
|
}
|
|
117
|
+
return integrity;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function verifyNpmArtifactIntegrity() {
|
|
121
|
+
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
122
|
+
const suffix = `@${targetVersion}#`;
|
|
123
|
+
if (typeof job.artifactRef !== "string" || !job.artifactRef.startsWith("npm:") || !job.artifactRef.includes(suffix)) {
|
|
124
|
+
throw new Error(`Update job does not pin npm integrity for version ${targetVersion}.`);
|
|
125
|
+
}
|
|
126
|
+
const separator = job.artifactRef.indexOf(suffix);
|
|
127
|
+
const packageName = job.artifactRef.slice("npm:".length, separator);
|
|
128
|
+
const expectedIntegrity = job.artifactRef.slice(separator + suffix.length);
|
|
129
|
+
if (!supportedPackages.has(packageName) || !expectedIntegrity) {
|
|
130
|
+
throw new Error(`Update job does not identify a supported immutable npm artifact.`);
|
|
131
|
+
}
|
|
132
|
+
const actualIntegrity = npmArtifactIntegrity(packageName);
|
|
83
133
|
if (actualIntegrity !== expectedIntegrity) {
|
|
84
|
-
throw new Error(
|
|
134
|
+
throw new Error(`${packageName} npm artifact integrity mismatch: expected ${expectedIntegrity}, found ${String(actualIntegrity || "unknown")}.`);
|
|
85
135
|
}
|
|
136
|
+
return packageName;
|
|
86
137
|
}
|
|
87
138
|
|
|
88
|
-
|
|
139
|
+
async function waitForControlPlaneHealth(healthUrl, timeoutMs = 60_000) {
|
|
140
|
+
const deadline = Date.now() + timeoutMs;
|
|
141
|
+
let lastFailure = "not reachable";
|
|
142
|
+
while (Date.now() < deadline) {
|
|
143
|
+
try {
|
|
144
|
+
if (process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_HEALTH_FILE) {
|
|
145
|
+
const payload = JSON.parse(fs.readFileSync(process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_HEALTH_FILE, "utf8"));
|
|
146
|
+
const version = payload?.data?.version;
|
|
147
|
+
if (version === targetVersion) return;
|
|
148
|
+
lastFailure = `reported version ${String(version || "unknown")}`;
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const response = await fetch(healthUrl, {
|
|
153
|
+
headers: { "cache-control": "no-cache" },
|
|
154
|
+
signal: AbortSignal.timeout(2_000),
|
|
155
|
+
});
|
|
156
|
+
const payload = await response.json().catch(() => ({}));
|
|
157
|
+
const version = payload?.data?.version;
|
|
158
|
+
if (response.ok && version === targetVersion) return;
|
|
159
|
+
lastFailure = response.ok ? `reported version ${String(version || "unknown")}` : `returned HTTP ${response.status}`;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
lastFailure = error instanceof Error ? error.message : String(error);
|
|
162
|
+
}
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`Control plane did not become healthy at ${healthUrl} with version ${targetVersion}: ${lastFailure}.`);
|
|
166
|
+
}
|
|
89
167
|
|
|
168
|
+
async function main() {
|
|
90
169
|
try {
|
|
91
170
|
const claimed = updateJob(["queued"], (current) => ({
|
|
92
171
|
status: "updating-node",
|
|
@@ -95,33 +174,58 @@ try {
|
|
|
95
174
|
error: undefined,
|
|
96
175
|
}));
|
|
97
176
|
if (!claimed) process.exit(0);
|
|
98
|
-
verifyNpmArtifactIntegrity();
|
|
177
|
+
const packageName = verifyNpmArtifactIntegrity();
|
|
99
178
|
const prefixResult = spawnSync(npmCommand, ["prefix", "--global"], { encoding: "utf8" });
|
|
100
179
|
if (prefixResult.status !== 0) throw new Error("Could not determine the npm global prefix.");
|
|
101
180
|
const prefix = prefixResult.stdout.trim();
|
|
181
|
+
const rootResult = spawnSync(npmCommand, ["root", "--global"], { encoding: "utf8" });
|
|
182
|
+
if (rootResult.status !== 0) throw new Error("Could not determine the npm global module root.");
|
|
183
|
+
const globalRoot = rootResult.stdout.trim();
|
|
184
|
+
const standaloneNodeAgentWasInstalled = packageName === "@task-handoff/server"
|
|
185
|
+
&& installedVersion("@task-handoff/node-agent", globalRoot) !== undefined;
|
|
102
186
|
run(npmCommand, [
|
|
103
187
|
"install",
|
|
104
188
|
"--global",
|
|
105
189
|
"--prefix",
|
|
106
190
|
prefix,
|
|
107
|
-
|
|
191
|
+
`${packageName}@${targetVersion}`,
|
|
108
192
|
]);
|
|
193
|
+
verifyInstalledVersion(packageName, globalRoot);
|
|
194
|
+
if (packageName === "@task-handoff/server") {
|
|
195
|
+
if (standaloneNodeAgentWasInstalled && installedVersion("@task-handoff/node-agent", globalRoot) !== targetVersion) {
|
|
196
|
+
const expectedNodeAgentIntegrity = npmArtifactIntegrity("@task-handoff/node-agent");
|
|
197
|
+
run(npmCommand, [
|
|
198
|
+
"install",
|
|
199
|
+
"--global",
|
|
200
|
+
"--prefix",
|
|
201
|
+
prefix,
|
|
202
|
+
`@task-handoff/node-agent@${targetVersion}`,
|
|
203
|
+
]);
|
|
204
|
+
if (npmArtifactIntegrity("@task-handoff/node-agent") !== expectedNodeAgentIntegrity) {
|
|
205
|
+
throw new Error("@task-handoff/node-agent npm artifact integrity changed during installation.");
|
|
206
|
+
}
|
|
207
|
+
verifyInstalledVersion("@task-handoff/node-agent", globalRoot);
|
|
208
|
+
}
|
|
209
|
+
verifyServerDistributionVersions(globalRoot);
|
|
210
|
+
}
|
|
211
|
+
if (packageName === "@task-handoff/server") {
|
|
212
|
+
if (!controlPlaneHealthUrl) throw new Error("A control-plane health URL is required for a complete server update.");
|
|
213
|
+
run("systemctl", ["restart", "task-handoff-control-plane.service"]);
|
|
214
|
+
await waitForControlPlaneHealth(controlPlaneHealthUrl);
|
|
215
|
+
}
|
|
109
216
|
const handedOff = updateJob(["updating-node"], (current) => ({ status: "restarting-node", rollout: { ...current.rollout, phase: "restarting-node" } }));
|
|
110
217
|
if (!handedOff) process.exit(0);
|
|
111
|
-
// Once restart is attempted, the new node-agent exclusively owns all later
|
|
112
|
-
// rollout transitions. The old worker must never write this job again.
|
|
113
|
-
restartAttempted = true;
|
|
114
218
|
run("systemctl", ["restart", service]);
|
|
115
|
-
verifyInstalledVersion();
|
|
116
219
|
} catch (error) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}));
|
|
124
|
-
}
|
|
220
|
+
updateJob(["queued", "updating-node", "restarting-node"], (current) => ({
|
|
221
|
+
status: "failed",
|
|
222
|
+
rollout: { ...current.rollout, phase: "failed" },
|
|
223
|
+
error: { code: "NODE_UPDATE_FAILED", message: error instanceof Error ? error.message : String(error), retryable: false },
|
|
224
|
+
completedAt: new Date().toISOString(),
|
|
225
|
+
}));
|
|
125
226
|
console.error(error);
|
|
126
227
|
process.exit(1);
|
|
127
228
|
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
void main();
|