diff --git a/package.json b/package.json index 6685638..a0221f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform-agent", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/ros_bridge/__pycache__/bridge.cpython-310.pyc b/ros_bridge/__pycache__/bridge.cpython-310.pyc new file mode 100644 index 0000000..40d5d42 Binary files /dev/null and b/ros_bridge/__pycache__/bridge.cpython-310.pyc differ diff --git a/ros_bridge/bridge.py b/ros_bridge/bridge.py index 5f6cc9e..c21dbd2 100644 --- a/ros_bridge/bridge.py +++ b/ros_bridge/bridge.py @@ -25,7 +25,7 @@ from rclpy.executors import SingleThreadedExecutor from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy 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 nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath from nav_msgs.srv import GetMap @@ -237,6 +237,13 @@ class SubscriberNode(Node): "/rviz/routing/rough_goal", 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 self._trajectory = None @@ -1247,6 +1254,17 @@ class SubscriberNode(Node): self._nav2_goal_handle = None 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 _spin_thread: Optional[threading.Thread] = None @@ -1676,6 +1694,37 @@ def disengage(): 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") def reset(): """ diff --git a/scripts/install.sh b/scripts/install.sh index e0ad0fc..b7d5f6c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -490,6 +490,7 @@ values = { "TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"), "TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"), "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"]: values["INSTALL_TAILSCALE"] = "true" @@ -690,6 +691,7 @@ write_agent_config_from_env() { CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \ KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \ AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \ + AGENT_DEVICE_ID="$AGENT_DEVICE_ID" \ bun -e ' const fs = require("fs"); const path = process.env.AGENT_CONFIG_PATH; @@ -700,6 +702,10 @@ write_agent_config_from_env() { cfg.camera ??= {}; cfg.desktop ??= {}; 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.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"; diff --git a/scripts/installer-tui/installer-tui-linux-amd64 b/scripts/installer-tui/installer-tui-linux-amd64 index 96c13e9..ae2cdf8 100755 Binary files a/scripts/installer-tui/installer-tui-linux-amd64 and b/scripts/installer-tui/installer-tui-linux-amd64 differ diff --git a/scripts/installer-tui/installer-tui-linux-arm64 b/scripts/installer-tui/installer-tui-linux-arm64 index c0da45a..5dc226a 100755 Binary files a/scripts/installer-tui/installer-tui-linux-arm64 and b/scripts/installer-tui/installer-tui-linux-arm64 differ diff --git a/src/agentUpdate.ts b/src/agentUpdate.ts new file mode 100644 index 0000000..e47fc40 --- /dev/null +++ b/src/agentUpdate.ts @@ -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 { + 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 { + 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 { + 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 { + 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), + }; + } +} diff --git a/src/bridge.ts b/src/bridge.ts index 872349a..26aa028 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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) => { try { return c.json(await bridgeJson(config, "/reset", { method: "POST" })); diff --git a/src/config.ts b/src/config.ts index 49eba19..3643b19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,11 @@ export interface AgentConfig { security: { gatewayToken: string; }; + updates: { + enabled: boolean; + checkUrl: string; + deviceId: string; + }; iframe: { title: string; allowOrigins: string[]; @@ -72,6 +77,11 @@ const defaultConfig: AgentConfig = { security: { gatewayToken: "", }, + updates: { + enabled: true, + checkUrl: "https://portal-api.autonomystudio.net/api/provision/agent/update-check", + deviceId: "", + }, iframe: { title: "KasmVNC", 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, ...(isPlainObject(patch.security) ? patch.security : {}), }, + updates: { + ...base.updates, + ...(isPlainObject(patch.updates) ? patch.updates : {}), + }, iframe: { ...base.iframe, ...(isPlainObject(patch.iframe) ? patch.iframe : {}), @@ -218,6 +232,10 @@ export function normalizeConfig(config: AgentConfig): AgentConfig { config.server.port = Number(config.server.port); 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.display = String(config.desktop.display || ":1"); config.desktop.webPort = Number(config.desktop.webPort); @@ -334,6 +352,8 @@ export async function saveConfigPatch(current: AgentConfig, patch: unknown): Pro const normalized = normalizeConfig(merged); await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); 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.desktop, normalized.desktop); Object.assign(current.camera, normalized.camera); @@ -377,6 +397,10 @@ export function getConfigSummary(config: AgentConfig) { pipelineDescConfigured: config.camera.pipelineDesc.length > 0, }, workspace: config.workspace, + updates: { + enabled: config.updates.enabled, + deviceIdConfigured: config.updates.deviceId.length > 0, + }, ros: config.ros, modules: config.modules, }; diff --git a/src/server.tsx b/src/server.tsx index a0335b7..e0e6d3c 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -12,10 +12,21 @@ import { createDisplayApi } from "./display.ts"; import { createKasmVncApi } from "./kasmVnc.ts"; import { createBridgeApi, createViewerControlApi } from "./bridge.ts"; import { createVisualizationApi } from "./visualization.ts"; +import { checkForAgentUpdate } from "./agentUpdate.ts"; 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 camera = new CameraManager(config); +const agentStartedAt = new Date(); +const agentStartedAtMs = agentStartedAt.getTime(); const AUTHORIZED_KEYS_BEGIN = "# BEGIN 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({ ok: true, + startedAt: agentStartedAt.toISOString(), + uptimeSeconds: Math.floor((Date.now() - agentStartedAtMs) / 1000), sshUser: process.env.AGENT_SSH_USER || process.env.USER || null, workspaceRoot: config.workspace.root, + agentVersion: updateResult.currentVersion, + update: { + status: updateResult.status, + latestVersion: updateResult.latestVersion, + }, })); app.post("/api/ssh/authorized-keys", async (c) => { const body = await c.req.json().catch(() => ({}));