@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.
@@ -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 nodeAgentManifest = path.resolve(__dirname, "..", "package.json");
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 verifyInstalledVersion() {
62
- const installedVersion = JSON.parse(fs.readFileSync(nodeAgentManifest, "utf8")).version;
63
- if (installedVersion !== targetVersion) {
64
- throw new Error(`Updated node-agent verification failed: expected ${targetVersion}, found ${installedVersion || "unknown"}.`);
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 verifyNpmArtifactIntegrity() {
69
- const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
70
- const prefix = `npm:@task-handoff/node-agent@${targetVersion}#`;
71
- if (typeof job.artifactRef !== "string" || !job.artifactRef.startsWith(prefix) || job.artifactRef.length === prefix.length) {
72
- throw new Error(`Update job does not pin npm integrity for @task-handoff/node-agent@${targetVersion}.`);
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
- const expectedIntegrity = job.artifactRef.slice(prefix.length);
75
- const result = spawnSync(npmCommand, ["view", `@task-handoff/node-agent@${targetVersion}`, "dist.integrity", "--json"], { encoding: "utf8" });
76
- if (result.status !== 0) throw new Error("Could not verify the node-agent npm artifact integrity.");
77
- let actualIntegrity;
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
- actualIntegrity = JSON.parse(result.stdout);
110
+ integrity = JSON.parse(result.stdout);
80
111
  } catch {
81
- throw new Error("npm returned invalid node-agent artifact integrity metadata.");
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(`Node-agent npm artifact integrity mismatch: expected ${expectedIntegrity}, found ${String(actualIntegrity || "unknown")}.`);
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
- let restartAttempted = false;
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
- `@task-handoff/node-agent@${targetVersion}`,
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
- if (!restartAttempted) {
118
- updateJob(["queued", "updating-node"], (current) => ({
119
- status: "failed",
120
- rollout: { ...current.rollout, phase: "failed" },
121
- error: { code: "NODE_UPDATE_FAILED", message: error instanceof Error ? error.message : String(error), retryable: false },
122
- completedAt: new Date().toISOString(),
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();