agenticros 0.1.18 → 0.1.19
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/dist/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/sync-skill-tools.mjs +67 -10
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
"""
|
|
2
2
|
AgenticROS Discovery Node
|
|
3
3
|
|
|
4
|
-
Introspects the running ROS2 system and publishes
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
Introspects the running ROS2 system and publishes two manifests:
|
|
5
|
+
|
|
6
|
+
1. A capability manifest describing the available topics, services, and
|
|
7
|
+
actions on the ROS2 graph (consumed by the OpenClaw plugin to inform
|
|
8
|
+
the AI agent about what the robot can do).
|
|
9
|
+
2. (Phase 1.e) A robot heartbeat (`RobotInfo`) carrying id, name, kind,
|
|
10
|
+
capabilities, and sensor flags — published on `<namespace>/agenticros/
|
|
11
|
+
robot_info` at 1 Hz. This is the live counterpart to the TS-side
|
|
12
|
+
`config.robots[i]` block: the TS agent can subscribe to a fleet's
|
|
13
|
+
heartbeats and answer `ros2_find_robots_for(capability=…)` against
|
|
14
|
+
robots it has never been configured for. See docs/strategy-ai-agents-
|
|
15
|
+
plus-ros.md §4(d).
|
|
16
|
+
|
|
17
|
+
Published topics:
|
|
18
|
+
/agenticros/capabilities (agenticros_msgs/msg/CapabilityManifest)
|
|
19
|
+
<namespace>/agenticros/robot_info (agenticros_msgs/msg/RobotInfo, Phase 1.e)
|
|
10
20
|
|
|
11
21
|
Service:
|
|
12
22
|
/agenticros/get_capabilities (agenticros_msgs/srv/GetCapabilities)
|
|
13
23
|
|
|
14
24
|
Parameters:
|
|
15
|
-
robot_name
|
|
16
|
-
robot_namespace —
|
|
17
|
-
|
|
25
|
+
robot_name — Display name (default: "Robot")
|
|
26
|
+
robot_namespace — ROS2 namespace, also used as the heartbeat id fallback (default: "")
|
|
27
|
+
robot_id — Stable id for the robot (Phase 1.e). When empty, falls back to robot_namespace.
|
|
28
|
+
robot_kind — Robot kind: amr | arm | drone | rover | … (Phase 1.e, default: "amr")
|
|
29
|
+
capability_ids — Per-robot capability allowlist for ros2_find_robots_for. Empty = inherit gateway registry. (Phase 1.e)
|
|
30
|
+
has_realsense — Sensor tag, surfaces in RobotInfo + ros2_find_robots_for (Phase 1.e)
|
|
31
|
+
has_lidar — Sensor tag (Phase 1.e)
|
|
32
|
+
has_arm — Sensor tag (Phase 1.e)
|
|
33
|
+
publish_interval — Seconds between CapabilityManifest publications (default: 5.0)
|
|
34
|
+
heartbeat_interval — Seconds between RobotInfo heartbeats (Phase 1.e, default: 1.0)
|
|
18
35
|
"""
|
|
19
36
|
|
|
20
37
|
from __future__ import annotations
|
|
@@ -23,7 +40,7 @@ import rclpy
|
|
|
23
40
|
from rclpy.node import Node
|
|
24
41
|
from rclpy.qos import QoSProfile, DurabilityPolicy
|
|
25
42
|
|
|
26
|
-
from agenticros_msgs.msg import CapabilityManifest
|
|
43
|
+
from agenticros_msgs.msg import CapabilityManifest, RobotInfo
|
|
27
44
|
from agenticros_msgs.srv import GetCapabilities
|
|
28
45
|
|
|
29
46
|
|
|
@@ -35,38 +52,92 @@ _INTERNAL_PREFIXES = (
|
|
|
35
52
|
)
|
|
36
53
|
|
|
37
54
|
|
|
55
|
+
def _normalize_namespace_for_topic(ns: str) -> str:
|
|
56
|
+
"""Return `<ns>/agenticros/robot_info` with exactly one leading slash.
|
|
57
|
+
|
|
58
|
+
Empty namespace falls back to a single global topic (no per-robot prefix)
|
|
59
|
+
so single-robot deployments without an explicit namespace still get a
|
|
60
|
+
heartbeat at `/agenticros/robot_info`.
|
|
61
|
+
"""
|
|
62
|
+
cleaned = ns.strip().lstrip("/")
|
|
63
|
+
if not cleaned:
|
|
64
|
+
return "/agenticros/robot_info"
|
|
65
|
+
return f"/{cleaned}/agenticros/robot_info"
|
|
66
|
+
|
|
67
|
+
|
|
38
68
|
class DiscoveryNode(Node):
|
|
39
69
|
"""Periodically discovers ROS2 capabilities and publishes a manifest."""
|
|
40
70
|
|
|
41
71
|
def __init__(self) -> None:
|
|
42
72
|
super().__init__("agenticros_discovery")
|
|
43
73
|
|
|
44
|
-
# Parameters
|
|
74
|
+
# Parameters — historical first, then Phase 1.e additions.
|
|
45
75
|
self.declare_parameter("robot_name", "Robot")
|
|
46
76
|
self.declare_parameter("robot_namespace", "")
|
|
47
77
|
self.declare_parameter("publish_interval", 5.0)
|
|
48
78
|
|
|
79
|
+
# Phase 1.e identity + fleet-metadata parameters. Declared with
|
|
80
|
+
# back-compat defaults so existing single-robot launches keep
|
|
81
|
+
# working without setting anything new.
|
|
82
|
+
self.declare_parameter("robot_id", "")
|
|
83
|
+
self.declare_parameter("robot_kind", "amr")
|
|
84
|
+
self.declare_parameter("capability_ids", [""])
|
|
85
|
+
self.declare_parameter("has_realsense", False)
|
|
86
|
+
self.declare_parameter("has_lidar", False)
|
|
87
|
+
self.declare_parameter("has_arm", False)
|
|
88
|
+
self.declare_parameter("heartbeat_interval", 1.0)
|
|
89
|
+
|
|
49
90
|
self.robot_name: str = self.get_parameter("robot_name").value
|
|
50
91
|
self.robot_namespace: str = self.get_parameter("robot_namespace").value
|
|
51
92
|
self.publish_interval: float = self.get_parameter("publish_interval").value
|
|
52
93
|
|
|
94
|
+
# rclpy can't declare an empty string-array default — it falls
|
|
95
|
+
# back to TYPE_NOT_SET unless we seed with [""]. Strip the
|
|
96
|
+
# placeholder here so the published heartbeat carries an
|
|
97
|
+
# accurate count.
|
|
98
|
+
raw_capability_ids = self.get_parameter("capability_ids").value or []
|
|
99
|
+
self.capability_ids = [c for c in raw_capability_ids if c]
|
|
100
|
+
|
|
101
|
+
# robot_id falls back to robot_namespace, matching the TS-side
|
|
102
|
+
# listRobots() legacy-fallback contract.
|
|
103
|
+
configured_id: str = self.get_parameter("robot_id").value
|
|
104
|
+
self.robot_id: str = configured_id.strip() or (self.robot_namespace.strip() or "default")
|
|
105
|
+
self.robot_kind: str = self.get_parameter("robot_kind").value
|
|
106
|
+
self.has_realsense: bool = self.get_parameter("has_realsense").value
|
|
107
|
+
self.has_lidar: bool = self.get_parameter("has_lidar").value
|
|
108
|
+
self.has_arm: bool = self.get_parameter("has_arm").value
|
|
109
|
+
self.heartbeat_interval: float = self.get_parameter("heartbeat_interval").value
|
|
110
|
+
|
|
53
111
|
# Publisher — transient local so late subscribers get the last manifest
|
|
54
112
|
qos = QoSProfile(depth=1, durability=DurabilityPolicy.TRANSIENT_LOCAL)
|
|
55
113
|
self.manifest_pub = self.create_publisher(
|
|
56
114
|
CapabilityManifest, "/agenticros/capabilities", qos
|
|
57
115
|
)
|
|
116
|
+
# Phase 1.e heartbeat publisher. Transient-local QoS so a TS
|
|
117
|
+
# adapter that subscribes mid-flight immediately gets the
|
|
118
|
+
# latest sample without waiting up to `heartbeat_interval` for
|
|
119
|
+
# the next tick.
|
|
120
|
+
self.robot_info_topic = _normalize_namespace_for_topic(self.robot_namespace)
|
|
121
|
+
self.robot_info_pub = self.create_publisher(RobotInfo, self.robot_info_topic, qos)
|
|
58
122
|
|
|
59
123
|
# Service — on-demand query
|
|
60
124
|
self.get_caps_srv = self.create_service(
|
|
61
125
|
GetCapabilities, "/agenticros/get_capabilities", self._handle_get_capabilities
|
|
62
126
|
)
|
|
63
127
|
|
|
64
|
-
#
|
|
128
|
+
# Timers — keep manifest + heartbeat on independent cadences.
|
|
129
|
+
# The manifest is comparatively heavy (whole topic graph) and
|
|
130
|
+
# changes rarely, so the existing 5 s default stays. The
|
|
131
|
+
# heartbeat at 1 Hz matches the strategy memo's
|
|
132
|
+
# "1 Hz with 5 s staleness window" recommendation.
|
|
65
133
|
self.timer = self.create_timer(self.publish_interval, self._on_timer)
|
|
134
|
+
self.heartbeat_timer = self.create_timer(self.heartbeat_interval, self._on_heartbeat)
|
|
66
135
|
|
|
67
136
|
self.get_logger().info(
|
|
68
137
|
f"Discovery node started: robot={self.robot_name}, "
|
|
69
|
-
f"namespace='{self.robot_namespace}', interval={self.publish_interval}s"
|
|
138
|
+
f"namespace='{self.robot_namespace}', interval={self.publish_interval}s, "
|
|
139
|
+
f"id={self.robot_id}, kind={self.robot_kind}, "
|
|
140
|
+
f"heartbeat={self.heartbeat_interval}s on {self.robot_info_topic}"
|
|
70
141
|
)
|
|
71
142
|
|
|
72
143
|
def _on_timer(self) -> None:
|
|
@@ -79,6 +150,30 @@ class DiscoveryNode(Node):
|
|
|
79
150
|
f"{len(manifest.action_names)} actions"
|
|
80
151
|
)
|
|
81
152
|
|
|
153
|
+
def _on_heartbeat(self) -> None:
|
|
154
|
+
"""Publish a `RobotInfo` heartbeat on `<namespace>/agenticros/robot_info`.
|
|
155
|
+
|
|
156
|
+
Carries id, name, kind, capability ids, sensor flags, and a
|
|
157
|
+
timestamp so subscribers can apply a staleness window without
|
|
158
|
+
relying on transport-level keepalives.
|
|
159
|
+
"""
|
|
160
|
+
info = self._build_robot_info()
|
|
161
|
+
self.robot_info_pub.publish(info)
|
|
162
|
+
|
|
163
|
+
def _build_robot_info(self) -> RobotInfo:
|
|
164
|
+
"""Materialise the Phase 1.e heartbeat payload from declared params."""
|
|
165
|
+
msg = RobotInfo()
|
|
166
|
+
msg.id = self.robot_id
|
|
167
|
+
msg.name = self.robot_name
|
|
168
|
+
msg.kind = self.robot_kind
|
|
169
|
+
msg.robot_namespace = self.robot_namespace
|
|
170
|
+
msg.capability_ids = list(self.capability_ids)
|
|
171
|
+
msg.has_realsense = bool(self.has_realsense)
|
|
172
|
+
msg.has_lidar = bool(self.has_lidar)
|
|
173
|
+
msg.has_arm = bool(self.has_arm)
|
|
174
|
+
msg.stamp = self.get_clock().now().to_msg()
|
|
175
|
+
return msg
|
|
176
|
+
|
|
82
177
|
def _handle_get_capabilities(
|
|
83
178
|
self,
|
|
84
179
|
request: GetCapabilities.Request,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Phase 1.e robot heartbeat — published on `<namespace>/agenticros/robot_info`
|
|
2
|
+
# by agenticros_discovery at 1 Hz. Mirrors the TS-side RobotEntry shape so
|
|
3
|
+
# the discovery service can self-advertise without TS-side static config,
|
|
4
|
+
# and so a future Phase 4 ACP/A2A agent-card payload is a strict superset
|
|
5
|
+
# of these fields. See docs/strategy-ai-agents-plus-ros.md §4(d).
|
|
6
|
+
#
|
|
7
|
+
# Required fields are at the top. Optional fields (sensors, battery, pose)
|
|
8
|
+
# are below — Phase 1.e ships id/name/kind/namespace/capabilities/sensors
|
|
9
|
+
# and leaves the others as forward-compatible placeholders so consumers
|
|
10
|
+
# don't have to handle a schema bump for Phase 3 spatial memory.
|
|
11
|
+
|
|
12
|
+
# Stable, human-readable identifier — the same `id` the TS side uses as
|
|
13
|
+
# `robot_id` in tool calls (`ros2_publish(robot_id, …)`). Falls back to
|
|
14
|
+
# `robot_namespace` when not set.
|
|
15
|
+
string id
|
|
16
|
+
|
|
17
|
+
# Display name shown to users / the chat agent (e.g. "Kitchen Bot").
|
|
18
|
+
string name
|
|
19
|
+
|
|
20
|
+
# Robot kind — "amr" | "arm" | "drone" | "rover" | (free-form). Used by
|
|
21
|
+
# `ros2_find_robots_for(kind=…)` to partition heterogeneous fleets.
|
|
22
|
+
string kind
|
|
23
|
+
|
|
24
|
+
# ROS2 topic namespace prefix (e.g. "robot3946b404…"). Without leading
|
|
25
|
+
# slash — consumers prepend "/" + this when computing topic paths.
|
|
26
|
+
string robot_namespace
|
|
27
|
+
|
|
28
|
+
# Capability ids the robot advertises (matches the verbs returned by
|
|
29
|
+
# `ros2_list_capabilities`, e.g. "drive_base", "follow_person",
|
|
30
|
+
# "arm_grasp"). When empty, consumers fall back to the gateway-wide
|
|
31
|
+
# global registry — same precedence as the TS-side per-robot allowlist.
|
|
32
|
+
string[] capability_ids
|
|
33
|
+
|
|
34
|
+
# Sensor / hardware flags. Three slots match the Phase 1.e schema; add
|
|
35
|
+
# new ones additively (default-false at the consumer when absent) so old
|
|
36
|
+
# subscribers don't break.
|
|
37
|
+
bool has_realsense
|
|
38
|
+
bool has_lidar
|
|
39
|
+
bool has_arm
|
|
40
|
+
|
|
41
|
+
# Heartbeat timestamp — consumers treat the robot as offline when the
|
|
42
|
+
# last observed stamp is older than ~5 s (per the strategy memo's
|
|
43
|
+
# "1 Hz + 5 s staleness window" recommendation).
|
|
44
|
+
builtin_interfaces/Time stamp
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the staleness-detection logic in refresh-skill-deps.mjs.
|
|
3
|
+
*
|
|
4
|
+
* The pure-function pieces (file listing + diff) are what we actually care
|
|
5
|
+
* about pinning down — the spawnSync layer is integration-tested by the
|
|
6
|
+
* `pnpm refresh:skills` smoke run inside `pnpm deploy:plugin`.
|
|
7
|
+
*
|
|
8
|
+
* Why these tests matter: today there's only one staleness-detection
|
|
9
|
+
* algorithm, and if it accidentally treats "target has more files than
|
|
10
|
+
* source" as stale (it should NOT), or treats unrelated files in the
|
|
11
|
+
* skill's @agenticros/core as missing (it should NOT), the refresh will
|
|
12
|
+
* either thrash (always reinstall) or no-op (never reinstall) — both of
|
|
13
|
+
* which break the contract.
|
|
14
|
+
*/
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
listFilesRecursive,
|
|
23
|
+
findMissingFiles,
|
|
24
|
+
checkSkillStaleness,
|
|
25
|
+
discoverSkillPaths,
|
|
26
|
+
} from "../refresh-skill-deps.mjs";
|
|
27
|
+
|
|
28
|
+
async function writeText(file, text) {
|
|
29
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
30
|
+
await writeFile(file, text, "utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("listFilesRecursive: empty dir returns []", async () => {
|
|
34
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
35
|
+
try {
|
|
36
|
+
const empty = path.join(root, "empty");
|
|
37
|
+
await mkdir(empty);
|
|
38
|
+
assert.deepEqual(listFilesRecursive(empty), []);
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(root, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("listFilesRecursive: missing dir returns [] (no throw)", () => {
|
|
45
|
+
assert.deepEqual(listFilesRecursive("/no/such/dir/ever-${process.pid}"), []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("listFilesRecursive: walks subdirs and returns relative, sorted paths", async () => {
|
|
49
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
50
|
+
try {
|
|
51
|
+
await writeText(path.join(root, "a.js"), "a");
|
|
52
|
+
await writeText(path.join(root, "sub", "b.js"), "b");
|
|
53
|
+
await writeText(path.join(root, "sub", "deep", "c.js"), "c");
|
|
54
|
+
const files = listFilesRecursive(root);
|
|
55
|
+
assert.deepEqual(files, ["a.js", path.join("sub", "b.js"), path.join("sub", "deep", "c.js")]);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("findMissingFiles: returns source files absent from target", () => {
|
|
62
|
+
const source = ["a.js", "b.js", "c.js"];
|
|
63
|
+
const target = ["a.js", "c.js"];
|
|
64
|
+
assert.deepEqual(findMissingFiles(source, target), ["b.js"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("findMissingFiles: target supersets are NOT treated as missing in source", () => {
|
|
68
|
+
// Skills sometimes contain extra build artifacts; that's not a cascade.
|
|
69
|
+
const source = ["a.js", "b.js"];
|
|
70
|
+
const target = ["a.js", "b.js", "extra.js"];
|
|
71
|
+
assert.deepEqual(findMissingFiles(source, target), []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("findMissingFiles: empty source ⇒ no missing files (degenerate)", () => {
|
|
75
|
+
assert.deepEqual(findMissingFiles([], ["a.js"]), []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("findMissingFiles: empty target ⇒ everything in source is missing", () => {
|
|
79
|
+
assert.deepEqual(findMissingFiles(["a.js", "b.js"], []), ["a.js", "b.js"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("checkSkillStaleness: hasCoreLink=false when skill has no @agenticros/core dep", async () => {
|
|
83
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
84
|
+
try {
|
|
85
|
+
const skill = path.join(root, "skill-no-core");
|
|
86
|
+
await mkdir(skill, { recursive: true });
|
|
87
|
+
const result = checkSkillStaleness(skill, { sourceCoreDist: path.join(root, "core-src") });
|
|
88
|
+
assert.equal(result.hasCoreLink, false);
|
|
89
|
+
assert.deepEqual(result.missingFiles, []);
|
|
90
|
+
} finally {
|
|
91
|
+
await rm(root, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("checkSkillStaleness: detects missing files when target lacks new source files", async () => {
|
|
96
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
97
|
+
try {
|
|
98
|
+
const coreSrc = path.join(root, "core", "dist");
|
|
99
|
+
await writeText(path.join(coreSrc, "index.js"), "");
|
|
100
|
+
await writeText(path.join(coreSrc, "capabilities.js"), "");
|
|
101
|
+
await writeText(path.join(coreSrc, "mission.js"), "");
|
|
102
|
+
|
|
103
|
+
// Skill has a stale link — only index.js, missing capabilities.js and mission.js.
|
|
104
|
+
const skill = path.join(root, "skill");
|
|
105
|
+
const staleDist = path.join(skill, "node_modules", "@agenticros", "core", "dist");
|
|
106
|
+
await writeText(path.join(staleDist, "index.js"), "");
|
|
107
|
+
|
|
108
|
+
const result = checkSkillStaleness(skill, { sourceCoreDist: coreSrc });
|
|
109
|
+
assert.equal(result.hasCoreLink, true);
|
|
110
|
+
assert.deepEqual(result.missingFiles.sort(), ["capabilities.js", "mission.js"]);
|
|
111
|
+
assert.equal(result.sourceCount, 3);
|
|
112
|
+
assert.equal(result.targetCount, 1);
|
|
113
|
+
} finally {
|
|
114
|
+
await rm(root, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("checkSkillStaleness: returns missingFiles=[] when target is current", async () => {
|
|
119
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
120
|
+
try {
|
|
121
|
+
const coreSrc = path.join(root, "core", "dist");
|
|
122
|
+
await writeText(path.join(coreSrc, "index.js"), "");
|
|
123
|
+
await writeText(path.join(coreSrc, "capabilities.js"), "");
|
|
124
|
+
|
|
125
|
+
const skill = path.join(root, "skill");
|
|
126
|
+
const dist = path.join(skill, "node_modules", "@agenticros", "core", "dist");
|
|
127
|
+
await writeText(path.join(dist, "index.js"), "");
|
|
128
|
+
await writeText(path.join(dist, "capabilities.js"), "");
|
|
129
|
+
|
|
130
|
+
const result = checkSkillStaleness(skill, { sourceCoreDist: coreSrc });
|
|
131
|
+
assert.equal(result.hasCoreLink, true);
|
|
132
|
+
assert.deepEqual(result.missingFiles, []);
|
|
133
|
+
assert.equal(result.sourceCount, 2);
|
|
134
|
+
assert.equal(result.targetCount, 2);
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(root, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("checkSkillStaleness: works when the skill's @agenticros/core is a symlink (real pnpm layout)", async () => {
|
|
141
|
+
const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
|
|
142
|
+
try {
|
|
143
|
+
const coreSrc = path.join(root, "core", "dist");
|
|
144
|
+
await writeText(path.join(coreSrc, "index.js"), "");
|
|
145
|
+
await writeText(path.join(coreSrc, "mission.js"), "");
|
|
146
|
+
|
|
147
|
+
// Mimic the pnpm virtual store layout.
|
|
148
|
+
const pnpmStore = path.join(
|
|
149
|
+
root,
|
|
150
|
+
"skill",
|
|
151
|
+
"node_modules",
|
|
152
|
+
".pnpm",
|
|
153
|
+
"@agenticros+core@file+stub",
|
|
154
|
+
"node_modules",
|
|
155
|
+
"@agenticros",
|
|
156
|
+
"core",
|
|
157
|
+
);
|
|
158
|
+
await writeText(path.join(pnpmStore, "dist", "index.js"), "");
|
|
159
|
+
await writeText(path.join(pnpmStore, "dist", "mission.js"), "");
|
|
160
|
+
|
|
161
|
+
const linkParent = path.join(root, "skill", "node_modules", "@agenticros");
|
|
162
|
+
await mkdir(linkParent, { recursive: true });
|
|
163
|
+
await symlink(pnpmStore, path.join(linkParent, "core"));
|
|
164
|
+
|
|
165
|
+
const result = checkSkillStaleness(path.join(root, "skill"), { sourceCoreDist: coreSrc });
|
|
166
|
+
assert.equal(result.hasCoreLink, true);
|
|
167
|
+
assert.deepEqual(result.missingFiles, []);
|
|
168
|
+
assert.equal(result.targetCount, 2);
|
|
169
|
+
} finally {
|
|
170
|
+
await rm(root, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("discoverSkillPaths: extraSkillPaths are picked up and absolutised", () => {
|
|
175
|
+
// Inject only the extraSkillPaths and isolate from env / config files by
|
|
176
|
+
// clearing the relevant env vars for the duration of the call. The
|
|
177
|
+
// function also reads ~/.openclaw/openclaw.json + ~/.agenticros/config.json
|
|
178
|
+
// unconditionally; we accept that those may add more entries, and just
|
|
179
|
+
// assert our injected path is INCLUDED, not that it's the only one.
|
|
180
|
+
const original = {
|
|
181
|
+
AGENTICROS_SKILL_PATHS: process.env.AGENTICROS_SKILL_PATHS,
|
|
182
|
+
OPENCLAW_CONFIG: process.env.OPENCLAW_CONFIG,
|
|
183
|
+
AGENTICROS_CONFIG_PATH: process.env.AGENTICROS_CONFIG_PATH,
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
delete process.env.AGENTICROS_SKILL_PATHS;
|
|
187
|
+
// Point OpenClaw + AgenticROS config readers at nonexistent files so
|
|
188
|
+
// those branches contribute nothing during the test.
|
|
189
|
+
process.env.OPENCLAW_CONFIG = "/dev/null/nonexistent.json";
|
|
190
|
+
process.env.AGENTICROS_CONFIG_PATH = "/dev/null/nonexistent.json";
|
|
191
|
+
|
|
192
|
+
const paths = discoverSkillPaths({ extraSkillPaths: ["/tmp/my-skill"] });
|
|
193
|
+
assert.ok(paths.includes("/tmp/my-skill"), `expected /tmp/my-skill in discovered paths; got ${paths.join(", ")}`);
|
|
194
|
+
} finally {
|
|
195
|
+
for (const [k, v] of Object.entries(original)) {
|
|
196
|
+
if (v === undefined) delete process.env[k];
|
|
197
|
+
else process.env[k] = v;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("discoverSkillPaths: AGENTICROS_SKILL_PATHS env var splits on ':'", () => {
|
|
203
|
+
const original = process.env.AGENTICROS_SKILL_PATHS;
|
|
204
|
+
const originalOpen = process.env.OPENCLAW_CONFIG;
|
|
205
|
+
const originalAgenticros = process.env.AGENTICROS_CONFIG_PATH;
|
|
206
|
+
try {
|
|
207
|
+
process.env.OPENCLAW_CONFIG = "/dev/null/nonexistent.json";
|
|
208
|
+
process.env.AGENTICROS_CONFIG_PATH = "/dev/null/nonexistent.json";
|
|
209
|
+
process.env.AGENTICROS_SKILL_PATHS = "/tmp/skill-a:/tmp/skill-b";
|
|
210
|
+
|
|
211
|
+
const paths = discoverSkillPaths();
|
|
212
|
+
assert.ok(paths.includes("/tmp/skill-a"));
|
|
213
|
+
assert.ok(paths.includes("/tmp/skill-b"));
|
|
214
|
+
} finally {
|
|
215
|
+
if (original === undefined) delete process.env.AGENTICROS_SKILL_PATHS;
|
|
216
|
+
else process.env.AGENTICROS_SKILL_PATHS = original;
|
|
217
|
+
if (originalOpen === undefined) delete process.env.OPENCLAW_CONFIG;
|
|
218
|
+
else process.env.OPENCLAW_CONFIG = originalOpen;
|
|
219
|
+
if (originalAgenticros === undefined) delete process.env.AGENTICROS_CONFIG_PATH;
|
|
220
|
+
else process.env.AGENTICROS_CONFIG_PATH = originalAgenticros;
|
|
221
|
+
}
|
|
222
|
+
});
|