agent update!

This commit is contained in:
2026-08-07 10:14:19 +09:00
parent 521d11ffa6
commit 45a13718e6
10 changed files with 217 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "platform-agent", "name": "platform-agent",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
Binary file not shown.
+50 -1
View File
@@ -25,7 +25,7 @@ from rclpy.executors import SingleThreadedExecutor
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
from rclpy.action import ActionClient from rclpy.action import ActionClient
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
from lifecycle_msgs.srv import GetState from lifecycle_msgs.srv import GetState
from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav_msgs.srv import GetMap from nav_msgs.srv import GetMap
@@ -237,6 +237,13 @@ class SubscriberNode(Node):
"/rviz/routing/rough_goal", "/rviz/routing/rough_goal",
10, 10,
) )
self._cmd_vel_pub = None
if _ROBOT_STACK == "nav2":
self._cmd_vel_pub = self.create_publisher(
Twist,
"/cmd_vel",
10,
)
# Short control trajectory / long predicted path # Short control trajectory / long predicted path
self._trajectory = None self._trajectory = None
@@ -1247,6 +1254,17 @@ class SubscriberNode(Node):
self._nav2_goal_handle = None self._nav2_goal_handle = None
return {"ok": True} return {"ok": True}
def publish_nav2_stop(self, repeat=1, interval=0.1):
if self._cmd_vel_pub is None:
return {"topic": "/cmd_vel", "published": 0}
zero_velocity = Twist()
for index in range(max(1, repeat)):
self._cmd_vel_pub.publish(zero_velocity)
if index + 1 < repeat:
time.sleep(interval)
return {"topic": "/cmd_vel", "published": max(1, repeat)}
sub_node: Optional[SubscriberNode] = None sub_node: Optional[SubscriberNode] = None
_spin_thread: Optional[threading.Thread] = None _spin_thread: Optional[threading.Thread] = None
@@ -1676,6 +1694,37 @@ def disengage():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/emergency_stop")
def emergency_stop():
"""Stop motion immediately through the active robot stack.
This is a software stop. A physical emergency stop still has to be handled
by the platform's safety controller.
"""
try:
if _ROBOT_STACK == "nav2":
# Stop the controller first, then cancel Nav2 so a pending action
# cannot issue another velocity command while cancellation completes.
initial_stop = sub_node.publish_nav2_stop()
cancel_result = sub_node.cancel_nav2_goal()
repeated_stop = sub_node.publish_nav2_stop(repeat=4)
return {
"ok": True,
"stack": "nav2",
"cancelled": cancel_result.get("ok", False),
"cmdVel": {
"topic": initial_stop["topic"],
"published": initial_stop["published"] + repeated_stop["published"],
},
}
result = disengage()
return {**result, "stack": _ROBOT_STACK, "emergency": True}
except Exception as e:
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/reset") @app.post("/reset")
def reset(): def reset():
""" """
+6
View File
@@ -490,6 +490,7 @@ values = {
"TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"), "TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"),
"TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"), "TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"),
"AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"), "AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"),
"AGENT_DEVICE_ID": first("agentDeviceId", "agent_device_id"),
} }
if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]: if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]:
values["INSTALL_TAILSCALE"] = "true" values["INSTALL_TAILSCALE"] = "true"
@@ -690,6 +691,7 @@ write_agent_config_from_env() {
CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \ CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \
KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \ KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \
AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \ AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \
AGENT_DEVICE_ID="$AGENT_DEVICE_ID" \
bun -e ' bun -e '
const fs = require("fs"); const fs = require("fs");
const path = process.env.AGENT_CONFIG_PATH; const path = process.env.AGENT_CONFIG_PATH;
@@ -700,6 +702,10 @@ write_agent_config_from_env() {
cfg.camera ??= {}; cfg.camera ??= {};
cfg.desktop ??= {}; cfg.desktop ??= {};
cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || ""; cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || "";
cfg.updates ??= {};
cfg.updates.enabled = cfg.updates.enabled !== false;
cfg.updates.checkUrl = cfg.updates.checkUrl || "https://portal-api.autonomystudio.net/api/provision/agent/update-check";
cfg.updates.deviceId = process.env.AGENT_DEVICE_ID || cfg.updates.deviceId || "";
cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces"; cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces";
cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware"; cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware";
cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0"; cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0";
Binary file not shown.
Binary file not shown.
+110
View File
@@ -0,0 +1,110 @@
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { arch } from "node:process";
import { basename, dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import type { AgentConfig } from "./config.ts";
type UpdateCheckResponse = {
latestVersion?: string;
releaseBaseUrl?: string;
updateAvailable?: boolean;
};
export type AgentUpdateResult = {
currentVersion: string;
latestVersion: string | null;
status: "disabled" | "unconfigured" | "current" | "updated" | "failed";
message?: string;
};
function packageRoot(): string {
return resolve(import.meta.dir, "..");
}
async function readCurrentVersion(): Promise<string> {
const packageJson = JSON.parse(await readFile(join(packageRoot(), "package.json"), "utf8")) as { version?: unknown };
return typeof packageJson.version === "string" && packageJson.version.trim() ? packageJson.version.trim() : "0.0.0";
}
function archiveArchitecture(): "amd64" | "arm64" | null {
if (arch === "x64") return "amd64";
if (arch === "arm64") return "arm64";
return null;
}
async function run(command: string[], cwd?: string): Promise<void> {
const process = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" });
const exitCode = await process.exited;
if (exitCode === 0) return;
const stderr = (await new Response(process.stderr).text()).trim();
throw new Error(stderr || `${basename(command[0])} exited with code ${exitCode}`);
}
async function replaceRelease(releaseBaseUrl: string, version: string): Promise<void> {
const architecture = archiveArchitecture();
if (!architecture) throw new Error(`unsupported architecture: ${arch}`);
const response = await fetch(`${releaseBaseUrl}/platform-agent-linux-${architecture}.tar.gz`, {
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`release download failed: HTTP ${response.status}`);
const stagingRoot = await mkdtemp(join(tmpdir(), "platform-agent-update-"));
try {
const archivePath = join(stagingRoot, "platform-agent.tar.gz");
await writeFile(archivePath, new Uint8Array(await response.arrayBuffer()));
await run(["tar", "-xzf", archivePath, "-C", stagingRoot]);
const releaseRoot = join(stagingRoot, "platform-agent");
const releasePackage = JSON.parse(await readFile(join(releaseRoot, "package.json"), "utf8")) as { version?: unknown };
if (releasePackage.version !== version) {
throw new Error(`release version mismatch: expected ${version}, received ${String(releasePackage.version)}`);
}
const root = packageRoot();
for (const entry of ["src", "scripts", "package.json", "bun.lock", "tsconfig.json"]) {
await cp(join(releaseRoot, entry), join(root, entry), { recursive: true, force: true });
}
await run(["bun", "install", "--frozen-lockfile"], root);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
}
export async function checkForAgentUpdate(config: AgentConfig): Promise<AgentUpdateResult> {
const currentVersion = await readCurrentVersion();
if (!config.updates.enabled) return { currentVersion, latestVersion: null, status: "disabled" };
if (!config.security.gatewayToken || !config.updates.deviceId || !config.updates.checkUrl) {
return { currentVersion, latestVersion: null, status: "unconfigured" };
}
try {
const response = await fetch(config.updates.checkUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-agent-gateway-token": config.security.gatewayToken,
},
body: JSON.stringify({ deviceId: config.updates.deviceId, version: currentVersion }),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`update check failed: HTTP ${response.status}`);
const update = await response.json() as UpdateCheckResponse;
const latestVersion = typeof update.latestVersion === "string" ? update.latestVersion : null;
if (!update.updateAvailable || !latestVersion) {
return { currentVersion, latestVersion, status: "current" };
}
if (!update.releaseBaseUrl) throw new Error("update response does not include a release URL");
await replaceRelease(update.releaseBaseUrl, latestVersion);
return { currentVersion, latestVersion, status: "updated" };
} catch (error) {
return {
currentVersion,
latestVersion: null,
status: "failed",
message: error instanceof Error ? error.message : String(error),
};
}
}
+8
View File
@@ -160,6 +160,14 @@ export function createBridgeApi(config: AgentConfig): Hono {
} }
}); });
app.post("/emergency-stop", async (c) => {
try {
return c.json(await bridgeJson(config, "/emergency_stop", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to emergency stop robot.");
}
});
app.post("/reset", async (c) => { app.post("/reset", async (c) => {
try { try {
return c.json(await bridgeJson(config, "/reset", { method: "POST" })); return c.json(await bridgeJson(config, "/reset", { method: "POST" }));
+24
View File
@@ -11,6 +11,11 @@ export interface AgentConfig {
security: { security: {
gatewayToken: string; gatewayToken: string;
}; };
updates: {
enabled: boolean;
checkUrl: string;
deviceId: string;
};
iframe: { iframe: {
title: string; title: string;
allowOrigins: string[]; allowOrigins: string[];
@@ -72,6 +77,11 @@ const defaultConfig: AgentConfig = {
security: { security: {
gatewayToken: "", gatewayToken: "",
}, },
updates: {
enabled: true,
checkUrl: "https://portal-api.autonomystudio.net/api/provision/agent/update-check",
deviceId: "",
},
iframe: { iframe: {
title: "KasmVNC", title: "KasmVNC",
allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"], allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"],
@@ -175,6 +185,10 @@ function mergeConfig(base: AgentConfig, patch: unknown): AgentConfig {
...base.security, ...base.security,
...(isPlainObject(patch.security) ? patch.security : {}), ...(isPlainObject(patch.security) ? patch.security : {}),
}, },
updates: {
...base.updates,
...(isPlainObject(patch.updates) ? patch.updates : {}),
},
iframe: { iframe: {
...base.iframe, ...base.iframe,
...(isPlainObject(patch.iframe) ? patch.iframe : {}), ...(isPlainObject(patch.iframe) ? patch.iframe : {}),
@@ -218,6 +232,10 @@ export function normalizeConfig(config: AgentConfig): AgentConfig {
config.server.port = Number(config.server.port); config.server.port = Number(config.server.port);
config.security ??= { gatewayToken: "" }; config.security ??= { gatewayToken: "" };
config.security.gatewayToken = String(config.security.gatewayToken ?? ""); config.security.gatewayToken = String(config.security.gatewayToken ?? "");
config.updates ??= { ...defaultConfig.updates };
config.updates.enabled = config.updates.enabled !== false;
config.updates.checkUrl = String(config.updates.checkUrl || defaultConfig.updates.checkUrl).replace(/\/+$/, "");
config.updates.deviceId = String(config.updates.deviceId ?? "").trim();
config.desktop.provider = "kasmvnc"; config.desktop.provider = "kasmvnc";
config.desktop.display = String(config.desktop.display || ":1"); config.desktop.display = String(config.desktop.display || ":1");
config.desktop.webPort = Number(config.desktop.webPort); config.desktop.webPort = Number(config.desktop.webPort);
@@ -334,6 +352,8 @@ export async function saveConfigPatch(current: AgentConfig, patch: unknown): Pro
const normalized = normalizeConfig(merged); const normalized = normalizeConfig(merged);
await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
Object.assign(current.server, normalized.server); Object.assign(current.server, normalized.server);
Object.assign(current.security, normalized.security);
Object.assign(current.updates, normalized.updates);
Object.assign(current.iframe, normalized.iframe); Object.assign(current.iframe, normalized.iframe);
Object.assign(current.desktop, normalized.desktop); Object.assign(current.desktop, normalized.desktop);
Object.assign(current.camera, normalized.camera); Object.assign(current.camera, normalized.camera);
@@ -377,6 +397,10 @@ export function getConfigSummary(config: AgentConfig) {
pipelineDescConfigured: config.camera.pipelineDesc.length > 0, pipelineDescConfigured: config.camera.pipelineDesc.length > 0,
}, },
workspace: config.workspace, workspace: config.workspace,
updates: {
enabled: config.updates.enabled,
deviceIdConfigured: config.updates.deviceId.length > 0,
},
ros: config.ros, ros: config.ros,
modules: config.modules, modules: config.modules,
}; };
+18
View File
@@ -12,10 +12,21 @@ import { createDisplayApi } from "./display.ts";
import { createKasmVncApi } from "./kasmVnc.ts"; import { createKasmVncApi } from "./kasmVnc.ts";
import { createBridgeApi, createViewerControlApi } from "./bridge.ts"; import { createBridgeApi, createViewerControlApi } from "./bridge.ts";
import { createVisualizationApi } from "./visualization.ts"; import { createVisualizationApi } from "./visualization.ts";
import { checkForAgentUpdate } from "./agentUpdate.ts";
const config = loadConfig(); const config = loadConfig();
const updateResult = await checkForAgentUpdate(config);
if (updateResult.status === "updated") {
console.log(`[agent-update] updated ${updateResult.currentVersion} -> ${updateResult.latestVersion}; restarting service.`);
process.exit(0);
}
if (updateResult.status === "failed") {
console.warn(`[agent-update] ${updateResult.message}`);
}
const app = new Hono(); const app = new Hono();
const camera = new CameraManager(config); const camera = new CameraManager(config);
const agentStartedAt = new Date();
const agentStartedAtMs = agentStartedAt.getTime();
const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS"; const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS";
const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS"; const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS";
@@ -327,8 +338,15 @@ app.get("/camera", (c) => {
app.get("/health", (c) => c.json({ app.get("/health", (c) => c.json({
ok: true, ok: true,
startedAt: agentStartedAt.toISOString(),
uptimeSeconds: Math.floor((Date.now() - agentStartedAtMs) / 1000),
sshUser: process.env.AGENT_SSH_USER || process.env.USER || null, sshUser: process.env.AGENT_SSH_USER || process.env.USER || null,
workspaceRoot: config.workspace.root, workspaceRoot: config.workspace.root,
agentVersion: updateResult.currentVersion,
update: {
status: updateResult.status,
latestVersion: updateResult.latestVersion,
},
})); }));
app.post("/api/ssh/authorized-keys", async (c) => { app.post("/api/ssh/authorized-keys", async (c) => {
const body = await c.req.json().catch(() => ({})); const body = await c.req.json().catch(() => ({}));