agent update!
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "platform-agent",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
+50
-1
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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";
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user