Feat/skill hub hardened (#172)

* feat(skill-hub): add Skill Hub catalog, publish flow, and lite materialize pipeline

Introduce Skill Hub for browsing, importing, publishing, and installing skills, with lite instance package materialization and runtime sync support.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skill-hub): remove token-governance hooks from Skill Hub PR

Strip validateManagedRuntimeEnvironmentOverrides, network lock policy sync,
and egress proxy audit wiring that belong to the upcoming token-usage work,
so the Skill Hub branch compiles independently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skill-hub): update migration number in materialize docs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skill-hub): make RuntimeAgentClient test stub and hub tests compile-safe

Add ResyncInstanceSkills to the runtime pool handler fake client, and harden
skill hub payload helpers/tests against nil storage/instance repos so go test passes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: add Skill Hub hardening with session usage tracking and egress governance

Unify Skill Hub runtime sync improvements with session-token observability,
egress network policy, and admin/instance usage reporting for reopenable PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skill-hub): repair CI tests and nested skill install

* fix(ci): restore release deployment configuration

* feat(skill-hub): 支持 Skill 批量安装与 Hermes 导入

* 定时任务开发,Skill hub功能优化,最新Hermes版本适配

---------

Co-authored-by: heshengran <heshengran@ieisystem.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
heranran
2026-07-30 18:27:15 +08:00
committed by GitHub
parent 5e3db3ac85
commit 6ab0fef99c
32 changed files with 3533 additions and 145 deletions
+1
View File
@@ -93,3 +93,4 @@ TEAM_PROFILES_LOCAL_TESTING.md
/clawmanager-team-profiles-test.yaml
/clawmanager-tenant.yaml
.codex
/litellm-five-highlights-design.md
+4
View File
@@ -402,6 +402,9 @@ func main() {
instances.POST("/:id/skills/:skillId/import-to-library", instanceHandler.ImportInstanceSkillToLibrary)
instances.POST("/:id/skills/:skillId/retry-package-collect", instanceHandler.RetrySkillPackageCollect)
instances.POST("/:id/skills/:skillId/publish-to-hub", instanceHandler.PublishInstanceSkillToHub)
instances.POST("/:id/skills/:skillId/restore", instanceHandler.RestoreInstanceSkill)
instances.POST("/:id/skills/:skillId/save-back-to-library", instanceHandler.SaveBackInstanceSkillToLibrary)
instances.POST("/:id/skills/:skillId/save-to-my-library", instanceHandler.SaveForeignInstanceSkillToMyLibrary)
instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance)
}
@@ -499,6 +502,7 @@ func main() {
skillHub.POST("/skills/import", skillHubHandler.ImportSkills)
skillHub.GET("/skills/:id", skillHubHandler.GetSkill)
skillHub.POST("/skills/:id/publish", skillHubHandler.PublishSkill)
skillHub.POST("/skills/:id/publish-as-new", skillHubHandler.PublishSkillAsNew)
skillHub.POST("/skills/:id/unpublish", skillHubHandler.UnpublishSkill)
skillHub.PUT("/skills/:id/tags", skillHubHandler.UpdateTags)
skillHub.DELETE("/skills/:id", skillHubHandler.DeleteSkill)
@@ -1977,6 +1977,66 @@ func (h *InstanceHandler) PublishInstanceSkillToHub(c *gin.Context) {
utils.Success(c, http.StatusOK, "Skill published to hub successfully", item)
}
func (h *InstanceHandler) RestoreInstanceSkill(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
skillID, err := strconv.Atoi(c.Param("skillId"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
item, err := h.skillService.RestoreInstanceSkill(userID.(int), userRole.(string), instance.ID, skillID)
if err != nil {
utils.HandleHubError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill restored on instance successfully", item)
}
func (h *InstanceHandler) SaveBackInstanceSkillToLibrary(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
skillID, err := strconv.Atoi(c.Param("skillId"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
item, err := h.skillService.SaveBackInstanceSkillToLibrary(userID.(int), userRole.(string), instance.ID, skillID)
if err != nil {
utils.HandleHubError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill saved back to library successfully", item)
}
func (h *InstanceHandler) SaveForeignInstanceSkillToMyLibrary(c *gin.Context) {
instance, ok := h.requireOwnedInstance(c)
if !ok {
return
}
skillID, err := strconv.Atoi(c.Param("skillId"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
item, err := h.skillService.SaveForeignInstanceSkillToMyLibrary(userID.(int), userRole.(string), instance.ID, skillID)
if err != nil {
utils.HandleHubError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill saved to your library successfully", item)
}
func (h *InstanceHandler) requireOwnedInstance(c *gin.Context) (*models.Instance, bool) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
@@ -144,6 +144,27 @@ func (h *SkillHubHandler) PublishSkill(c *gin.Context) {
utils.Success(c, http.StatusOK, "Skill published to hub successfully", item)
}
func (h *SkillHubHandler) PublishSkillAsNew(c *gin.Context) {
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
var req services.PublishSkillHubRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.PublishSkillAsNew(userID.(int), userRole.(string), skillID, req.TagIDs)
if err != nil {
utils.HandleHubError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill published as new hub skill successfully", item)
}
func (h *SkillHubHandler) UnpublishSkill(c *gin.Context) {
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
@@ -0,0 +1,58 @@
package services
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"clawreef/internal/models"
)
func TestCollectUpstreamSetCookies(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Set-Cookie", "hermes_session_at=session-127; Path=/proxy; HttpOnly")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
resp, err := srv.Client().Post(srv.URL+"/auth/password-login", "application/json", bytes.NewReader([]byte(`{}`)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got := collectUpstreamSetCookies(resp)
if len(got) == 0 {
t.Fatalf("no cookies; Values=%v Map=%v Cookies=%v", resp.Header.Values("Set-Cookie"), resp.Header["Set-Cookie"], resp.Cookies())
}
}
func TestIsHermesLiteProxyInstanceMatchesTokenInjectionSetup(t *testing.T) {
instanceToken := "igt_hermes_instance"
workspacePath := "/workspaces/hermes/user-45/instance-127"
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[127] = &models.Instance{
ID: 127,
UserID: 45,
Type: "hermes",
RuntimeType: "gateway",
InstanceMode: InstanceModeLite,
Status: "running",
AccessToken: &instanceToken,
WorkspacePath: &workspacePath,
RuntimeGeneration: 5,
}
service := NewInstanceProxyService(NewInstanceAccessService())
service.instanceRepo = instanceRepo
if !service.isHermesLiteProxyInstance(127, "hermes") {
inst, _ := instanceRepo.GetByID(127)
rt, ok := v2RuntimeTypeForInstance(inst)
t.Fatalf("isHermesLiteProxyInstance=false; runtimeType=%q ok=%v mode=%q runtime=%q", rt, ok, inst.InstanceMode, inst.RuntimeType)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/127/proxy/chat", nil)
if !shouldBootstrapHermesDashboardSession(req, "/chat") {
t.Fatal("shouldBootstrap=false")
}
}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
@@ -145,6 +146,9 @@ func (s *InstanceProxyService) ProxyRequest(ctx context.Context, instanceID int,
}
managedGatewayToken := s.managedRuntimeGatewayBearerToken(ctx, instanceID, accessToken.InstanceType)
proxyPrefix := hermesProxyPrefix(instanceID)
hermesLite := s.isHermesLiteProxyInstance(instanceID, accessToken.InstanceType)
bootstrapPath := stripInstanceProxyPrefix(targetPath, instanceID)
// Copy query parameters, excluding ClawManager-owned proxy/gateway tokens.
queryParams := r.URL.Query()
@@ -157,6 +161,31 @@ func (s *InstanceProxyService) ProxyRequest(ctx context.Context, instanceID int,
proxyCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
var bootstrapSetCookies []string
if hermesLite && shouldBootstrapHermesDashboardSession(r, bootstrapPath) && strings.TrimSpace(managedGatewayToken) != "" {
if cookies, bootErr := s.bootstrapHermesDashboardSession(proxyCtx, targetURL, instanceID, managedGatewayToken, r); bootErr == nil {
bootstrapSetCookies = cookies
}
}
// After a successful bootstrap on non-chat entry points, send the browser
// to /chat with session cookies instead of serving the login HTML.
if len(bootstrapSetCookies) > 0 && shouldRedirectHermesBootstrapToChat(bootstrapPath) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Credentials", "true")
for _, cookie := range bootstrapSetCookies {
if strings.TrimSpace(cookie) == "" {
continue
}
w.Header().Add("Set-Cookie", cookie)
}
w.Header().Set("Location", hermesChatProxyLocation(instanceID, token))
w.Header().Del("X-Frame-Options")
w.Header().Del("Content-Security-Policy")
w.WriteHeader(http.StatusFound)
return nil
}
proxyReq, err := http.NewRequestWithContext(proxyCtx, r.Method, targetURL.String(), r.Body)
if err != nil {
return fmt.Errorf("failed to create proxy request: %w", err)
@@ -168,13 +197,16 @@ func (s *InstanceProxyService) ProxyRequest(ctx context.Context, instanceID int,
proxyReq.Header.Add(key, value)
}
}
attachCookiesToRequest(proxyReq, bootstrapSetCookies)
// Set X-Forwarded headers
proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr)
proxyReq.Header.Set("X-Forwarded-Host", r.Host)
proxyReq.Header.Set("X-Forwarded-Proto", requestScheme(r))
proxyReq.Header.Set("X-Forwarded-Prefix", fmt.Sprintf("/api/v1/instances/%d/proxy", instanceID))
setManagedRuntimeGatewayAuthHeaders(proxyReq.Header, managedGatewayToken)
proxyReq.Header.Set("X-Forwarded-Prefix", proxyPrefix)
if !isHermesDashboardPublicAuthPath(bootstrapPath) {
setManagedRuntimeGatewayAuthHeaders(proxyReq.Header, managedGatewayToken)
}
if shouldRewriteHTML {
proxyReq.Header.Del("Accept-Encoding")
}
@@ -207,6 +239,9 @@ func (s *InstanceProxyService) ProxyRequest(ctx context.Context, instanceID int,
}
modifiedBody := injectProxyBase(string(body), proxyBaseForRequestPath(effectiveRequestPath, instanceID))
if hermesLite {
modifiedBody = injectHermesAbsolutePathPatch(modifiedBody, proxyPrefix)
}
resp.Body = io.NopCloser(bytes.NewReader([]byte(modifiedBody)))
resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", strconv.Itoa(len(modifiedBody)))
@@ -220,6 +255,12 @@ func (s *InstanceProxyService) ProxyRequest(ctx context.Context, instanceID int,
w.Header().Add(key, value)
}
}
for _, cookie := range bootstrapSetCookies {
if strings.TrimSpace(cookie) == "" {
continue
}
w.Header().Add("Set-Cookie", cookie)
}
w.Header().Del("X-Frame-Options")
w.Header().Del("Content-Security-Policy")
@@ -271,6 +312,9 @@ func (s *InstanceProxyService) ProxyWebSocket(ctx context.Context, instanceID in
}
managedGatewayToken := s.managedRuntimeGatewayBearerToken(ctx, instanceID, accessToken.InstanceType)
upstreamPath := stripInstanceProxyPrefix(targetPath, instanceID)
hermesLite := s.isHermesLiteProxyInstance(instanceID, accessToken.InstanceType)
skipManagedWSAuth := hermesLite && isHermesDashboardTicketWebSocket(upstreamPath, r.URL.Query())
// Copy query parameters, excluding ClawManager-owned proxy/gateway tokens.
queryParams := r.URL.Query()
@@ -295,18 +339,38 @@ func (s *InstanceProxyService) ProxyWebSocket(ctx context.Context, instanceID in
upstreamHeader.Set("X-Forwarded-Host", r.Host)
upstreamHeader.Set("X-Forwarded-Proto", requestScheme(r))
upstreamHeader.Set("X-Forwarded-Prefix", fmt.Sprintf("/api/v1/instances/%d/proxy", instanceID))
setManagedRuntimeGatewayAuthHeaders(upstreamHeader, managedGatewayToken)
if managedGatewayToken != "" {
upstreamHeader.Set("Origin", s.openClawWebSocketOrigin(targetURL))
// Hermes dashboard chat uses cookie + ticket query auth. Do not inject
// managed Bearer/API-Key headers or rewrite Origin for those sockets.
if skipManagedWSAuth {
upstreamHeader.Del("Authorization")
upstreamHeader.Del("X-Api-Key")
upstreamHeader.Del("X-OpenAI-Api-Key")
upstreamHeader.Del("OpenAI-Api-Key")
upstreamHeader.Del("X-ClawManager-Instance-Token")
upstreamHeader.Del("X-ClawManager-LLM-API-Key")
} else {
setManagedRuntimeGatewayAuthHeaders(upstreamHeader, managedGatewayToken)
if managedGatewayToken != "" {
upstreamHeader.Set("Origin", s.openClawWebSocketOrigin(targetURL))
}
}
// Keep the pipe alive for the full WebSocket lifetime; do not inherit any
// short deadlines that may be attached to the inbound request context.
proxyCtx := ctx
if ctx != nil {
proxyCtx = context.WithoutCancel(ctx)
}
dialer := websocket.Dialer{
Proxy: http.ProxyFromEnvironment,
HandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
ReadBufferSize: 1024 * 1024,
WriteBufferSize: 1024 * 1024,
}
upstreamConn, resp, err := dialer.DialContext(ctx, targetURL.String(), upstreamHeader)
upstreamConn, resp, err := dialer.DialContext(proxyCtx, targetURL.String(), upstreamHeader)
if err != nil {
if resp != nil {
defer resp.Body.Close()
@@ -314,9 +378,12 @@ func (s *InstanceProxyService) ProxyWebSocket(ctx context.Context, instanceID in
return fmt.Errorf("failed to connect upstream websocket: %w", err)
}
defer upstreamConn.Close()
upstreamConn.SetReadLimit(hermesWebSocketMaxMessageBytes)
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 1024 * 1024,
WriteBufferSize: 1024 * 1024,
}
responseHeader := http.Header{}
@@ -329,6 +396,7 @@ func (s *InstanceProxyService) ProxyWebSocket(ctx context.Context, instanceID in
return fmt.Errorf("failed to upgrade client websocket: %w", err)
}
defer clientConn.Close()
clientConn.SetReadLimit(hermesWebSocketMaxMessageBytes)
errCh := make(chan error, 2)
pipe := func(dst, src *websocket.Conn) {
@@ -403,6 +471,270 @@ func setManagedRuntimeGatewayAuthHeaders(header http.Header, token string) {
header.Set("X-ClawManager-Instance-Token", token)
header.Set("X-ClawManager-LLM-API-Key", token)
}
func hermesProxyPrefix(instanceID int) string {
return fmt.Sprintf("/api/v1/instances/%d/proxy", instanceID)
}
func (s *InstanceProxyService) isHermesLiteProxyInstance(instanceID int, instanceType string) bool {
if s == nil || s.instanceRepo == nil || !strings.EqualFold(strings.TrimSpace(instanceType), RuntimeTypeHermes) {
return false
}
instance, err := s.instanceRepo.GetByID(instanceID)
if err != nil || instance == nil {
return false
}
runtimeType, ok := v2RuntimeTypeForInstance(instance)
return ok && runtimeType == RuntimeTypeHermes
}
func isHermesDashboardPublicAuthPath(targetPath string) bool {
path := strings.TrimSpace(targetPath)
if path == "" {
return false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
switch {
case path == "/login", strings.HasPrefix(path, "/login?"):
return true
case path == "/auth", strings.HasPrefix(path, "/auth/"):
return true
default:
return false
}
}
const hermesWebSocketMaxMessageBytes int64 = 8 << 20 // 8 MiB PTY snapshots
func isHermesDashboardTicketWebSocket(targetPath string, query url.Values) bool {
if query != nil && strings.TrimSpace(query.Get("ticket")) != "" {
return true
}
path := normalizeHermesBootstrapPath(targetPath)
switch path {
case "/api/pty", "/api/events":
return true
default:
return strings.HasPrefix(path, "/api/pty/") || strings.HasPrefix(path, "/api/events/")
}
}
func isHermesSessionCookieName(name string) bool {
bare := strings.TrimSpace(name)
for _, prefix := range []string{"__Host-", "__Secure-"} {
if strings.HasPrefix(bare, prefix) {
bare = strings.TrimPrefix(bare, prefix)
break
}
}
return bare == "hermes_session_at"
}
func requestHasHermesSessionCookie(r *http.Request) bool {
if r == nil {
return false
}
for _, cookie := range r.Cookies() {
if isHermesSessionCookieName(cookie.Name) && strings.TrimSpace(cookie.Value) != "" {
return true
}
}
return false
}
func shouldBootstrapHermesDashboardSession(r *http.Request, targetPath string) bool {
if r == nil {
return false
}
method := strings.ToUpper(strings.TrimSpace(r.Method))
if method != http.MethodGet && method != http.MethodHead {
return false
}
if requestHasHermesSessionCookie(r) {
return false
}
path := normalizeHermesBootstrapPath(targetPath)
switch path {
case "/", "/chat", "/login":
return true
}
if strings.HasPrefix(path, "/login") {
return true
}
accept := strings.ToLower(r.Header.Get("Accept"))
if strings.Contains(accept, "text/html") &&
!strings.HasPrefix(path, "/api") &&
!strings.HasPrefix(path, "/auth") &&
!strings.HasPrefix(path, "/assets") {
return true
}
return false
}
func normalizeHermesBootstrapPath(targetPath string) string {
path := strings.TrimSpace(targetPath)
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
trimmed := strings.TrimSuffix(path, "/")
if trimmed == "" {
return "/"
}
return trimmed
}
func shouldRedirectHermesBootstrapToChat(bootstrapPath string) bool {
return normalizeHermesBootstrapPath(bootstrapPath) != "/chat"
}
func hermesChatProxyLocation(instanceID int, proxyToken string) string {
location := fmt.Sprintf("/api/v1/instances/%d/proxy/chat/", instanceID)
if token := strings.TrimSpace(proxyToken); token != "" {
return location + "?token=" + url.QueryEscape(token)
}
return location
}
func (s *InstanceProxyService) bootstrapHermesDashboardSession(
ctx context.Context,
upstreamTarget *url.URL,
instanceID int,
password string,
clientReq *http.Request,
) ([]string, error) {
if s == nil || s.httpClient == nil || upstreamTarget == nil {
return nil, fmt.Errorf("hermes bootstrap unavailable")
}
password = strings.TrimSpace(password)
if password == "" {
return nil, fmt.Errorf("hermes bootstrap password missing")
}
loginURL := &url.URL{
Scheme: upstreamTarget.Scheme,
Host: upstreamTarget.Host,
Path: "/auth/password-login",
}
body, err := json.Marshal(map[string]string{
"provider": "basic",
"username": "clawmanager",
"password": password,
"next": "/chat",
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if clientReq != nil {
req.Header.Set("X-Forwarded-For", clientReq.RemoteAddr)
req.Header.Set("X-Forwarded-Host", clientReq.Host)
req.Header.Set("X-Forwarded-Proto", requestScheme(clientReq))
}
req.Header.Set("X-Forwarded-Prefix", hermesProxyPrefix(instanceID))
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("hermes password-login status %d", resp.StatusCode)
}
cookies := collectUpstreamSetCookies(resp)
if len(cookies) == 0 {
return nil, fmt.Errorf("hermes password-login returned no cookies")
}
return cookies, nil
}
func collectUpstreamSetCookies(resp *http.Response) []string {
if resp == nil {
return nil
}
if values := resp.Header.Values("Set-Cookie"); len(values) > 0 {
return append([]string(nil), values...)
}
if values := resp.Header["Set-Cookie"]; len(values) > 0 {
return append([]string(nil), values...)
}
out := make([]string, 0, len(resp.Cookies()))
for _, cookie := range resp.Cookies() {
if cookie == nil || strings.TrimSpace(cookie.Name) == "" {
continue
}
out = append(out, cookie.String())
}
return out
}
func attachCookiesToRequest(req *http.Request, setCookies []string) {
if req == nil || len(setCookies) == 0 {
return
}
existing := req.Header.Get("Cookie")
parts := make([]string, 0, len(setCookies)+1)
if strings.TrimSpace(existing) != "" {
parts = append(parts, existing)
}
for _, raw := range setCookies {
name, value, ok := parseSetCookiePair(raw)
if !ok {
continue
}
parts = append(parts, name+"="+value)
}
if len(parts) == 0 {
return
}
req.Header.Set("Cookie", strings.Join(parts, "; "))
}
func parseSetCookiePair(raw string) (name, value string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
pair := strings.SplitN(raw, ";", 2)[0]
name, value, found := strings.Cut(pair, "=")
name = strings.TrimSpace(name)
if !found || name == "" {
return "", "", false
}
return name, value, true
}
func injectHermesAbsolutePathPatch(html, proxyPrefix string) string {
prefix := strings.TrimRight(strings.TrimSpace(proxyPrefix), "/")
if prefix == "" || html == "" {
return html
}
// Keep the script brace-safe for fmt; prefix is JSON-quoted for JS.
prefixJSON, err := json.Marshal(prefix)
if err != nil {
return html
}
script := `<script>(function(p){if(!p)return;function fix(u){if(typeof u!=="string")return u;if(!u||u.charAt(0)!=="/"||u.indexOf("//")===0)return u;if(u===p||u.indexOf(p+"/")===0)return u;return p+u;}var of=window.fetch;if(typeof of==="function"){window.fetch=function(input,init){if(typeof input==="string"){input=fix(input);}else if(input&&typeof input.url==="string"){try{input=new Request(fix(input.url),input);}catch(e){}}return of.call(this,input,init);};}if(window.XMLHttpRequest&&XMLHttpRequest.prototype){var oo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){if(typeof url==="string"){arguments[1]=fix(url);}return oo.apply(this,arguments);};}function wrap(fn){return function(url){if(typeof url==="string"){url=fix(url);}return fn.call(this,url);};}try{var la=window.location.assign.bind(window.location);window.location.assign=wrap(la);}catch(e){}try{var lr=window.location.replace.bind(window.location);window.location.replace=wrap(lr);}catch(e){}})(` + string(prefixJSON) + `);</script>`
for _, tag := range []string{"<head>", "<Head>", "<HEAD>"} {
if idx := strings.Index(html, tag); idx != -1 {
insertAt := idx + len(tag)
return html[:insertAt] + script + html[insertAt:]
}
}
return script + html
}
func (s *InstanceProxyService) managedRuntimeGatewayBearerToken(ctx context.Context, instanceID int, instanceType string) string {
if s == nil || s.instanceRepo == nil {
return ""
@@ -1,6 +1,8 @@
package services
import (
"bytes"
"encoding/json"
"errors"
"net"
"net/http"
@@ -84,7 +86,31 @@ func TestInstanceProxyServiceUsesRuntimeBindingForV2(t *testing.T) {
func TestInstanceProxyServiceInjectsInstanceTokenForHermesLite(t *testing.T) {
instanceToken := "igt_hermes_instance"
var loginHits int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/password-login" {
loginHits++
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("password-login Authorization = %q, want empty", got)
}
if got := r.Header.Get("X-Forwarded-Prefix"); got != "/api/v1/instances/127/proxy" {
t.Fatalf("password-login X-Forwarded-Prefix = %q", got)
}
var payload map[string]string
_ = json.NewDecoder(r.Body).Decode(&payload)
if payload["provider"] != "basic" || payload["username"] != "clawmanager" || payload["password"] != instanceToken {
t.Fatalf("password-login payload = %#v", payload)
}
http.SetCookie(w, &http.Cookie{
Name: "hermes_session_at",
Value: "session-127",
Path: "/api/v1/instances/127/proxy",
HttpOnly: true,
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"next":"/chat"}`))
return
}
if r.URL.Path != "/chat" {
t.Fatalf("unexpected upstream path %s", r.URL.Path)
}
@@ -100,6 +126,9 @@ func TestInstanceProxyServiceInjectsInstanceTokenForHermesLite(t *testing.T) {
if got := r.Header.Get("X-ClawManager-Instance-Token"); got != instanceToken {
t.Fatalf("X-ClawManager-Instance-Token = %q", got)
}
if !strings.Contains(r.Header.Get("Cookie"), "hermes_session_at=session-127") {
t.Fatalf("expected bootstrap session cookie on chat request, got %q (loginHits=%d)", r.Header.Get("Cookie"), loginHits)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
@@ -149,16 +178,25 @@ func TestInstanceProxyServiceInjectsInstanceTokenForHermesLite(t *testing.T) {
rec := httptest.NewRecorder()
if err := service.ProxyRequest(req.Context(), 127, token.Token, rec, req); err != nil {
t.Fatalf("ProxyRequest returned error: %v", err)
t.Fatalf("ProxyRequest returned error: %v (loginHits=%d)", err, loginHits)
}
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Set-Cookie"); !strings.Contains(got, "hermes_session_at=session-127") {
t.Fatalf("expected Set-Cookie from bootstrap, got %q", got)
}
}
func TestInstanceProxyServiceUsesHermesLiteAccessURLForRootEntry(t *testing.T) {
instanceToken := "igt_hermes_instance"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/password-login" {
w.Header().Add("Set-Cookie", "hermes_session_at=session-131; Path=/api/v1/instances/131/proxy; HttpOnly")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"next":"/chat"}`))
return
}
if r.URL.Path != "/chat/" {
t.Fatalf("unexpected upstream path %s", r.URL.Path)
}
@@ -219,8 +257,12 @@ func TestInstanceProxyServiceUsesHermesLiteAccessURLForRootEntry(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `<base href="/api/v1/instances/131/proxy/chat/">`) {
t.Fatalf("expected Hermes Lite HTML to include chat proxy base, got %q", rec.Body.String())
body := rec.Body.String()
if !strings.Contains(body, `<base href="/api/v1/instances/131/proxy/chat/">`) {
t.Fatalf("expected Hermes Lite HTML to include chat proxy base, got %q", body)
}
if !strings.Contains(body, `"/api/v1/instances/131/proxy"`) || !strings.Contains(body, "window.fetch") {
t.Fatalf("expected Hermes absolute-path patch in HTML, got %q", body)
}
}
@@ -382,7 +424,10 @@ func TestInstanceProxyServiceStripsStaleProxyAccessTokenQuery(t *testing.T) {
func TestInstanceProxyServiceRewritesHermesLiteHTMLBase(t *testing.T) {
instanceToken := "igt_hermes_instance"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
if r.URL.Path == "/auth/password-login" {
t.Fatalf("unexpected password-login when session cookie already present")
}
if r.URL.Path != "/chat/" {
t.Fatalf("unexpected upstream path %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
@@ -433,7 +478,8 @@ func TestInstanceProxyServiceRewritesHermesLiteHTMLBase(t *testing.T) {
service.runtimePodRepo = podRepo
service.httpClient = upstream.Client()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/128/proxy/?token="+url.QueryEscape(token.Token), nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/128/proxy/chat/?token="+url.QueryEscape(token.Token), nil)
req.AddCookie(&http.Cookie{Name: "hermes_session_at", Value: "existing-session"})
rec := httptest.NewRecorder()
if err := service.ProxyRequest(req.Context(), 128, token.Token, rec, req); err != nil {
@@ -442,8 +488,94 @@ func TestInstanceProxyServiceRewritesHermesLiteHTMLBase(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `<base href="/api/v1/instances/128/proxy/">`) {
t.Fatalf("expected Hermes Lite HTML to include proxy base, got %q", rec.Body.String())
body := rec.Body.String()
if !strings.Contains(body, `<base href="/api/v1/instances/128/proxy/chat/">`) {
t.Fatalf("expected Hermes Lite HTML to include chat proxy base, got %q", body)
}
if !strings.Contains(body, "window.fetch") {
t.Fatalf("expected Hermes absolute-path patch, got %q", body)
}
}
func TestInstanceProxyServiceRedirectsHermesLiteRootBootstrapToChat(t *testing.T) {
instanceToken := "igt_hermes_instance"
var loginHits int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/password-login" {
t.Fatalf("unexpected upstream path %s after root bootstrap", r.URL.Path)
}
loginHits++
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("password-login Authorization = %q, want empty", got)
}
http.SetCookie(w, &http.Cookie{
Name: "hermes_session_at",
Value: "session-140",
Path: "/api/v1/instances/140/proxy",
HttpOnly: true,
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"next":"/chat"}`))
}))
defer upstream.Close()
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
workspacePath := "/workspaces/hermes/user-45/instance-140"
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[140] = &models.Instance{
ID: 140,
UserID: 45,
Type: "hermes",
RuntimeType: "gateway",
InstanceMode: InstanceModeLite,
Status: "running",
AccessToken: &instanceToken,
WorkspacePath: &workspacePath,
RuntimeGeneration: 5,
}
bindingRepo := newFakeRuntimeBindingRepo()
bindingRepo.bindings[140] = &models.InstanceRuntimeBinding{
InstanceID: 140,
RuntimePodID: 20,
GatewayPort: gatewayPort,
State: "running",
Generation: 5,
}
podRepo := &fakeRuntimePodRepo{
pods: map[int64]*models.RuntimePod{
20: {ID: 20, PodIP: &podIP, State: "ready"},
},
}
accessService := NewInstanceAccessService()
defer accessService.Stop()
token, err := accessService.GenerateToken(45, 140, "hermes", "/api/v1/instances/140/proxy/", "", 3000, time.Hour)
if err != nil {
t.Fatalf("GenerateToken returned error: %v", err)
}
service := NewInstanceProxyService(accessService)
service.instanceRepo = instanceRepo
service.bindingRepo = bindingRepo
service.runtimePodRepo = podRepo
service.httpClient = upstream.Client()
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/140/proxy/?token="+url.QueryEscape(token.Token), nil)
rec := httptest.NewRecorder()
if err := service.ProxyRequest(req.Context(), 140, token.Token, rec, req); err != nil {
t.Fatalf("ProxyRequest returned error: %v", err)
}
if loginHits != 1 {
t.Fatalf("password-login hits = %d, want 1", loginHits)
}
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
wantLocation := "/api/v1/instances/140/proxy/chat/?token=" + url.QueryEscape(token.Token)
if got := rec.Header().Get("Location"); got != wantLocation {
t.Fatalf("Location = %q, want %q", got, wantLocation)
}
if got := rec.Header().Get("Set-Cookie"); !strings.Contains(got, "hermes_session_at=session-140") {
t.Fatalf("expected bootstrap Set-Cookie, got %q", got)
}
}
@@ -553,6 +685,128 @@ func TestInstanceProxyServiceProxiesHermesLiteWebSocket(t *testing.T) {
}
}
func TestInstanceProxyServiceSkipsBearerForHermesTicketWebSocket(t *testing.T) {
instanceToken := "igt_hermes_instance"
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/pty" {
t.Fatalf("unexpected upstream websocket path %s", r.URL.Path)
}
if got := r.URL.Query().Get("ticket"); got != "ticket-abc" {
t.Fatalf("ticket = %q", got)
}
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want empty for ticket WS", got)
}
if got := r.Header.Get("X-Api-Key"); got != "" {
t.Fatalf("X-Api-Key = %q, want empty for ticket WS", got)
}
if got := r.Header.Get("Cookie"); !strings.Contains(got, "hermes_session_at=session-pty") {
t.Fatalf("Cookie = %q, want session cookie forwarded", got)
}
if got := r.Header.Get("X-Forwarded-Prefix"); got != "/api/v1/instances/141/proxy" {
t.Fatalf("X-Forwarded-Prefix = %q", got)
}
if got := r.Header.Get("Origin"); got == "http://"+r.Host {
t.Fatalf("Origin should not be rewritten to upstream for ticket WS, got %q", got)
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatalf("upstream websocket upgrade failed: %v", err)
}
defer conn.Close()
if err := conn.WriteMessage(websocket.BinaryMessage, bytes.Repeat([]byte("x"), 1024*1024)); err != nil {
t.Fatalf("upstream write failed: %v", err)
}
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatalf("upstream read failed: %v", err)
}
if string(message) != "ack" {
t.Fatalf("upstream message = %q", message)
}
}))
defer upstream.Close()
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
workspacePath := "/workspaces/hermes/user-45/instance-141"
instanceRepo := newV2LifecycleInstanceRepo()
instanceRepo.byID[141] = &models.Instance{
ID: 141,
UserID: 45,
Type: "hermes",
RuntimeType: "gateway",
InstanceMode: InstanceModeLite,
Status: "running",
AccessToken: &instanceToken,
WorkspacePath: &workspacePath,
RuntimeGeneration: 5,
}
bindingRepo := newFakeRuntimeBindingRepo()
bindingRepo.bindings[141] = &models.InstanceRuntimeBinding{
InstanceID: 141,
RuntimePodID: 21,
GatewayPort: gatewayPort,
State: "running",
Generation: 5,
}
podRepo := &fakeRuntimePodRepo{
pods: map[int64]*models.RuntimePod{
21: {ID: 21, PodIP: &podIP, State: "ready"},
},
}
accessService := NewInstanceAccessService()
defer accessService.Stop()
token, err := accessService.GenerateToken(45, 141, "hermes", "/api/v1/instances/141/proxy/", "", 3000, time.Hour)
if err != nil {
t.Fatalf("GenerateToken returned error: %v", err)
}
service := NewInstanceProxyService(accessService)
service.instanceRepo = instanceRepo
service.bindingRepo = bindingRepo
service.runtimePodRepo = podRepo
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := service.ProxyWebSocket(r.Context(), 141, token.Token, w, r); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
}
}))
defer proxy.Close()
wsURL := "ws" + strings.TrimPrefix(proxy.URL, "http") +
"/api/v1/instances/141/proxy/api/pty?channel=chat-1&ticket=ticket-abc"
header := http.Header{}
header.Set("Cookie", "hermes_session_at=session-pty")
header.Set("Origin", "http://clawmanager.example")
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
t.Fatalf("client websocket dial failed: %v", err)
}
defer clientConn.Close()
_, message, err := clientConn.ReadMessage()
if err != nil {
t.Fatalf("client websocket read failed: %v", err)
}
if len(message) != 1024*1024 {
t.Fatalf("message size = %d, want 1MiB", len(message))
}
if err := clientConn.WriteMessage(websocket.TextMessage, []byte("ack")); err != nil {
t.Fatalf("client websocket write failed: %v", err)
}
}
func TestIsHermesDashboardTicketWebSocket(t *testing.T) {
if !isHermesDashboardTicketWebSocket("/api/pty", url.Values{"ticket": []string{"t"}}) {
t.Fatal("expected ticket query to match")
}
if !isHermesDashboardTicketWebSocket("/api/events", nil) {
t.Fatal("expected /api/events to match")
}
if isHermesDashboardTicketWebSocket("/ws", nil) {
t.Fatal("expected /ws not to match")
}
}
func TestInstanceProxyServiceUsesBaseProxyEntryForV2OpenClaw(t *testing.T) {
workspacePath := "/workspaces/openclaw/user-45/instance-123"
accessService := NewInstanceAccessService()
@@ -499,6 +499,11 @@ func (s *openClawConfigService) ValidateResource(req UpsertOpenClawConfigResourc
if err != nil {
return err
}
if resourceType == OpenClawConfigResourceTypeScheduledTask {
if err := validateScheduledTaskEnvelope(envelope); err != nil {
return err
}
}
for _, dep := range envelope.DependsOn {
if !isValidOpenClawResourceType(dep.Type) {
return fmt.Errorf("openclaw config dependency type is invalid")
@@ -0,0 +1,179 @@
package services
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
const OpenClawScheduledTaskFormat = "task/openclaw-cron@v1"
type openClawCronSchedule struct {
Kind string `json:"kind"`
At string `json:"at,omitempty"`
EveryMs *int64 `json:"everyMs,omitempty"`
AnchorMs *int64 `json:"anchorMs,omitempty"`
Expr string `json:"expr,omitempty"`
Tz string `json:"tz,omitempty"`
StaggerMs *int64 `json:"staggerMs,omitempty"`
}
type openClawCronDelivery struct {
Mode string `json:"mode"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
BestEffort *bool `json:"bestEffort,omitempty"`
}
type openClawCronPayload struct {
Kind string `json:"kind"`
Text string `json:"text,omitempty"`
Message string `json:"message,omitempty"`
Model string `json:"model,omitempty"`
Thinking string `json:"thinking,omitempty"`
TimeoutSeconds *int `json:"timeoutSeconds,omitempty"`
AllowUnsafeExternalContent *bool `json:"allowUnsafeExternalContent,omitempty"`
Deliver *bool `json:"deliver,omitempty"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
BestEffortDeliver *bool `json:"bestEffortDeliver,omitempty"`
}
type openClawCronConfig struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
DeleteAfterRun *bool `json:"deleteAfterRun,omitempty"`
AgentID string `json:"agentId,omitempty"`
SessionKey string `json:"sessionKey,omitempty"`
Schedule openClawCronSchedule `json:"schedule"`
SessionTarget string `json:"sessionTarget"`
WakeMode string `json:"wakeMode"`
Payload openClawCronPayload `json:"payload"`
Delivery *openClawCronDelivery `json:"delivery,omitempty"`
}
// ValidateOpenClawCronConfig validates scheduled_task config against the OpenClaw
// cron schedule + payload + delivery acceptance benchmark.
func ValidateOpenClawCronConfig(raw json.RawMessage) error {
if len(raw) == 0 {
return fmt.Errorf("scheduled task config is required")
}
var cfg openClawCronConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("scheduled task config must be valid JSON")
}
if strings.TrimSpace(cfg.Name) == "" {
return fmt.Errorf("scheduled task name is required")
}
sessionTarget := strings.TrimSpace(cfg.SessionTarget)
switch sessionTarget {
case "main", "isolated":
default:
return fmt.Errorf("scheduled task sessionTarget must be main or isolated")
}
wakeMode := strings.TrimSpace(cfg.WakeMode)
switch wakeMode {
case "next-heartbeat", "now":
default:
return fmt.Errorf("scheduled task wakeMode must be next-heartbeat or now")
}
if err := validateOpenClawCronSchedule(cfg.Schedule); err != nil {
return err
}
if err := validateOpenClawCronPayload(cfg.Payload, sessionTarget); err != nil {
return err
}
if err := validateOpenClawCronDelivery(cfg.Delivery); err != nil {
return err
}
return nil
}
func validateOpenClawCronSchedule(schedule openClawCronSchedule) error {
kind := strings.TrimSpace(strings.ToLower(schedule.Kind))
switch kind {
case "at":
if strings.TrimSpace(schedule.At) == "" {
return fmt.Errorf("scheduled task schedule.at is required for kind=at")
}
case "every":
if schedule.EveryMs == nil || *schedule.EveryMs <= 0 {
return fmt.Errorf("scheduled task schedule.everyMs must be > 0 for kind=every")
}
case "cron":
if strings.TrimSpace(schedule.Expr) == "" {
return fmt.Errorf("scheduled task schedule.expr is required for kind=cron")
}
default:
return fmt.Errorf("scheduled task schedule.kind must be at, every, or cron")
}
return nil
}
func validateOpenClawCronPayload(payload openClawCronPayload, sessionTarget string) error {
kind := strings.TrimSpace(payload.Kind)
switch kind {
case "systemEvent":
if strings.TrimSpace(payload.Text) == "" {
return fmt.Errorf("scheduled task payload.text is required for kind=systemEvent")
}
if sessionTarget != "main" {
return fmt.Errorf("scheduled task payload.kind=systemEvent requires sessionTarget=main")
}
case "agentTurn":
if strings.TrimSpace(payload.Message) == "" {
return fmt.Errorf("scheduled task payload.message is required for kind=agentTurn")
}
if sessionTarget != "isolated" {
return fmt.Errorf("scheduled task payload.kind=agentTurn requires sessionTarget=isolated")
}
default:
return fmt.Errorf("scheduled task payload.kind must be systemEvent or agentTurn")
}
if sessionTarget == "main" && kind != "systemEvent" {
return fmt.Errorf("scheduled task sessionTarget=main requires payload.kind=systemEvent")
}
if sessionTarget == "isolated" && kind != "agentTurn" {
return fmt.Errorf("scheduled task sessionTarget=isolated requires payload.kind=agentTurn")
}
return nil
}
func validateOpenClawCronDelivery(delivery *openClawCronDelivery) error {
if delivery == nil {
return nil
}
mode := strings.TrimSpace(strings.ToLower(delivery.Mode))
switch mode {
case "", "none", "announce":
return nil
case "webhook":
to := strings.TrimSpace(delivery.To)
if to == "" {
return fmt.Errorf("scheduled task delivery.to is required for mode=webhook")
}
parsed, err := url.ParseRequestURI(to)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("scheduled task delivery.to must be an http(s) URL for mode=webhook")
}
return nil
default:
return fmt.Errorf("scheduled task delivery.mode must be none, announce, or webhook")
}
}
func validateScheduledTaskEnvelope(envelope OpenClawConfigEnvelope) error {
format := strings.TrimSpace(envelope.Format)
if format != OpenClawScheduledTaskFormat {
return fmt.Errorf("scheduled task format must be %s", OpenClawScheduledTaskFormat)
}
return ValidateOpenClawCronConfig(envelope.Config)
}
@@ -0,0 +1,179 @@
package services
import (
"encoding/json"
"strings"
"testing"
"clawreef/internal/models"
)
func TestValidateOpenClawCronConfigAcceptsValidVariants(t *testing.T) {
t.Parallel()
cases := []string{
`{"name":"daily","schedule":{"kind":"cron","expr":"0 9 * * *","tz":"Asia/Shanghai"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"brief me"},"delivery":{"mode":"announce","channel":"last","bestEffort":true}}`,
`{"name":"once","schedule":{"kind":"at","at":"2026-07-23T10:00:00Z"},"sessionTarget":"main","wakeMode":"next-heartbeat","payload":{"kind":"systemEvent","text":"ping"},"delivery":{"mode":"none"}}`,
`{"name":"every","enabled":true,"schedule":{"kind":"every","everyMs":60000},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"tick"},"delivery":{"mode":"webhook","to":"https://example.com/hook"}}`,
}
for _, raw := range cases {
if err := ValidateOpenClawCronConfig(json.RawMessage(raw)); err != nil {
t.Fatalf("ValidateOpenClawCronConfig(%s) unexpected error: %v", raw, err)
}
}
}
func TestValidateOpenClawCronConfigRejectsInvalidVariants(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantErr string
}{
{
name: "missing name",
raw: `{"name":"","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"x"}}`,
wantErr: "name is required",
},
{
name: "main requires systemEvent",
raw: `{"name":"bad","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"main","wakeMode":"now","payload":{"kind":"agentTurn","message":"x"}}`,
wantErr: "requires sessionTarget=isolated",
},
{
name: "isolated requires agentTurn",
raw: `{"name":"bad","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"systemEvent","text":"x"}}`,
wantErr: "requires sessionTarget=main",
},
{
name: "webhook missing to",
raw: `{"name":"hook","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"x"},"delivery":{"mode":"webhook"}}`,
wantErr: "delivery.to is required",
},
{
name: "everyMs must be positive",
raw: `{"name":"every","schedule":{"kind":"every","everyMs":0},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"x"}}`,
wantErr: "everyMs must be > 0",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := ValidateOpenClawCronConfig(json.RawMessage(tc.raw))
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr)
}
})
}
}
func TestValidateResourceRejectsWrongScheduledTaskFormat(t *testing.T) {
t.Parallel()
svc := &openClawConfigService{}
err := svc.ValidateResource(UpsertOpenClawConfigResourceRequest{
ResourceType: OpenClawConfigResourceTypeScheduledTask,
ResourceKey: "daily-brief",
Name: "Daily Brief",
Enabled: true,
Content: json.RawMessage(`{
"schemaVersion":1,
"kind":"scheduled_task",
"format":"task/default@v1",
"dependsOn":[],
"config":{
"name":"daily",
"schedule":{"kind":"cron","expr":"0 9 * * *"},
"sessionTarget":"isolated",
"wakeMode":"now",
"payload":{"kind":"agentTurn","message":"brief"}
}
}`),
})
if err == nil || !strings.Contains(err.Error(), OpenClawScheduledTaskFormat) {
t.Fatalf("expected format error, got %v", err)
}
}
func TestValidateResourceAcceptsScheduledTaskOpenClawCronFormat(t *testing.T) {
t.Parallel()
svc := &openClawConfigService{}
err := svc.ValidateResource(UpsertOpenClawConfigResourceRequest{
ResourceType: OpenClawConfigResourceTypeScheduledTask,
ResourceKey: "daily-brief",
Name: "Daily Brief",
Enabled: true,
Content: json.RawMessage(`{
"schemaVersion":1,
"kind":"scheduled_task",
"format":"task/openclaw-cron@v1",
"dependsOn":[],
"config":{
"name":"daily",
"schedule":{"kind":"cron","expr":"0 9 * * *","tz":"Asia/Shanghai"},
"sessionTarget":"isolated",
"wakeMode":"now",
"payload":{"kind":"agentTurn","message":"brief"},
"delivery":{"mode":"announce","channel":"last"}
}
}`),
})
if err != nil {
t.Fatalf("ValidateResource() unexpected error: %v", err)
}
}
func TestRenderCompiledOpenClawPayloadIncludesScheduledTasks(t *testing.T) {
t.Parallel()
content := `{"schemaVersion":1,"kind":"scheduled_task","format":"task/openclaw-cron@v1","dependsOn":[],"config":{"name":"daily","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"brief"},"delivery":{"mode":"webhook","to":"https://example.com/h"}}}`
resources := []compiledOpenClawResource{
{
model: models.OpenClawConfigResource{
ID: 42,
ResourceType: OpenClawConfigResourceTypeScheduledTask,
ResourceKey: "daily-brief",
Name: "Daily Brief",
Version: 1,
ContentJSON: content,
},
tags: []string{"ops"},
envelope: OpenClawConfigEnvelope{
SchemaVersion: 1,
Kind: "scheduled_task",
Format: OpenClawScheduledTaskFormat,
Config: json.RawMessage(`{"name":"daily","schedule":{"kind":"cron","expr":"0 9 * * *"},"sessionTarget":"isolated","wakeMode":"now","payload":{"kind":"agentTurn","message":"brief"},"delivery":{"mode":"webhook","to":"https://example.com/h"}}`),
},
},
}
env, _, _, _, err := renderCompiledOpenClawPayload(OpenClawConfigPlan{Mode: OpenClawConfigPlanModeManual}, nil, resources)
if err != nil {
t.Fatalf("renderCompiledOpenClawPayload() error: %v", err)
}
raw, ok := env[OpenClawScheduledTasksEnv]
if !ok || strings.TrimSpace(raw) == "" {
t.Fatalf("expected %s in rendered env", OpenClawScheduledTasksEnv)
}
if !strings.Contains(raw, `"key":"daily-brief"`) {
t.Fatalf("scheduled tasks payload missing resource key: %s", raw)
}
if !strings.Contains(raw, `"mode":"webhook"`) {
t.Fatalf("scheduled tasks payload missing delivery: %s", raw)
}
aliased := runtimeBootstrapEnvValues("hermes", env)
if _, ok := aliased[HermesScheduledTasksEnv]; !ok {
t.Fatalf("expected hermes alias %s", HermesScheduledTasksEnv)
}
if _, ok := aliased[RuntimeScheduledTasksEnv]; !ok {
t.Fatalf("expected runtime alias %s", RuntimeScheduledTasksEnv)
}
}
@@ -53,7 +53,20 @@ func (s *skillRepoStub) GetSkillByUserKey(userID int, skillKey string) (*models.
return nil, nil
}
func (s *skillRepoStub) CreateSkill(*models.Skill) error { return nil }
func (s *skillRepoStub) CreateSkill(skill *models.Skill) error {
if s.skills == nil {
s.skills = map[int]*models.Skill{}
}
if skill.ID == 0 {
skill.ID = len(s.skills) + 1
for s.skills[skill.ID] != nil {
skill.ID++
}
}
copy := *skill
s.skills[skill.ID] = &copy
return nil
}
func (s *skillRepoStub) UpdateSkill(skill *models.Skill) error {
if s.skills == nil {
s.skills = map[int]*models.Skill{}
@@ -68,7 +81,15 @@ func (s *skillRepoStub) DeleteSkill(int) error {
return nil
}
func (s *skillRepoStub) GetBlobByContentHash(string) (*models.SkillBlob, error) { return nil, nil }
func (s *skillRepoStub) GetBlobByContentHash(hash string) (*models.SkillBlob, error) {
for _, blob := range s.blobs {
if blob != nil && blob.ContentHash == hash {
copy := *blob
return &copy, nil
}
}
return nil, nil
}
func (s *skillRepoStub) GetBlobByID(id int) (*models.SkillBlob, error) {
if blob, ok := s.blobs[id]; ok {
@@ -109,15 +130,44 @@ func (s *skillRepoStub) GetVersionByID(id int) (*models.SkillVersion, error) {
return nil, nil
}
func (s *skillRepoStub) GetVersionBySkillAndBlob(int, int) (*models.SkillVersion, error) {
func (s *skillRepoStub) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) {
for _, version := range s.versions {
if version != nil && version.SkillID == skillID && version.BlobID == blobID {
copy := *version
return &copy, nil
}
}
return nil, nil
}
func (s *skillRepoStub) GetLatestVersionBySkillID(int) (*models.SkillVersion, error) {
return nil, nil
func (s *skillRepoStub) GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error) {
var latest *models.SkillVersion
for _, version := range s.versions {
if version == nil || version.SkillID != skillID {
continue
}
if latest == nil || version.VersionNo > latest.VersionNo || (version.VersionNo == latest.VersionNo && version.ID > latest.ID) {
copy := *version
latest = &copy
}
}
return latest, nil
}
func (s *skillRepoStub) CreateVersion(*models.SkillVersion) error { return nil }
func (s *skillRepoStub) CreateVersion(version *models.SkillVersion) error {
if s.versions == nil {
s.versions = map[int]*models.SkillVersion{}
}
if version.ID == 0 {
version.ID = len(s.versions) + 1
for s.versions[version.ID] != nil {
version.ID++
}
}
copy := *version
s.versions[version.ID] = &copy
return nil
}
func (s *skillRepoStub) UpdateVersion(version *models.SkillVersion) error {
if s.versions == nil {
@@ -128,8 +178,14 @@ func (s *skillRepoStub) UpdateVersion(version *models.SkillVersion) error {
return nil
}
func (s *skillRepoStub) ListInstanceSkills(int) ([]models.InstanceSkill, error) {
return nil, nil
func (s *skillRepoStub) ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error) {
items := make([]models.InstanceSkill, 0)
for _, item := range s.instanceSkills {
if item.InstanceID == instanceID {
items = append(items, item)
}
}
return items, nil
}
func (s *skillRepoStub) ListActiveInstanceSkillsBySkillID(skillID int) ([]models.InstanceSkill, error) {
@@ -166,8 +222,36 @@ func (s *skillRepoStub) GetInstanceSkill(instanceID, skillID int) (*models.Insta
}
return nil, nil
}
func (s *skillRepoStub) UpsertInstanceSkill(*models.InstanceSkill) error { return nil }
func (s *skillRepoStub) MarkInstanceSkillRemoved(int, int, time.Time) error { return nil }
func (s *skillRepoStub) UpsertInstanceSkill(item *models.InstanceSkill) error {
for i, existing := range s.instanceSkills {
if existing.InstanceID == item.InstanceID && existing.SkillID == item.SkillID {
copy := *item
if copy.ID == 0 {
copy.ID = existing.ID
}
s.instanceSkills[i] = copy
return nil
}
}
copy := *item
if copy.ID == 0 {
copy.ID = len(s.instanceSkills) + 1
}
s.instanceSkills = append(s.instanceSkills, copy)
return nil
}
func (s *skillRepoStub) MarkInstanceSkillRemoved(instanceID, skillID int, observedAt time.Time) error {
for i := range s.instanceSkills {
item := &s.instanceSkills[i]
if item.InstanceID == instanceID && item.SkillID == skillID {
item.Status = "removed"
item.RemovedAt = &observedAt
item.UpdatedAt = observedAt
return nil
}
}
return nil
}
func (s *skillRepoStub) MarkInstanceSkillRemovedBySkillKey(int, string, time.Time) error {
return nil
}
@@ -825,6 +825,423 @@ func (s *skillService) ListAttachableSkills(actorUserID int, actorRole string) (
return result, nil
}
func isInstanceSkillContentDiverged(observedHash *string, installedHash string) bool {
if observedHash == nil {
return false
}
observed := strings.TrimSpace(*observedHash)
installed := strings.TrimSpace(installedHash)
if observed == "" || installed == "" {
return false
}
return !strings.EqualFold(observed, installed)
}
func (s *skillService) installedContentHashForInstanceSkill(item *models.InstanceSkill) (string, error) {
if item == nil || item.SkillVersionID == nil {
return "", nil
}
version, err := s.repo.GetVersionByID(*item.SkillVersionID)
if err != nil {
return "", err
}
if version == nil {
return "", nil
}
blob, err := s.repo.GetBlobByID(version.BlobID)
if err != nil {
return "", err
}
if blob == nil {
return "", nil
}
return strings.TrimSpace(blob.ContentHash), nil
}
func (s *skillService) requireOwnedInstanceAccess(actorUserID int, actorRole string, instanceID int) (*models.Instance, error) {
instance, err := s.instanceRepo.GetByID(instanceID)
if err != nil {
return nil, err
}
if instance == nil {
return nil, fmt.Errorf("instance not found")
}
if !isAdminRole(actorRole) && instance.UserID != actorUserID {
return nil, fmt.Errorf("access denied")
}
return instance, nil
}
func (s *skillService) allocateUniqueSkillKey(userID int, base string) (string, error) {
base = sanitizeSkillKey(base)
if base == "" {
base = "skill"
}
candidates := []string{base + "-copy"}
for i := 2; i <= 100; i++ {
candidates = append(candidates, fmt.Sprintf("%s-copy-%d", base, i))
}
for _, candidate := range candidates {
existing, err := s.repo.GetSkillByUserKey(userID, candidate)
if err != nil {
return "", err
}
if existing == nil {
return candidate, nil
}
}
return "", fmt.Errorf("unable to allocate unique skill key")
}
func (s *skillService) ensureVersionForSkillBlob(skillID, blobID int, sourceType string) (*models.SkillVersion, error) {
version, err := s.repo.GetVersionBySkillAndBlob(skillID, blobID)
if err != nil {
return nil, err
}
if version != nil {
return version, nil
}
latest, err := s.repo.GetLatestVersionBySkillID(skillID)
if err != nil {
return nil, err
}
versionNo := 1
if latest != nil {
versionNo = latest.VersionNo + 1
}
version = &models.SkillVersion{
SkillID: skillID, BlobID: blobID, VersionNo: versionNo, SourceType: sourceType,
}
if err := s.repo.CreateVersion(version); err != nil {
return nil, err
}
return version, nil
}
func (s *skillService) collectCurrentInstanceSkillBlob(instanceID int, skill *models.Skill, instanceSkill *models.InstanceSkill) (*models.SkillBlob, error) {
instance, err := s.instanceRepo.GetByID(instanceID)
if err != nil {
return nil, err
}
if instance == nil {
return nil, fmt.Errorf("instance not found")
}
if liteInventoryUsesWorkspaceHash(instance) {
workspaceDir := resolveLiteWorkspaceDir(instanceSkill, skill)
if workspaceDir == "" {
return nil, fmt.Errorf("skill_package_pending")
}
dir, contentMD5, err := loadLiteSkillDirectoryFromWorkspace(instance, workspaceDir)
if err != nil {
return nil, err
}
// Pass nil existing blob so diverged content never mutates the installed version blob.
return s.persistDiscoveredSkillPackage(context.Background(), instanceID, dir, contentMD5, nil)
}
observed := ""
if instanceSkill != nil && instanceSkill.ObservedHash != nil {
observed = strings.TrimSpace(*instanceSkill.ObservedHash)
}
if observed != "" {
blob, err := s.repo.GetBlobByContentHash(observed)
if err != nil {
return nil, err
}
if blob != nil && strings.TrimSpace(blob.ObjectKey) != "" {
if blob.LastScanResultID == nil || !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
if err := s.recordScanFromStoredBlob(blob); err != nil {
return nil, err
}
blob, err = s.repo.GetBlobByID(blob.ID)
if err != nil {
return nil, err
}
}
return blob, nil
}
}
payload := map[string]interface{}{
"skill_id": formatExternalSkillID(skill.ID),
"identifier": skill.SkillKey,
"source": instanceSkill.SourceType,
}
if observed != "" {
payload["content_md5"] = observed
}
if instanceSkill != nil && instanceSkill.SkillVersionID != nil {
payload["skill_version"] = formatExternalVersionID(*instanceSkill.SkillVersionID)
}
_ = s.enqueueCollectSkillPackage(instanceID, payload, fmt.Sprintf("collect-skill-package-diverged-%d-%d-%d", instanceID, skill.ID, time.Now().Unix()))
return nil, fmt.Errorf("skill_package_pending")
}
func (s *skillService) rebindInstanceSkill(instanceID, oldSkillID, newSkillID int, versionID *int, observedHash string) error {
now := time.Now().UTC()
oldItem, err := s.repo.GetInstanceSkill(instanceID, oldSkillID)
if err != nil {
return err
}
if oldItem == nil || oldItem.Status == "removed" {
return fmt.Errorf("skill not found on instance")
}
newItem := &models.InstanceSkill{
InstanceID: instanceID, SkillID: newSkillID, SkillVersionID: versionID,
SourceType: "injected_by_clawmanager", InstallPath: oldItem.InstallPath, WorkspaceDir: oldItem.WorkspaceDir,
ObservedHash: optionalString(observedHash), Status: "active", LastSeenAt: &now, UpdatedAt: now, RemovedAt: nil,
}
if err := s.repo.UpsertInstanceSkill(newItem); err != nil {
return err
}
if oldSkillID != newSkillID {
if err := s.repo.MarkInstanceSkillRemoved(instanceID, oldSkillID, now); err != nil {
return err
}
}
return nil
}
func (s *skillService) RestoreInstanceSkill(actorUserID int, actorRole string, instanceID, skillID int) (*InstanceSkillPayload, error) {
instance, err := s.requireOwnedInstanceAccess(actorUserID, actorRole, instanceID)
if err != nil {
return nil, err
}
if err := EnsureInstanceWorkspacePathForServerScan(context.Background(), s.instanceRepo, instance); err != nil {
return nil, err
}
skill, err := s.repo.GetSkillByID(skillID)
if err != nil {
return nil, err
}
if skill == nil || isDeletedSkill(skill) {
return nil, fmt.Errorf("skill not found")
}
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
if err != nil {
return nil, err
}
if instanceSkill == nil || instanceSkill.Status == "removed" {
return nil, fmt.Errorf("skill not found on instance")
}
if instanceSkill.SkillVersionID == nil {
return nil, fmt.Errorf("skill version not found")
}
version, err := s.repo.GetVersionByID(*instanceSkill.SkillVersionID)
if err != nil {
return nil, err
}
if version == nil {
return nil, fmt.Errorf("skill version not found")
}
blob, err := s.repo.GetBlobByID(version.BlobID)
if err != nil {
return nil, err
}
if blob == nil || strings.TrimSpace(blob.ObjectKey) == "" {
return nil, fmt.Errorf("skill blob not found")
}
if err := s.materializeLiteInstanceSkill(context.Background(), instanceID, skill, blob); err != nil {
return nil, err
}
now := time.Now().UTC()
contentHash := strings.TrimSpace(blob.ContentHash)
instanceSkill.SkillVersionID = &version.ID
instanceSkill.ObservedHash = optionalString(contentHash)
instanceSkill.Status = "active"
instanceSkill.LastSeenAt = &now
instanceSkill.UpdatedAt = now
instanceSkill.RemovedAt = nil
if err := s.repo.UpsertInstanceSkill(instanceSkill); err != nil {
return nil, err
}
if _, err := s.commandService.Create(instanceID, nil, CreateInstanceCommandRequest{
CommandType: InstanceCommandTypeInstallSkill,
Payload: map[string]interface{}{
"skill_id": formatExternalSkillID(skillID),
"skill_version": formatExternalVersionID(version.ID),
"target_name": skill.SkillKey,
"content_md5": s.resolveContentMD5(blob),
},
IdempotencyKey: fmt.Sprintf("restore-skill-%d-%d-%d", instanceID, skillID, now.UnixNano()),
TimeoutSeconds: 300,
}); err != nil {
return nil, fmt.Errorf("failed to queue restore skill command: %w", err)
}
items, err := s.ListInstanceSkills(instanceID)
if err != nil {
return nil, err
}
for _, candidate := range items {
if candidate.SkillID == skillID {
return &candidate, nil
}
}
return nil, fmt.Errorf("instance skill not found after restore")
}
func (s *skillService) SaveBackInstanceSkillToLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error) {
if _, err := s.requireOwnedInstanceAccess(actorUserID, actorRole, instanceID); err != nil {
return nil, err
}
skill, err := s.repo.GetSkillByID(skillID)
if err != nil {
return nil, err
}
if skill == nil || isDeletedSkill(skill) {
return nil, fmt.Errorf("skill not found")
}
if skill.UserID != actorUserID && !isAdminRole(actorRole) {
return nil, fmt.Errorf("access denied")
}
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
if err != nil {
return nil, err
}
if instanceSkill == nil || instanceSkill.Status == "removed" {
return nil, fmt.Errorf("skill not found on instance")
}
blob, err := s.collectCurrentInstanceSkillBlob(instanceID, skill, instanceSkill)
if err != nil {
return nil, err
}
if blob == nil {
return nil, fmt.Errorf("skill_package_pending")
}
version, err := s.ensureVersionForSkillBlob(skill.ID, blob.ID, skillSourceUploaded)
if err != nil {
return nil, err
}
now := time.Now().UTC()
skill.CurrentVersionID = &version.ID
skill.SourceType = skillSourceUploaded
skill.Visibility = skillVisibilityPrivate
skill.RiskLevel = blob.RiskLevel
skill.LastScannedAt = blob.LastScannedAt
skill.LastScanResultID = blob.LastScanResultID
skill.UpdatedAt = now
if err := s.repo.UpdateSkill(skill); err != nil {
return nil, err
}
version.SourceType = skillSourceUploaded
version.UpdatedAt = now
if err := s.repo.UpdateVersion(version); err != nil {
return nil, err
}
if err := s.rebindInstanceSkill(instanceID, skillID, skillID, &version.ID, blob.ContentHash); err != nil {
return nil, err
}
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
}
func (s *skillService) SaveForeignInstanceSkillToMyLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error) {
if _, err := s.requireOwnedInstanceAccess(actorUserID, actorRole, instanceID); err != nil {
return nil, err
}
skill, err := s.repo.GetSkillByID(skillID)
if err != nil {
return nil, err
}
if skill == nil || isDeletedSkill(skill) {
return nil, fmt.Errorf("skill not found")
}
if skill.UserID == actorUserID {
return nil, fmt.Errorf("access denied")
}
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
if err != nil {
return nil, err
}
if instanceSkill == nil || instanceSkill.Status == "removed" {
return nil, fmt.Errorf("skill not found on instance")
}
blob, err := s.collectCurrentInstanceSkillBlob(instanceID, skill, instanceSkill)
if err != nil {
return nil, err
}
if blob == nil {
return nil, fmt.Errorf("skill_package_pending")
}
newKey, err := s.allocateUniqueSkillKey(actorUserID, skill.SkillKey)
if err != nil {
return nil, err
}
description := skill.Description
newSkill := &models.Skill{
UserID: actorUserID, SkillKey: newKey, Name: skill.Name, Description: description,
SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPrivate,
RiskLevel: blob.RiskLevel, LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID,
}
if err := s.repo.CreateSkill(newSkill); err != nil {
return nil, err
}
version, err := s.ensureVersionForSkillBlob(newSkill.ID, blob.ID, skillSourceUploaded)
if err != nil {
return nil, err
}
now := time.Now().UTC()
newSkill.CurrentVersionID = &version.ID
newSkill.UpdatedAt = now
if err := s.repo.UpdateSkill(newSkill); err != nil {
return nil, err
}
if err := s.rebindInstanceSkill(instanceID, skillID, newSkill.ID, &version.ID, blob.ContentHash); err != nil {
return nil, err
}
return s.GetSkillHubDetail(actorUserID, actorRole, newSkill.ID)
}
func (s *skillService) PublishSkillAsNew(actorUserID int, actorRole string, skillID int, tagIDs []int) (*SkillPayload, error) {
skill, err := s.repo.GetSkillByID(skillID)
if err != nil {
return nil, err
}
if skill == nil || isDeletedSkill(skill) {
return nil, fmt.Errorf("skill not found")
}
if skill.UserID != actorUserID && !isAdminRole(actorRole) {
return nil, fmt.Errorf("skill not found")
}
blob, err := s.skillBlobForPublish(skill)
if err != nil {
return nil, err
}
if strings.TrimSpace(blob.ObjectKey) == "" {
return nil, fmt.Errorf("skill_package_pending")
}
if !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
return nil, fmt.Errorf("skill_not_scanned")
}
if !isHubPublishableBlob(blob) {
return nil, fmt.Errorf("skill_risk_blocked")
}
newKey, err := s.allocateUniqueSkillKey(actorUserID, skill.SkillKey)
if err != nil {
return nil, err
}
newSkill := &models.Skill{
UserID: actorUserID, SkillKey: newKey, Name: skill.Name, Description: skill.Description,
SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPrivate,
RiskLevel: blob.RiskLevel, LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID,
}
if err := s.repo.CreateSkill(newSkill); err != nil {
return nil, err
}
version, err := s.ensureVersionForSkillBlob(newSkill.ID, blob.ID, skillSourceUploaded)
if err != nil {
return nil, err
}
now := time.Now().UTC()
newSkill.CurrentVersionID = &version.ID
newSkill.UpdatedAt = now
if err := s.repo.UpdateSkill(newSkill); err != nil {
return nil, err
}
return s.PublishToHub(actorUserID, actorRole, newSkill.ID, tagIDs)
}
func (s *skillService) ImportHubArchive(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillPayload, error) {
items, err := s.ImportHubArchiveWithDecisions(ctx, userID, fileHeader, nil)
if err != nil {
@@ -955,3 +955,249 @@ func TestDownloadSkillNilSafe(t *testing.T) {
t.Fatal("expected error when blob is missing")
}
}
func TestIsInstanceSkillContentDiverged(t *testing.T) {
hash := "abc"
if isInstanceSkillContentDiverged(nil, hash) {
t.Fatal("nil observed should not diverge")
}
same := "abc"
if isInstanceSkillContentDiverged(&same, hash) {
t.Fatal("equal hashes should not diverge")
}
other := "def"
if !isInstanceSkillContentDiverged(&other, hash) {
t.Fatal("different hashes should diverge")
}
}
func TestSaveBackInstanceSkillToLibrarySetsPrivateAndNewVersion(t *testing.T) {
versionID := 10
blobID := 20
newBlobID := 30
scanID := 99
publishedAt := time.Now().UTC().Add(-time.Hour)
oldHash := "oldhash000000000000000000000001"
newHash := "newhash000000000000000000000001"
stub := &skillRepoStub{
skills: map[int]*models.Skill{
1: {
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
PublishedAt: &publishedAt, PublishedBy: skillTestIntPtr(1),
},
},
versions: map[int]*models.SkillVersion{
versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 1, SourceType: skillSourceUploaded},
},
blobs: map[int]*models.SkillBlob{
blobID: {
ID: blobID, ContentHash: oldHash, ObjectKey: "user/1/demo/old.zip",
ScanStatus: "completed", RiskLevel: skillRiskNone, LastScanResultID: &scanID,
},
newBlobID: {
ID: newBlobID, ContentHash: newHash, ObjectKey: "user/1/demo/new.zip",
ScanStatus: "completed", RiskLevel: skillRiskNone, LastScanResultID: &scanID,
},
},
instanceSkills: []models.InstanceSkill{{
InstanceID: 1, SkillID: 1, SkillVersionID: &versionID, Status: "active",
SourceType: "injected_by_clawmanager", ObservedHash: &newHash,
}},
tagAssignments: map[int][]int{},
}
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
payload, err := svc.SaveBackInstanceSkillToLibrary(1, "user", 1, 1)
if err != nil {
t.Fatalf("SaveBackInstanceSkillToLibrary() error = %v", err)
}
if stub.skills[1].Visibility != skillVisibilityPrivate {
t.Fatalf("visibility = %q, want private", stub.skills[1].Visibility)
}
if stub.skills[1].CurrentVersionID == nil || *stub.skills[1].CurrentVersionID == versionID {
t.Fatalf("current_version_id = %v, want new version", stub.skills[1].CurrentVersionID)
}
newVersion := stub.versions[*stub.skills[1].CurrentVersionID]
if newVersion == nil || newVersion.BlobID != newBlobID {
t.Fatalf("new version = %#v, want blob %d", newVersion, newBlobID)
}
if payload == nil || payload.Visibility != skillVisibilityPrivate {
t.Fatalf("payload visibility = %v", payload)
}
}
func TestSaveForeignInstanceSkillToMyLibraryForksPrivateSkill(t *testing.T) {
versionID := 10
blobID := 20
newBlobID := 30
scanID := 99
oldHash := "oldhash000000000000000000000002"
newHash := "newhash000000000000000000000002"
stub := &skillRepoStub{
skills: map[int]*models.Skill{
1: {
ID: 1, UserID: 9, SkillKey: "shared", Name: "Shared", Status: skillStatusActive,
SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
},
},
versions: map[int]*models.SkillVersion{
versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 1, SourceType: skillSourceUploaded},
},
blobs: map[int]*models.SkillBlob{
blobID: {
ID: blobID, ContentHash: oldHash, ObjectKey: "user/9/shared/old.zip",
ScanStatus: "completed", RiskLevel: skillRiskNone, LastScanResultID: &scanID,
},
newBlobID: {
ID: newBlobID, ContentHash: newHash, ObjectKey: "user/1/shared/new.zip",
ScanStatus: "completed", RiskLevel: skillRiskNone, LastScanResultID: &scanID,
},
},
instanceSkills: []models.InstanceSkill{{
InstanceID: 1, SkillID: 1, SkillVersionID: &versionID, Status: "active",
SourceType: "injected_by_clawmanager", ObservedHash: &newHash,
}},
tagAssignments: map[int][]int{},
}
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
payload, err := svc.SaveForeignInstanceSkillToMyLibrary(1, "user", 1, 1)
if err != nil {
t.Fatalf("SaveForeignInstanceSkillToMyLibrary() error = %v", err)
}
if stub.skills[1].Visibility != skillVisibilityPublic || stub.skills[1].UserID != 9 {
t.Fatalf("original skill mutated: %#v", stub.skills[1])
}
if payload == nil || payload.UserID != 1 || payload.Visibility != skillVisibilityPrivate {
t.Fatalf("forked payload = %#v", payload)
}
var removedOld bool
var boundNew bool
for _, item := range stub.instanceSkills {
if item.SkillID == 1 && item.Status == "removed" {
removedOld = true
}
if item.SkillID == payload.ID && item.Status == "active" {
boundNew = true
}
}
if !removedOld || !boundNew {
t.Fatalf("instance rebind failed: removedOld=%v boundNew=%v items=%#v", removedOld, boundNew, stub.instanceSkills)
}
}
func TestSaveForeignInstanceSkillToMyLibraryRejectsOwner(t *testing.T) {
versionID := 10
blobID := 20
stub := &skillRepoStub{
skills: map[int]*models.Skill{
1: {
ID: 1, UserID: 1, SkillKey: "mine", Name: "Mine", Status: skillStatusActive,
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
},
},
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ContentHash: "h", ObjectKey: "k", ScanStatus: "completed", RiskLevel: skillRiskNone}},
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "injected_by_clawmanager"}},
}
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
_, err := svc.SaveForeignInstanceSkillToMyLibrary(1, "user", 1, 1)
if err == nil || err.Error() != "access denied" {
t.Fatalf("expected access denied, got %v", err)
}
}
func TestPublishSkillAsNewKeepsOriginalPrivate(t *testing.T) {
versionID := 10
blobID := 20
scanID := 99
stub := &skillRepoStub{
skills: map[int]*models.Skill{
1: {
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
},
},
versions: map[int]*models.SkillVersion{
versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 1, SourceType: skillSourceUploaded},
},
blobs: map[int]*models.SkillBlob{
blobID: {
ID: blobID, ContentHash: "hash", ObjectKey: "user/1/demo.zip",
ScanStatus: "completed", RiskLevel: skillRiskNone, LastScanResultID: &scanID,
},
},
tags: map[int]*models.SkillHubTag{
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
},
tagAssignments: map[int][]int{},
}
svc := &skillService{repo: stub}
payload, err := svc.PublishSkillAsNew(1, "user", 1, []int{1})
if err != nil {
t.Fatalf("PublishSkillAsNew() error = %v", err)
}
if stub.skills[1].Visibility != skillVisibilityPrivate {
t.Fatalf("original visibility = %q, want private", stub.skills[1].Visibility)
}
if payload == nil || payload.ID == 1 || payload.Visibility != skillVisibilityPublic {
t.Fatalf("new skill payload = %#v", payload)
}
if stub.skills[payload.ID] == nil || stub.skills[payload.ID].Visibility != skillVisibilityPublic {
t.Fatalf("new skill not public in repo: %#v", stub.skills[payload.ID])
}
}
func TestRestoreInstanceSkillUsesLockedVersion(t *testing.T) {
versionID := 10
currentVersionID := 11
blobID := 20
currentBlobID := 21
oldHash := "oldhash000000000000000000000003"
newHash := "newhash000000000000000000000003"
observed := newHash
stub := &skillRepoStub{
skills: map[int]*models.Skill{
1: {
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, CurrentVersionID: &currentVersionID,
},
},
versions: map[int]*models.SkillVersion{
versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 1},
currentVersionID: {ID: currentVersionID, SkillID: 1, BlobID: currentBlobID, VersionNo: 2},
},
blobs: map[int]*models.SkillBlob{
blobID: {ID: blobID, ContentHash: oldHash, ObjectKey: "user/1/old.zip", ScanStatus: "completed", RiskLevel: skillRiskNone},
currentBlobID: {ID: currentBlobID, ContentHash: "hub-latest", ObjectKey: "user/1/latest.zip", ScanStatus: "completed", RiskLevel: skillRiskNone},
},
instanceSkills: []models.InstanceSkill{{
InstanceID: 1, SkillID: 1, SkillVersionID: &versionID, Status: "active",
SourceType: "injected_by_clawmanager", ObservedHash: &observed,
}},
tagAssignments: map[int][]int{},
}
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
cmdSvc := &capturingInstanceCommandService{}
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: cmdSvc}
item, err := svc.RestoreInstanceSkill(1, "user", 1, 1)
if err != nil {
t.Fatalf("RestoreInstanceSkill() error = %v", err)
}
if stub.skills[1].CurrentVersionID == nil || *stub.skills[1].CurrentVersionID != currentVersionID {
t.Fatalf("hub current version changed: %v", stub.skills[1].CurrentVersionID)
}
if item.ObservedHash == nil || *item.ObservedHash != oldHash {
t.Fatalf("observed hash = %v, want locked %q", item.ObservedHash, oldHash)
}
if item.ContentDiverged {
t.Fatalf("content_diverged should be false after restore, got %v", item.ContentDiverged)
}
if len(cmdSvc.created) == 0 || cmdSvc.created[0].CommandType != InstanceCommandTypeInstallSkill {
t.Fatalf("expected install_skill command, got %#v", cmdSvc.created)
}
}
func skillTestIntPtr(v int) *int { return &v }
+24 -13
View File
@@ -107,19 +107,21 @@ type SkillVersionPayload struct {
}
type InstanceSkillPayload struct {
ID int `json:"id"`
InstanceID int `json:"instance_id"`
SkillID int `json:"skill_id"`
SkillVersionID *int `json:"skill_version_id,omitempty"`
SourceType string `json:"source_type"`
InstallPath *string `json:"install_path,omitempty"`
WorkspaceDir *string `json:"workspace_dir,omitempty"`
ObservedHash *string `json:"observed_hash,omitempty"`
ContentMD5 *string `json:"content_md5,omitempty"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
RemovedAt *time.Time `json:"removed_at,omitempty"`
Skill *SkillPayload `json:"skill,omitempty"`
ID int `json:"id"`
InstanceID int `json:"instance_id"`
SkillID int `json:"skill_id"`
SkillVersionID *int `json:"skill_version_id,omitempty"`
SourceType string `json:"source_type"`
InstallPath *string `json:"install_path,omitempty"`
WorkspaceDir *string `json:"workspace_dir,omitempty"`
ObservedHash *string `json:"observed_hash,omitempty"`
ContentMD5 *string `json:"content_md5,omitempty"`
InstalledContentHash *string `json:"installed_content_hash,omitempty"`
ContentDiverged bool `json:"content_diverged"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
RemovedAt *time.Time `json:"removed_at,omitempty"`
Skill *SkillPayload `json:"skill,omitempty"`
}
type SkillScanResultPayload struct {
@@ -204,6 +206,10 @@ type SkillService interface {
BatchInstallHubSkill(actorUserID int, actorRole string, skillID int, instanceIDs []int) []BatchInstallHubSkillResult
PublishFromInstance(actorUserID int, actorRole string, instanceID, skillID int, tagIDs []int) (*SkillPayload, error)
ImportInstanceSkillToLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error)
RestoreInstanceSkill(actorUserID int, actorRole string, instanceID, skillID int) (*InstanceSkillPayload, error)
SaveBackInstanceSkillToLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error)
SaveForeignInstanceSkillToMyLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error)
PublishSkillAsNew(actorUserID int, actorRole string, skillID int, tagIDs []int) (*SkillPayload, error)
RetrySkillPackageCollection(actorUserID int, actorRole string, instanceID, skillID int) error
ListAttachableSkills(actorUserID int, actorRole string) ([]SkillPayload, error)
ImportHubArchive(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillPayload, error)
@@ -481,9 +487,14 @@ func (s *skillService) ListInstanceSkills(instanceID int) ([]InstanceSkillPayloa
if isRemovedInstanceSkill(&item) {
continue
}
installedHash, err := s.installedContentHashForInstanceSkill(&item)
if err != nil {
return nil, err
}
payload := InstanceSkillPayload{
ID: item.ID, InstanceID: item.InstanceID, SkillID: item.SkillID, SkillVersionID: item.SkillVersionID,
SourceType: item.SourceType, InstallPath: item.InstallPath, WorkspaceDir: item.WorkspaceDir, ObservedHash: item.ObservedHash,
InstalledContentHash: optionalString(installedHash), ContentDiverged: isInstanceSkillContentDiverged(item.ObservedHash, installedHash),
Status: item.Status, LastSeenAt: item.LastSeenAt, RemovedAt: item.RemovedAt,
}
skill, err := s.repo.GetSkillByID(item.SkillID)
+9 -1
View File
@@ -147,7 +147,15 @@ At startup, read bootstrap payloads in this priority order. Use the first non-em
| Agents | `CLAWMANAGER_HERMES_AGENTS_JSON` | `CLAWMANAGER_RUNTIME_AGENTS_JSON`, `CLAWMANAGER_OPENCLAW_AGENTS_JSON` |
| Scheduled Tasks | `CLAWMANAGER_HERMES_SCHEDULED_TASKS_JSON` | `CLAWMANAGER_RUNTIME_SCHEDULED_TASKS_JSON`, `CLAWMANAGER_OPENCLAW_SCHEDULED_TASKS_JSON` |
If a variable is missing or empty, treat it as an empty config. Do not fail agent startup for missing optional bootstrap payloads. If a variable exists but contains invalid JSON, log a clear error and report `health.bootstrap_config` or `health.config_loader` as `error` in the next state report.
Scheduled Task resources use the OpenClaw cron benchmark (`schedule` + `payload` + `delivery`, format `task/openclaw-cron@v1`). At Hermes startup the payload is **translated** into native Hermes cron jobs under `~/.hermes/cron/jobs.json` (managed ids `cm-st-*`) and executed by the gateway built-in cron. `delivery.mode=announce` maps to Hermes `deliver` targets; `webhook` maps to `deliver=local`, appends a required HTTP POST instruction to the prompt, and writes `cron/webhooks/cm-st-*.url`.
Hermes notes:
- Apply is soft-fail: parse/translate errors are recorded in bootstrap `scheduled-tasks` state and do not abort startup.
- Identical payload sha256 + managed job count skips rewriting `jobs.json`.
- OpenClaw fields `wakeMode` and `sessionTarget` are intentionally ignored on Hermes (`ignored_fields`); Hermes cron always uses a fresh agent session. Keep those fields in the platform resource for OpenClaw compatibility.
If a variable is missing or empty, treat it as an empty config. Do not fail agent startup for missing optional bootstrap payloads. If a variable exists but contains invalid JSON, agent should record a clear error and report `health.bootstrap_config` or `health.config_loader` as `error` in the next state report.
Recommended local bootstrap state:
+16 -3
View File
@@ -7,22 +7,35 @@ Resource Management is the reusable asset layer for OpenClaw workspaces in ClawM
- `Channels` for workspace connectivity and integration templates
- `Skills` for reusable uploaded packages that can be installed into runtime instances
- Config skills for bootstrap configuration delivered through runtime environment payloads
- `Scheduled tasks` (`scheduled_task`) for OpenClaw-compatible cron jobs (`schedule` + `payload` + `delivery`) injected at instance create/start
- `Bundles` for composing repeatable resource sets, including both config resources and uploaded skills
- injection snapshots for tracking the compiled result applied to an instance
## Scheduled Task Bootstrap
- Platform format: `task/openclaw-cron@v1`
- Acceptance benchmark: OpenClaw cron `schedule` / `payload` / `delivery` (including announce and webhook)
- Compiled env: `CLAWMANAGER_OPENCLAW_SCHEDULED_TASKS_JSON` (Hermes also receives `CLAWMANAGER_HERMES_*` / `CLAWMANAGER_RUNTIME_*` aliases)
- OpenClaw Lite/Pro: managed jobs are upserted into `~/.openclaw/cron/jobs.json` with ids `cm-st-{resource_id}`
- Hermes Lite/Pro: the same OpenClaw-benchmark config is **translated** into Hermes native cron jobs under `~/.hermes/cron/jobs.json`, then executed by Hermes gateway cron
- Managed jobs never delete user-created cron entries
- Runtime apply is soft-fail: invalid bootstrap JSON is logged and skipped; instance/gateway startup continues
- Identical payload hash + managed id set skips rewriting the cron store
- Hermes translation ignores OpenClaw-only `wakeMode` / `sessionTarget` (recorded as `ignored_fields`); Hermes always runs fresh agent sessions
## Core Workflows
1. Create or import channels and skills in the OpenClaw Config Center.
1. Create or import channels, skills, and scheduled tasks in the OpenClaw Config Center.
2. Organize selected config resources and uploaded skills into reusable bundles.
3. Review scan posture for skills through Security Center.
4. Apply resources or bundles to OpenClaw workspaces.
4. Apply resources or bundles to OpenClaw/Hermes workspaces at instance creation.
5. Inspect runtime state and instance-level resource results after injection.
## How It Connects to the Platform
- Resource Management defines what should be delivered to a workspace.
- Config resources are compiled into bootstrap environment payloads. Uploaded skills in a bundle are installed through the Agent Control Plane skill installation path.
- Agent Control Plane applies and tracks those changes at runtime.
- Agent Control Plane / runtime agents apply and track those changes at runtime.
- Security Center and `skill-scanner` help review the risk posture of reusable skills before broad rollout.
## Related Guides
@@ -0,0 +1,30 @@
# Scheduled Task Bootstrap Acceptance
Manual checklist after rebuilding ClawManager + OpenClaw/Hermes runtime images.
## Shared setup
1. In Resource Management, create a `scheduled_task` with format `task/openclaw-cron@v1`.
2. Configure one announce job (`delivery.mode=announce`) and one webhook job (`delivery.mode=webhook` with an http(s) URL).
3. Create instances with those resources selected (manual or bundle).
## Matrix
For each of: OpenClaw Lite, OpenClaw Pro, Hermes Lite, Hermes Pro:
1. Instance reaches running and agent/gateway is healthy.
2. Managed jobs appear with ids `cm-st-{resource_id}`:
- OpenClaw: `~/.openclaw/cron/jobs.json`
- Hermes: `~/.hermes/cron/jobs.json`
- **Owner must be the instance Linux UID/GID (`200000 + instance_id`), not `root`.** A `root:root` `jobs.json` causes Control UI cron pages to fail with `EACCES`.
3. Announce job fires and delivers to the configured channel/origin.
4. Webhook job fires and POSTs (OpenClaw native delivery, or Hermes prompt+local outbox/`cron/webhooks/*.url` path).
5. Restart instance / recreate Lite gateway:
- identical bootstrap payload skips rewrite (or remains idempotent)
- user-created non-`cm-st-*` cron jobs remain
6. Invalid scheduled-tasks env (if injected manually) logs an error and does **not** prevent startup.
## Notes
- Hermes ignores OpenClaw `wakeMode` / `sessionTarget`; expect `ignored_fields` in bootstrap state.
- Platform save validation remains strict; only runtime apply is soft-fail.
+1 -1
View File
@@ -623,8 +623,8 @@ AI 审计页面用于查看最近的受管模型调用记录,帮助管理员
- **通道**
- **技能**
- **定时任务**
- **智能体(即将上线)**
- **定时任务(即将上线)**
页面右上角支持:
+1 -1
View File
@@ -619,8 +619,8 @@ On the left side of the Resource Management page, you can also manage resources
- **Channels**
- **Skills**
- **Scheduled Tasks**
- **Agents (coming soon)**
- **Scheduled Tasks (coming soon)**
The upper-right corner of the page supports:
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useI18n } from "../contexts/I18nContext";
@@ -14,7 +14,7 @@ type Props = {
children: React.ReactNode;
};
function readStoredCollapsed(storageKey: string, defaultCollapsed: boolean): boolean {
export function readStoredCollapsed(storageKey: string, defaultCollapsed: boolean): boolean {
try {
const stored = localStorage.getItem(storageKey);
if (stored === "true") {
@@ -29,6 +29,13 @@ function readStoredCollapsed(storageKey: string, defaultCollapsed: boolean): boo
return defaultCollapsed;
}
export function instancePanelStorageKey(
panel: "skills" | "session-usage",
instanceId: number,
): string {
return `clawmanager.instance-panel.${panel}.${instanceId}`;
}
export default function InstanceCollapsiblePanel({
storageKey,
title,
@@ -42,6 +49,7 @@ export default function InstanceCollapsiblePanel({
}: Props) {
const { t } = useI18n();
const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(storageKey, defaultCollapsed));
const skipNextPersistRef = useRef(false);
const toggle = () => {
setCollapsed((current) => {
@@ -51,11 +59,20 @@ export default function InstanceCollapsiblePanel({
});
};
useEffect(() => {
skipNextPersistRef.current = true;
setCollapsed(readStoredCollapsed(storageKey, defaultCollapsed));
}, [storageKey, defaultCollapsed]);
useEffect(() => {
onExpandedChange?.(!collapsed);
}, [collapsed, onExpandedChange]);
useEffect(() => {
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
return;
}
try {
localStorage.setItem(storageKey, String(collapsed));
} catch {
@@ -1,6 +1,7 @@
import { Maximize2, Minimize2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useInstanceDesktopAccess } from "../hooks/useInstanceDesktopAccess";
import { clearHermesDashboardStorage, prepareHermesDashboardStorage } from "../lib/hermesDashboardStorage";
import { prepareOpenClawControlUIStorage } from "../lib/openclawControlStorage";
import type { InstanceAvailability } from "../types/instance";
@@ -44,6 +45,8 @@ export function InstanceServiceFrame({
const frameContainerRef = useRef<HTMLElement | null>(null);
const [preparedFrame, setPreparedFrame] = useState<PreparedFrame | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const normalizedType = instanceType?.toLowerCase() ?? "";
const isHermes = normalizedType === "hermes";
const {
embedUrl,
loading,
@@ -82,12 +85,23 @@ export function InstanceServiceFrame({
return;
}
const src =
instanceType?.toLowerCase() === "openclaw"
? prepareOpenClawControlUIStorage(instanceId, embedUrl)
: embedUrl;
let src = embedUrl;
if (normalizedType === "openclaw") {
src = prepareOpenClawControlUIStorage(instanceId, embedUrl);
} else if (isHermes) {
src = prepareHermesDashboardStorage(instanceId, embedUrl);
}
setPreparedFrame({ instanceId, embedUrl, src });
}, [embedUrl, instanceId, instanceType]);
}, [embedUrl, instanceId, isHermes, normalizedType]);
useEffect(() => {
if (!isHermes) {
return;
}
return () => {
clearHermesDashboardStorage();
};
}, [isHermes, instanceId]);
useEffect(() => {
const handleChange = () => {
@@ -164,6 +178,7 @@ export function InstanceServiceFrame({
return renderFrameShell(
<iframe
key={isHermes ? `hermes-${instanceId}` : `frame-${instanceId}`}
title={`${instanceName} service`}
src={frameSrc}
className="min-h-0 w-full flex-1 border-0 bg-white"
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { BarChart3, ChevronDown, ChevronUp, Download, RefreshCw, Search } from "lucide-react";
import { useI18n } from "../contexts/I18nContext";
import InstanceCollapsiblePanel from "./InstanceCollapsiblePanel";
import InstanceCollapsiblePanel, { instancePanelStorageKey } from "./InstanceCollapsiblePanel";
import { instanceService } from "../services/instanceService";
import type {
InstanceSessionUsageDetail,
@@ -225,7 +225,7 @@ export default function InstanceSessionUsagePanel({
return (
<InstanceCollapsiblePanel
storageKey={`clawmanager.instance-panel.session-usage.${instanceId}`}
storageKey={instancePanelStorageKey("session-usage", instanceId)}
title={t("instances.sessionUsage.title")}
icon={<BarChart3 className="h-4 w-4 text-indigo-600" />}
summary={sessionPanelSummary}
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { KeyRound, Plus, X } from "lucide-react";
import { useAuth } from "../contexts/AuthContext";
import { useI18n } from "../contexts/I18nContext";
import InstanceCollapsiblePanel from "./InstanceCollapsiblePanel";
import InstanceCollapsiblePanel, { instancePanelStorageKey } from "./InstanceCollapsiblePanel";
import { instanceService } from "../services/instanceService";
import { skillHubService } from "../services/skillHubService";
import { skillService } from "../services/skillService";
@@ -125,6 +125,9 @@ type InstanceSkillCardProps = {
onImportToLibrary: (skillId: number) => void;
onRetryPackageCollect: (skillId: number) => void;
onPublish: (skillId: number) => void;
onRestore: (skillId: number) => void;
onSaveBackToLibrary: (skillId: number) => void;
onSaveToMyLibrary: (skillId: number) => void;
onRemove: (skillId: number) => void;
shouldShowImportToLibrary: (skill: Skill) => boolean;
isSkillPackagePending: (skill?: Skill) => boolean;
@@ -143,6 +146,9 @@ function InstanceSkillCard({
onImportToLibrary,
onRetryPackageCollect,
onPublish,
onRestore,
onSaveBackToLibrary,
onSaveToMyLibrary,
onRemove,
shouldShowImportToLibrary,
isSkillPackagePending,
@@ -154,6 +160,9 @@ function InstanceSkillCard({
resolveInstanceSkillProvenance(item) === "hub_installed"
? t("instances.skillProvenanceInjected")
: t("instances.skillProvenanceNative");
const contentDiverged = Boolean(item.content_diverged);
const isLibraryOwner = Boolean(item.skill && userId != null && item.skill.user_id === userId);
const riskLevel = contentDiverged ? "unknown" : item.skill?.risk_level;
return (
<div className="rounded-md border border-slate-200 px-3 py-3">
@@ -167,8 +176,13 @@ function InstanceSkillCard({
{provenance}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600">
{skillRiskLabel(t, item.skill?.risk_level)}
{skillRiskLabel(t, riskLevel)}
</span>
{contentDiverged ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800">
{t("skillHubPage.contentDiverged")}
</span>
) : null}
{isSkillPackagePending(item.skill) ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-800">
{t("instances.skillPackageSyncing")}
@@ -186,6 +200,9 @@ function InstanceSkillCard({
? ` · ${t("instances.lastSeenAt", { value: formatDateTime(item.last_seen_at, locale) })}`
: ""}
</p>
{contentDiverged ? (
<p className="mt-1 text-xs text-amber-800">{t("skillHubPage.contentDivergedHint")}</p>
) : null}
{item.skill?.package_collect_error ? (
<p className="mt-1 text-xs text-amber-800">{item.skill.package_collect_error}</p>
) : null}
@@ -206,7 +223,45 @@ function InstanceSkillCard({
: t("instances.retryPackageCollect")}
</button>
) : null}
{allowImportToLibrary &&
{contentDiverged ? (
<>
<button
type="button"
onClick={() => onRestore(item.skill_id)}
disabled={actionLoading === `restore-skill-${item.skill_id}`}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t("skillHubPage.restoreInstanceSkill")}
</button>
{isLibraryOwner ? (
<button
type="button"
onClick={() => onSaveBackToLibrary(item.skill_id)}
disabled={
actionLoading === `save-back-${item.skill_id}` ||
isSkillPackagePending(item.skill)
}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t("skillHubPage.saveBackToLibrary")}
</button>
) : (
<button
type="button"
onClick={() => onSaveToMyLibrary(item.skill_id)}
disabled={
actionLoading === `save-mine-${item.skill_id}` ||
isSkillPackagePending(item.skill)
}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t("skillHubPage.saveToMyLibrary")}
</button>
)}
</>
) : null}
{!contentDiverged &&
allowImportToLibrary &&
isNativeInstanceSkill(item) &&
item.skill &&
item.skill.user_id === userId &&
@@ -224,7 +279,8 @@ function InstanceSkillCard({
{t("skillHubPage.importToLibrary")}
</button>
) : null}
{item.skill &&
{!contentDiverged &&
item.skill &&
isNativeInstanceSkill(item) &&
item.skill.user_id === userId &&
item.skill.source_type === "uploaded" &&
@@ -565,6 +621,54 @@ const InstanceSkillHubPanel: React.FC<InstanceSkillHubPanelProps> = ({
}
};
const handleRestoreInstanceSkill = async (skillId: number) => {
try {
setActionLoading(`restore-skill-${skillId}`);
await skillHubService.restoreInstanceSkill(instanceId, skillId);
await reloadSkillSection();
} catch (err: unknown) {
alert(hubErrorMessage(err, t("skillHubPage.restoreInstanceSkillFailed")));
} finally {
setActionLoading(null);
}
};
const handleSaveBackToLibrary = async (skillId: number) => {
try {
setActionLoading(`save-back-${skillId}`);
await skillHubService.saveBackInstanceSkillToLibrary(instanceId, skillId);
await reloadSkillSection();
} catch (err: unknown) {
const errorKey = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
if (errorKey === "skill_package_pending") {
await reloadSkillSection();
alert(t("skillHubPage.importToLibraryPending"));
return;
}
alert(hubErrorMessage(err, t("skillHubPage.saveBackToLibraryFailed")));
} finally {
setActionLoading(null);
}
};
const handleSaveToMyLibrary = async (skillId: number) => {
try {
setActionLoading(`save-mine-${skillId}`);
await skillHubService.saveForeignInstanceSkillToMyLibrary(instanceId, skillId);
await reloadSkillSection();
} catch (err: unknown) {
const errorKey = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
if (errorKey === "skill_package_pending") {
await reloadSkillSection();
alert(t("skillHubPage.importToLibraryPending"));
return;
}
alert(hubErrorMessage(err, t("skillHubPage.saveToMyLibraryFailed")));
} finally {
setActionLoading(null);
}
};
const handlePublishSkillToHub = async () => {
if (publishSkillId === null || selectedHubTagIds.length === 0) {
alert(t("skillHubPage.errors.tagsRequired"));
@@ -744,7 +848,7 @@ const InstanceSkillHubPanel: React.FC<InstanceSkillHubPanelProps> = ({
return (
<>
<InstanceCollapsiblePanel
storageKey={`clawmanager.instance-panel.skills.${instanceId}`}
storageKey={instancePanelStorageKey("skills", instanceId)}
title={t("instances.skillManagement")}
icon={<KeyRound className="h-4 w-4 text-indigo-600" />}
summary={skillPanelSummary}
@@ -824,6 +928,9 @@ const InstanceSkillHubPanel: React.FC<InstanceSkillHubPanelProps> = ({
setPublishSkillId(skillId);
setSelectedHubTagIds([]);
}}
onRestore={(skillId) => void handleRestoreInstanceSkill(skillId)}
onSaveBackToLibrary={(skillId) => void handleSaveBackToLibrary(skillId)}
onSaveToMyLibrary={(skillId) => void handleSaveToMyLibrary(skillId)}
onRemove={(skillId) => void handleRemoveSkill(skillId)}
shouldShowImportToLibrary={shouldShowImportToLibrary}
isSkillPackagePending={isSkillPackagePending}
@@ -65,6 +65,11 @@ const OpenClawConfigPlanSection: React.FC<OpenClawConfigPlanSectionProps> = ({
() => resources.filter((resource) => resource.resource_type === "channel"),
[resources],
);
const scheduledTaskResources = useMemo(
() =>
resources.filter((resource) => resource.resource_type === "scheduled_task"),
[resources],
);
useEffect(() => {
let cancelled = false;
@@ -252,6 +257,49 @@ const OpenClawConfigPlanSection: React.FC<OpenClawConfigPlanSectionProps> = ({
)}
</div>
</div>
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#b46c50]">
{t("openClawInjectionSection.scheduledTask")}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{scheduledTaskResources.map((item) => {
const checked = resourceIds.includes(item.id);
return (
<label
key={item.id}
className={`flex cursor-pointer items-start gap-3 rounded-2xl border px-4 py-3 ${checked ? "border-indigo-300 bg-indigo-50" : "border-gray-200 bg-white"}`}
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
onSelectionChange({
bundleId: undefined,
resourceIds: e.target.checked
? [...resourceIds, item.id]
: resourceIds.filter((value) => value !== item.id),
})
}
/>
<span>
<span className="block font-medium text-gray-900">
{item.name}
</span>
<span className="mt-1 block text-xs text-gray-500">
{item.resource_key}
</span>
</span>
</label>
);
})}
{scheduledTaskResources.length === 0 && (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500">
{t("openClawInjectionSection.noScheduledTaskResources")}
</div>
)}
</div>
</div>
</div>
)}
</div>
@@ -0,0 +1,42 @@
const HERMES_PTY_ATTACH_KEY = "hermes.pty.token.chat";
const CLAWMANAGER_HERMES_INSTANCE_KEY = "clawmanager.hermes.instanceId";
function clearHermesPtyAttach(storage: Storage) {
storage.removeItem(HERMES_PTY_ATTACH_KEY);
}
/**
* Hermes dashboard persists its PTY attach id in localStorage under a fixed key.
* When embedded via the ClawManager same-origin proxy, that key is shared with the
* parent page and across instance visits — reusing a stale attach causes dual
* chat channels to fight and WebSocket 1006 reconnect loops.
*
* Clear the attach token before each Hermes iframe load so the dashboard mints a
* fresh attach id (mirrors OpenClaw's prepareOpenClawControlUIStorage pattern).
*/
export function prepareHermesDashboardStorage(instanceId: number, embedUrl: string) {
if (typeof window === "undefined") {
return embedUrl;
}
try {
const storage = window.localStorage;
clearHermesPtyAttach(storage);
storage.setItem(CLAWMANAGER_HERMES_INSTANCE_KEY, String(instanceId));
} catch {
return embedUrl;
}
return embedUrl;
}
export function clearHermesDashboardStorage() {
if (typeof window === "undefined") {
return;
}
try {
clearHermesPtyAttach(window.localStorage);
} catch {
// ignore quota / privacy mode failures
}
}
+179 -5
View File
@@ -1009,6 +1009,8 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
allTags: "All tags",
upload: "Upload ZIP",
publish: "Publish to Hub",
replacePublish: "Replace published version",
publishAsNew: "Publish as new skill",
unpublish: "Unpublish",
editTags: "Edit tags",
install: "Install to instance",
@@ -1036,6 +1038,14 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
discoveredPublishHint: "For instance skills, retry package collect or upload a ZIP manually.",
importToLibrarySuccess: "Skill saved to your library.",
importToLibraryFailed: "Failed to save skill to library.",
contentDiverged: "Content changed",
contentDivergedHint: "Instance copy differs from the installed library version. Changes are not synced to Hub.",
restoreInstanceSkill: "Restore",
restoreInstanceSkillFailed: "Failed to restore skill on instance.",
saveBackToLibrary: "Save back to library",
saveBackToLibraryFailed: "Failed to save skill back to library.",
saveToMyLibrary: "Save to my library",
saveToMyLibraryFailed: "Failed to save skill to your library.",
alreadyInLibrary: "In Skill Library",
versionLabel: "v{version}",
configCenterHint: "Upload skill packages here, then publish them from Skill Hub.",
@@ -1062,6 +1072,7 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
},
notices: {
published: "Skill published to hub",
publishedAsNew: "Published as a new skill",
unpublished: "Skill unpublished",
installed: "Skill installed to instance",
deleted: "Skill deleted",
@@ -1106,6 +1117,8 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
allTags: "全部标签",
upload: "上传 ZIP",
publish: "发布到 Hub",
replacePublish: "用当前版本替换发布",
publishAsNew: "作为新 Skill 发布",
unpublish: "下架",
editTags: "编辑标签",
install: "安装到实例",
@@ -1133,6 +1146,14 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
discoveredPublishHint: "实例内 skill 可重新采集包,或手动 ZIP 上传到 Skill Hub。",
importToLibrarySuccess: "已收录到 Skill 库。",
importToLibraryFailed: "收录到 Skill 库失败。",
contentDiverged: "内容已变化",
contentDivergedHint: "实例内副本与安装时的库版本不一致,不会自动同步到 Hub。",
restoreInstanceSkill: "还原",
restoreInstanceSkillFailed: "还原实例内 Skill 失败。",
saveBackToLibrary: "保存回库",
saveBackToLibraryFailed: "保存回库失败。",
saveToMyLibrary: "保存到我的库",
saveToMyLibraryFailed: "保存到我的库失败。",
alreadyInLibrary: "已在 Skill 库",
versionLabel: "v{version}",
configCenterHint: "可在此上传 Skill 包,然后前往 Skill Hub 发布。",
@@ -1159,6 +1180,7 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
},
notices: {
published: "已发布到 Hub",
publishedAsNew: "已作为新 Skill 发布",
unpublished: "已下架",
installed: "已安装到实例",
deleted: "已删除",
@@ -1203,6 +1225,8 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
allTags: "すべてのタグ",
upload: "ZIP をアップロード",
publish: "Hub に公開",
replacePublish: "公開版を現在のバージョンで置換",
publishAsNew: "新しい Skill として公開",
unpublish: "公開解除",
editTags: "タグを編集",
install: "インスタンスにインストール",
@@ -1228,6 +1252,14 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
importToLibraryPending: "インスタンスから Skill パッケージを同期中です。しばらくしてから再試行してください。",
importToLibrarySuccess: "Skill ライブラリに保存しました。",
importToLibraryFailed: "Skill ライブラリへの保存に失敗しました。",
contentDiverged: "内容が変更されました",
contentDivergedHint: "インスタンス上のコピーはインストール時のライブラリ版と異なります。Hub へは自動同期されません。",
restoreInstanceSkill: "復元",
restoreInstanceSkillFailed: "インスタンス上の Skill 復元に失敗しました。",
saveBackToLibrary: "ライブラリへ保存",
saveBackToLibraryFailed: "ライブラリへの保存に失敗しました。",
saveToMyLibrary: "マイライブラリに保存",
saveToMyLibraryFailed: "マイライブラリへの保存に失敗しました。",
alreadyInLibrary: "Skill ライブラリにあります",
versionLabel: "v{version}",
configCenterHint: "ここで Skill パッケージをアップロードし、Skill Hub から公開してください。",
@@ -1254,6 +1286,7 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
},
notices: {
published: "Hub に公開しました",
publishedAsNew: "新しい Skill として公開しました",
unpublished: "公開を解除しました",
installed: "インスタンスにインストールしました",
deleted: "削除しました",
@@ -1298,6 +1331,8 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
allTags: "모든 태그",
upload: "ZIP 업로드",
publish: "Hub에 게시",
replacePublish: "현재 버전으로 게시본 교체",
publishAsNew: "새 Skill로 게시",
unpublish: "게시 취소",
editTags: "태그 편집",
install: "인스턴스에 설치",
@@ -1323,6 +1358,14 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
importToLibraryPending: "인스턴스에서 Skill 패키지를 동기화 중입니다. 잠시 후 다시 시도하세요.",
importToLibrarySuccess: "Skill 라이브러리에 저장되었습니다.",
importToLibraryFailed: "Skill 라이브러리 저장에 실패했습니다.",
contentDiverged: "내용이 변경됨",
contentDivergedHint: "인스턴스 사본이 설치 시점 라이브러리 버전과 다릅니다. Hub로 자동 동기화되지 않습니다.",
restoreInstanceSkill: "복원",
restoreInstanceSkillFailed: "인스턴스 Skill 복원에 실패했습니다.",
saveBackToLibrary: "라이브러리에 다시 저장",
saveBackToLibraryFailed: "라이브러리 재저장에 실패했습니다.",
saveToMyLibrary: "내 라이브러리에 저장",
saveToMyLibraryFailed: "내 라이브러리 저장에 실패했습니다.",
alreadyInLibrary: "Skill 라이브러리에 있음",
versionLabel: "v{version}",
configCenterHint: "여기서 Skill 패키지를 업로드한 뒤 Skill Hub에서 게시하세요.",
@@ -1349,6 +1392,7 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
},
notices: {
published: "Hub에 게시됨",
publishedAsNew: "새 Skill로 게시됨",
unpublished: "게시 취소됨",
installed: "인스턴스에 설치됨",
deleted: "삭제됨",
@@ -1393,6 +1437,8 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
allTags: "Alle Tags",
upload: "ZIP hochladen",
publish: "Im Hub veröffentlichen",
replacePublish: "Veröffentlichte Version ersetzen",
publishAsNew: "Als neuen Skill veröffentlichen",
unpublish: "Veröffentlichung zurückziehen",
editTags: "Tags bearbeiten",
install: "Auf Instanz installieren",
@@ -1418,6 +1464,14 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
importToLibraryPending: "Skill-Paket wird von der Instanz synchronisiert. Bitte später erneut versuchen.",
importToLibrarySuccess: "In der Skill-Bibliothek gespeichert.",
importToLibraryFailed: "Speichern in der Skill-Bibliothek fehlgeschlagen.",
contentDiverged: "Inhalt geändert",
contentDivergedHint: "Die Instanzkopie weicht von der installierten Bibliotheksversion ab. Keine automatische Hub-Synchronisierung.",
restoreInstanceSkill: "Wiederherstellen",
restoreInstanceSkillFailed: "Skill auf der Instanz konnte nicht wiederhergestellt werden.",
saveBackToLibrary: "Zurück in die Bibliothek speichern",
saveBackToLibraryFailed: "Zurückspeichern in die Bibliothek fehlgeschlagen.",
saveToMyLibrary: "In meine Bibliothek speichern",
saveToMyLibraryFailed: "Speichern in meine Bibliothek fehlgeschlagen.",
alreadyInLibrary: "In Skill-Bibliothek",
versionLabel: "v{version}",
configCenterHint: "Laden Sie Skill-Pakete hier hoch und veröffentlichen Sie sie im Skill Hub.",
@@ -1444,6 +1498,7 @@ const skillHubTranslations: Record<Locale, TranslationTree> = {
},
notices: {
published: "Im Hub veröffentlicht",
publishedAsNew: "Als neuer Skill veröffentlicht",
unpublished: "Veröffentlichung zurückgezogen",
installed: "Auf Instanz installiert",
deleted: "Gelöscht",
@@ -2733,7 +2788,7 @@ export const translations: Record<Locale, TranslationTree> = {
thisResourceType: "This resource type",
notConfigurableYet: "{type} is not configurable yet.",
onlyChannelConfigurable:
"Channel resources are supported today. Skill, Agent, and Scheduled Task editors will be added in a later update.",
"Channel, Skill, and Scheduled Task editors are available. Agent editor will be added in a later update.",
bundlesSubtitle:
"Compose reusable OpenClaw bootstrap packages in a dedicated popup editor.",
loadingBundles: "Loading bundles...",
@@ -2849,6 +2904,65 @@ export const translations: Record<Locale, TranslationTree> = {
form: "Form",
json: "JSON",
},
scheduledTask: {
editorTitle: "Scheduled Task",
editorHint: "Tell the agent what to do, when to run, and where to send the result.",
advancedTitle: "Advanced options",
advancedHint: "OpenClaw-compatible cron fields. Most users can leave these alone.",
simple: {
prompt: "What should the agent do?",
promptPlaceholder: "e.g. Summarize overnight updates and send a short brief.",
when: "When to run",
where: "Where to send the result",
customAdvancedScheduleHint:
"This uses an advanced schedule (every/at). Edit it under Advanced options below.",
},
presets: {
daily9: "Every day at 09:00",
hourly: "Every hour",
every5m: "Every 5 minutes",
custom: "Custom schedule",
},
deliveryOptions: {
announce: "Last used channel",
webhook: "Webhook URL",
none: "Don't deliver",
},
fields: {
name: "Job name",
jobDescription: "Job description",
deleteAfterRun: "Delete after run",
scheduleKind: "Schedule kind",
expr: "Cron expression",
tz: "Timezone",
everyMs: "Every (ms)",
at: "Run at (ISO-8601)",
sessionTarget: "Session target",
wakeMode: "Wake mode",
payloadText: "System event text",
payloadMessage: "Agent turn message",
payloadModel: "Model override",
deliveryMode: "Delivery mode",
deliveryChannel: "Delivery channel",
deliveryTo: "Webhook URL",
deliveryBestEffort: "Best-effort delivery",
},
errors: {
invalid_json: "This scheduled task config is invalid.",
name_required: "Please enter a resource name (used as the job name).",
schedule_at_required: "Please set a run-at time.",
schedule_every_required: "Interval must be greater than 0.",
schedule_expr_required: "Please enter a cron expression.",
main_requires_system_event:
"Main session requires a system-event payload.",
isolated_requires_agent_turn:
"Isolated session requires an agent-turn message.",
payload_text_required: "Please enter the system event text.",
payload_message_required: "Please tell the agent what to do.",
delivery_webhook_url_required:
"Please enter an http(s) webhook URL.",
},
},
channelEditors: {
dingtalkConnector: {
title: "DingTalk Channel Editor",
@@ -2982,6 +3096,8 @@ export const translations: Record<Locale, TranslationTree> = {
bundleOptionCount: "{count} resources",
channel: "Channel",
noChannelResources: "No channel resources available.",
scheduledTask: "Scheduled Task",
noScheduledTaskResources: "No scheduled task resources available.",
errors: {
chooseBundle: "Choose a bundle before continuing.",
chooseResource: "Choose at least one resource before continuing.",
@@ -4163,7 +4279,7 @@ export const translations: Record<Locale, TranslationTree> = {
thisResourceType: "该资源类型",
notConfigurableYet: "{type} 暂不支持配置。",
onlyChannelConfigurable:
"当前支持配置 ChannelSkill、Agent 和 Scheduled Task 编辑器会在后续版本提供。",
"当前支持配置 ChannelSkill Scheduled Task。Agent 编辑器会在后续版本提供。",
bundlesSubtitle:
"把可复用的 OpenClaw 启动资源组合成资源包,并在弹窗中集中编辑。",
loadingBundles: "正在加载资源包...",
@@ -4271,6 +4387,62 @@ export const translations: Record<Locale, TranslationTree> = {
form: "表单",
json: "JSON",
},
scheduledTask: {
editorTitle: "定时任务",
editorHint: "写清让 Agent 做什么、何时跑、结果发到哪即可。",
advancedTitle: "高级选项",
advancedHint: "兼容 OpenClaw 定时任务格式。一般不用改。",
simple: {
prompt: "让 Agent 做什么?",
promptPlaceholder: "例如:汇总一夜更新,发一条短简报。",
when: "何时执行",
where: "结果发到哪",
customAdvancedScheduleHint:
"当前为高级调度(every/at),请在下方「高级选项」中修改。",
},
presets: {
daily9: "每天 09:00",
hourly: "每小时",
every5m: "每 5 分钟",
custom: "自定义",
},
deliveryOptions: {
announce: "发到上次频道",
webhook: "发到 Webhook",
none: "不投递",
},
fields: {
name: "任务名称",
jobDescription: "任务描述",
deleteAfterRun: "执行后删除",
scheduleKind: "调度类型",
expr: "Cron 表达式",
tz: "时区",
everyMs: "间隔(毫秒)",
at: "执行时间(ISO-8601",
sessionTarget: "会话目标",
wakeMode: "唤醒模式",
payloadText: "系统事件文本",
payloadMessage: "Agent 回合提示词",
payloadModel: "模型覆盖",
deliveryMode: "投递模式",
deliveryChannel: "投递通道",
deliveryTo: "Webhook URL",
deliveryBestEffort: "尽力投递(失败不阻断)",
},
errors: {
invalid_json: "定时任务配置无效。",
name_required: "请填写资源名称(会用作任务名)。",
schedule_at_required: "请填写执行时间。",
schedule_every_required: "间隔必须大于 0。",
schedule_expr_required: "请填写 Cron 表达式。",
main_requires_system_event: "主会话需要系统事件内容。",
isolated_requires_agent_turn: "独立会话需要 Agent 提示词。",
payload_text_required: "请填写系统事件文本。",
payload_message_required: "请填写让 Agent 做什么。",
delivery_webhook_url_required: "请填写 http(s) Webhook 地址。",
},
},
channelEditors: {
dingtalkConnector: {
title: "DingTalk Channel 编辑器",
@@ -4401,6 +4573,8 @@ export const translations: Record<Locale, TranslationTree> = {
bundleOptionCount: "{count} 个资源",
channel: "通道",
noChannelResources: "当前没有可用的通道资源。",
scheduledTask: "定时任务",
noScheduledTaskResources: "当前没有可用的定时任务资源。",
errors: {
chooseBundle: "请先选择一个资源包。",
chooseResource: "请至少选择一个资源。",
@@ -5592,7 +5766,7 @@ export const translations: Record<Locale, TranslationTree> = {
thisResourceType: "このリソースタイプ",
notConfigurableYet: "{type} はまだ設定できません。",
onlyChannelConfigurable:
"現在設定できるのは Channel のみです。Skill、Agent、Scheduled Task のエディタは後続の更新で追加されます。",
"現在設定できるのは Channel、Skill、Scheduled Task です。Agent のエディタは後続の更新で追加されます。",
bundlesSubtitle:
"再利用可能な OpenClaw 起動パッケージを束ねて、専用ポップアップで編集します。",
loadingBundles: "バンドルを読み込み中...",
@@ -7016,7 +7190,7 @@ export const translations: Record<Locale, TranslationTree> = {
thisResourceType: "이 리소스 유형은",
notConfigurableYet: "{type} 아직 설정할 수 없습니다.",
onlyChannelConfigurable:
"현재는 Channel만 설정할 수 있습니다. Skill, Agent, Scheduled Task 편집기는 이후 업데이트에서 추가됩니다.",
"현재는 Channel, Skill, Scheduled Task를 설정할 수 있습니다. Agent 편집기는 이후 업데이트에서 추가됩니다.",
bundlesSubtitle:
"재사용 가능한 OpenClaw 부트스트랩 패키지를 번들로 구성하고 전용 팝업에서 편집합니다.",
loadingBundles: "번들을 불러오는 중...",
@@ -8472,7 +8646,7 @@ export const translations: Record<Locale, TranslationTree> = {
thisResourceType: "Dieser Ressourcentyp",
notConfigurableYet: "{type} ist noch nicht konfigurierbar.",
onlyChannelConfigurable:
"Derzeit werden nur Channel-Ressourcen unterstützt. Editoren für Skill, Agent und Scheduled Task folgen in einem späteren Update.",
"Derzeit werden Channel-, Skill- und Scheduled-Task-Ressourcen unterstützt. Der Agent-Editor folgt in einem späteren Update.",
bundlesSubtitle:
"Stellen Sie wiederverwendbare OpenClaw-Startpakete in einem separaten Popup-Editor zusammen.",
loadingBundles: "Bundles werden geladen...",
@@ -0,0 +1,295 @@
export type ScheduledTaskScheduleKind = "at" | "every" | "cron";
export type ScheduledTaskSessionTarget = "main" | "isolated";
export type ScheduledTaskWakeMode = "next-heartbeat" | "now";
export type ScheduledTaskPayloadKind = "systemEvent" | "agentTurn";
export type ScheduledTaskDeliveryMode = "none" | "announce" | "webhook";
export type ScheduledTaskSchedulePreset =
| "daily9"
| "hourly"
| "every5m"
| "custom";
export interface ScheduledTaskFormState {
name: string;
description: string;
enabled: boolean;
deleteAfterRun: boolean;
scheduleKind: ScheduledTaskScheduleKind;
scheduleAt: string;
scheduleEveryMs: string;
scheduleExpr: string;
scheduleTz: string;
sessionTarget: ScheduledTaskSessionTarget;
wakeMode: ScheduledTaskWakeMode;
payloadKind: ScheduledTaskPayloadKind;
payloadText: string;
payloadMessage: string;
payloadModel: string;
deliveryMode: ScheduledTaskDeliveryMode;
deliveryChannel: string;
deliveryTo: string;
deliveryBestEffort: boolean;
}
export const SCHEDULE_PRESETS: Record<
Exclude<ScheduledTaskSchedulePreset, "custom">,
{ expr: string; tz: string }
> = {
daily9: { expr: "0 9 * * *", tz: "Asia/Shanghai" },
hourly: { expr: "0 * * * *", tz: "Asia/Shanghai" },
every5m: { expr: "*/5 * * * *", tz: "Asia/Shanghai" },
};
const defaultFormState = (): ScheduledTaskFormState => ({
name: "daily-brief",
description: "",
enabled: true,
deleteAfterRun: false,
scheduleKind: "cron",
scheduleAt: "",
scheduleEveryMs: "60000",
scheduleExpr: SCHEDULE_PRESETS.daily9.expr,
scheduleTz: SCHEDULE_PRESETS.daily9.tz,
sessionTarget: "isolated",
wakeMode: "now",
payloadKind: "agentTurn",
payloadText: "",
payloadMessage: "Summarize overnight updates and send a short brief.",
payloadModel: "",
deliveryMode: "announce",
deliveryChannel: "last",
deliveryTo: "",
deliveryBestEffort: true,
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value);
export const parseScheduledTaskContent = (
contentText: string,
): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(contentText) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
};
export const readScheduledTaskFormState = (
contentText: string,
): ScheduledTaskFormState | null => {
const parsed = parseScheduledTaskContent(contentText);
if (!parsed) {
return null;
}
const config = isRecord(parsed.config) ? parsed.config : null;
if (!config) {
return null;
}
const schedule = isRecord(config.schedule) ? config.schedule : {};
const payload = isRecord(config.payload) ? config.payload : {};
const delivery = isRecord(config.delivery) ? config.delivery : {};
const scheduleKind =
schedule.kind === "at" || schedule.kind === "every" || schedule.kind === "cron"
? schedule.kind
: "cron";
const sessionTarget =
config.sessionTarget === "main" || config.sessionTarget === "isolated"
? config.sessionTarget
: "isolated";
const wakeMode =
config.wakeMode === "next-heartbeat" || config.wakeMode === "now"
? config.wakeMode
: "now";
const payloadKind =
payload.kind === "systemEvent" || payload.kind === "agentTurn"
? payload.kind
: sessionTarget === "main"
? "systemEvent"
: "agentTurn";
const deliveryMode =
delivery.mode === "none" ||
delivery.mode === "announce" ||
delivery.mode === "webhook"
? delivery.mode
: "announce";
return {
name: typeof config.name === "string" ? config.name : "",
description: typeof config.description === "string" ? config.description : "",
enabled: config.enabled !== false,
deleteAfterRun: config.deleteAfterRun === true,
scheduleKind,
scheduleAt: typeof schedule.at === "string" ? schedule.at : "",
scheduleEveryMs:
typeof schedule.everyMs === "number"
? String(schedule.everyMs)
: defaultFormState().scheduleEveryMs,
scheduleExpr: typeof schedule.expr === "string" ? schedule.expr : "0 9 * * *",
scheduleTz: typeof schedule.tz === "string" ? schedule.tz : "Asia/Shanghai",
sessionTarget,
wakeMode,
payloadKind,
payloadText: typeof payload.text === "string" ? payload.text : "",
payloadMessage: typeof payload.message === "string" ? payload.message : "",
payloadModel: typeof payload.model === "string" ? payload.model : "",
deliveryMode,
deliveryChannel: typeof delivery.channel === "string" ? delivery.channel : "last",
deliveryTo: typeof delivery.to === "string" ? delivery.to : "",
deliveryBestEffort: delivery.bestEffort !== false,
};
};
export const resolveSchedulePreset = (
form: ScheduledTaskFormState,
): ScheduledTaskSchedulePreset => {
if (form.scheduleKind !== "cron") {
return "custom";
}
const expr = form.scheduleExpr.trim();
const tz = form.scheduleTz.trim() || "Asia/Shanghai";
for (const [id, preset] of Object.entries(SCHEDULE_PRESETS) as Array<
[Exclude<ScheduledTaskSchedulePreset, "custom">, { expr: string; tz: string }]
>) {
if (preset.expr === expr && preset.tz === tz) {
return id;
}
}
return "custom";
};
export const patchFromSchedulePreset = (
preset: ScheduledTaskSchedulePreset,
): Partial<ScheduledTaskFormState> => {
if (preset === "custom") {
return { scheduleKind: "cron" };
}
const resolved = SCHEDULE_PRESETS[preset];
return {
scheduleKind: "cron",
scheduleExpr: resolved.expr,
scheduleTz: resolved.tz,
};
};
export const buildScheduledTaskContent = (
contentText: string,
patch: Partial<ScheduledTaskFormState>,
): string => {
const parsed = parseScheduledTaskContent(contentText) || {
schemaVersion: 1,
kind: "scheduled_task",
format: "task/openclaw-cron@v1",
dependsOn: [],
config: {},
};
const current = readScheduledTaskFormState(contentText) || defaultFormState();
const next: ScheduledTaskFormState = { ...current, ...patch };
// Keep OpenClaw CRITICAL CONSTRAINTS consistent when switching sessionTarget.
if (patch.sessionTarget === "main") {
next.payloadKind = "systemEvent";
} else if (patch.sessionTarget === "isolated") {
next.payloadKind = "agentTurn";
} else if (patch.payloadKind === "systemEvent") {
next.sessionTarget = "main";
} else if (patch.payloadKind === "agentTurn") {
next.sessionTarget = "isolated";
}
const schedule: Record<string, unknown> = { kind: next.scheduleKind };
if (next.scheduleKind === "at") {
schedule.at = next.scheduleAt;
} else if (next.scheduleKind === "every") {
const everyMs = Number(next.scheduleEveryMs);
schedule.everyMs = Number.isFinite(everyMs) ? everyMs : 60000;
} else {
schedule.expr = next.scheduleExpr;
if (next.scheduleTz.trim()) {
schedule.tz = next.scheduleTz.trim();
}
}
const payload: Record<string, unknown> = { kind: next.payloadKind };
if (next.payloadKind === "systemEvent") {
payload.text = next.payloadText;
} else {
payload.message = next.payloadMessage;
if (next.payloadModel.trim()) {
payload.model = next.payloadModel.trim();
}
}
const delivery: Record<string, unknown> = {
mode: next.deliveryMode,
bestEffort: next.deliveryBestEffort,
};
if (next.deliveryMode === "announce" && next.deliveryChannel.trim()) {
delivery.channel = next.deliveryChannel.trim();
}
if (next.deliveryMode === "webhook" && next.deliveryTo.trim()) {
delivery.to = next.deliveryTo.trim();
}
parsed.schemaVersion = 1;
parsed.kind = "scheduled_task";
parsed.format = "task/openclaw-cron@v1";
if (!Array.isArray(parsed.dependsOn)) {
parsed.dependsOn = [];
}
parsed.config = {
name: next.name,
description: next.description,
enabled: next.enabled,
deleteAfterRun: next.deleteAfterRun,
schedule,
sessionTarget: next.sessionTarget,
wakeMode: next.wakeMode,
payload,
delivery,
};
return JSON.stringify(parsed, null, 2);
};
export const validateScheduledTaskContent = (contentText: string): string | null => {
const form = readScheduledTaskFormState(contentText);
if (!form) {
return "invalid_json";
}
if (!form.name.trim()) {
return "name_required";
}
if (form.scheduleKind === "at" && !form.scheduleAt.trim()) {
return "schedule_at_required";
}
if (form.scheduleKind === "every") {
const everyMs = Number(form.scheduleEveryMs);
if (!Number.isFinite(everyMs) || everyMs <= 0) {
return "schedule_every_required";
}
}
if (form.scheduleKind === "cron" && !form.scheduleExpr.trim()) {
return "schedule_expr_required";
}
if (form.sessionTarget === "main" && form.payloadKind !== "systemEvent") {
return "main_requires_system_event";
}
if (form.sessionTarget === "isolated" && form.payloadKind !== "agentTurn") {
return "isolated_requires_agent_turn";
}
if (form.payloadKind === "systemEvent" && !form.payloadText.trim()) {
return "payload_text_required";
}
if (form.payloadKind === "agentTurn" && !form.payloadMessage.trim()) {
return "payload_message_required";
}
if (form.deliveryMode === "webhook") {
const to = form.deliveryTo.trim();
if (!to || !/^https?:\/\//i.test(to)) {
return "delivery_webhook_url_required";
}
}
return null;
};
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
@@ -22,6 +22,10 @@ import {
X,
} from "lucide-react";
import ConfirmDialog from "../../components/ConfirmDialog";
import {
instancePanelStorageKey,
readStoredCollapsed,
} from "../../components/InstanceCollapsiblePanel";
import InstanceSkillHubPanel from "../../components/InstanceSkillHubPanel";
import InstanceSessionUsagePanel from "../../components/InstanceSessionUsagePanel";
import { InstanceServiceFrame } from "../../components/InstanceServiceFrame";
@@ -47,6 +51,9 @@ import type {
const META_POLL_INTERVAL_MS = 5000;
const RUNTIME_POLL_INTERVAL_MS = 5000;
const LITE_COLLAPSED_BOTTOM_FALLBACK_PX = 120;
const LITE_COLLAPSED_BOTTOM_MAX_PX = 220;
const LITE_ROOT_GAP_TOTAL_PX = 16; // two gap-2 rows between header / workspace / bottom
const DESKTOP_STREAM_PROFILES: Array<{
id: DesktopStreamProfile;
labelKey: string;
@@ -57,6 +64,13 @@ const DESKTOP_STREAM_PROFILES: Array<{
{ id: "high", labelKey: "instances.desktopStreamHigh", detail: "40 FPS / CRF 24" },
];
function readPanelExpanded(panel: "skills" | "session-usage", instanceId: number | null): boolean {
if (!instanceId || Number.isNaN(instanceId)) {
return false;
}
return !readStoredCollapsed(instancePanelStorageKey(panel, instanceId), true);
}
function availabilityForStatus(status: string): InstanceAvailability {
if (status === "running") {
return "available";
@@ -329,10 +343,19 @@ const InstanceDetailPage: React.FC = () => {
useState<DesktopStreamProfile | "">("");
const [desktopStreamMessage, setDesktopStreamMessage] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [skillPanelExpanded, setSkillPanelExpanded] = useState(false);
const [sessionPanelExpanded, setSessionPanelExpanded] = useState(false);
const [skillPanelExpanded, setSkillPanelExpanded] = useState(() =>
readPanelExpanded("skills", instanceId),
);
const [sessionPanelExpanded, setSessionPanelExpanded] = useState(() =>
readPanelExpanded("session-usage", instanceId),
);
const [workspaceHeightPx, setWorkspaceHeightPx] = useState<number | null>(null);
const [collapsedBottomHeightPx, setCollapsedBottomHeightPx] = useState<number | null>(null);
const workspaceSectionRef = useRef<HTMLElement>(null);
const liteRootRef = useRef<HTMLDivElement>(null);
const liteHeaderRef = useRef<HTMLDivElement>(null);
const liteBottomRef = useRef<HTMLDivElement>(null);
const bottomPanelExpandedRef = useRef(false);
const restartMenuRef = useRef<HTMLDivElement>(null);
const fetchMeta = useCallback(
@@ -492,16 +515,47 @@ const InstanceDetailPage: React.FC = () => {
if (isDedicatedInstance) {
return;
}
const section = workspaceSectionRef.current;
if (!section) {
const bottomExpanded = skillPanelExpanded || sessionPanelExpanded;
bottomPanelExpandedRef.current = bottomExpanded;
// Freeze workspace height while any bottom panel is expanded — avoid ResizeObserver races.
if (bottomExpanded) {
return;
}
const syncWorkspaceHeight = () => {
if (skillPanelExpanded || sessionPanelExpanded) {
if (bottomPanelExpandedRef.current) {
return;
}
const nextHeight = section.getBoundingClientRect().height;
const root = liteRootRef.current;
const header = liteHeaderRef.current;
const bottom = liteBottomRef.current;
if (!root || !header) {
return;
}
const parent = root.parentElement;
if (!parent) {
return;
}
let bottomReserve = collapsedBottomHeightPx ?? LITE_COLLAPSED_BOTTOM_FALLBACK_PX;
if (bottom) {
const measuredBottom = bottom.offsetHeight;
if (measuredBottom > 0 && measuredBottom <= LITE_COLLAPSED_BOTTOM_MAX_PX) {
bottomReserve = measuredBottom;
setCollapsedBottomHeightPx(measuredBottom);
}
}
const parentStyle = window.getComputedStyle(parent);
const padY =
(Number.parseFloat(parentStyle.paddingTop) || 0) +
(Number.parseFloat(parentStyle.paddingBottom) || 0);
const nextHeight =
parent.clientHeight - padY - header.offsetHeight - bottomReserve - LITE_ROOT_GAP_TOTAL_PX;
if (nextHeight > 0) {
setWorkspaceHeightPx(nextHeight);
}
@@ -509,21 +563,38 @@ const InstanceDetailPage: React.FC = () => {
syncWorkspaceHeight();
const observer = new ResizeObserver(syncWorkspaceHeight);
observer.observe(section);
const parent = liteRootRef.current?.parentElement;
if (parent) {
observer.observe(parent);
}
if (liteHeaderRef.current) {
observer.observe(liteHeaderRef.current);
}
window.addEventListener("resize", syncWorkspaceHeight);
return () => {
observer.disconnect();
window.removeEventListener("resize", syncWorkspaceHeight);
};
}, [isDedicatedInstance, sessionPanelExpanded, skillPanelExpanded]);
}, [
collapsedBottomHeightPx,
instance?.id,
isDedicatedInstance,
sessionPanelExpanded,
skillPanelExpanded,
]);
useEffect(() => {
const nextSkillExpanded = readPanelExpanded("skills", instanceId);
const nextSessionExpanded = readPanelExpanded("session-usage", instanceId);
setSkillPanelExpanded(nextSkillExpanded);
setSessionPanelExpanded(nextSessionExpanded);
}, [instanceId]);
const bottomPanelExpanded = skillPanelExpanded || sessionPanelExpanded;
bottomPanelExpandedRef.current = bottomPanelExpanded;
useLayoutEffect(() => {
if (isDedicatedInstance || !bottomPanelExpanded || workspaceHeightPx !== null) {
return;
}
const pinWorkspaceHeightBeforeExpand = useCallback(() => {
const section = workspaceSectionRef.current;
if (!section) {
return;
@@ -532,27 +603,27 @@ const InstanceDetailPage: React.FC = () => {
if (nextHeight > 0) {
setWorkspaceHeightPx(nextHeight);
}
}, [bottomPanelExpanded, isDedicatedInstance, workspaceHeightPx]);
const handleSkillPanelExpandedChange = useCallback((expanded: boolean) => {
if (expanded && workspaceSectionRef.current) {
const nextHeight = workspaceSectionRef.current.getBoundingClientRect().height;
if (nextHeight > 0) {
setWorkspaceHeightPx(nextHeight);
}
}
setSkillPanelExpanded(expanded);
}, []);
const handleSessionPanelExpandedChange = useCallback((expanded: boolean) => {
if (expanded && workspaceSectionRef.current) {
const nextHeight = workspaceSectionRef.current.getBoundingClientRect().height;
if (nextHeight > 0) {
setWorkspaceHeightPx(nextHeight);
const handleSkillPanelExpandedChange = useCallback(
(expanded: boolean) => {
if (expanded) {
pinWorkspaceHeightBeforeExpand();
}
}
setSessionPanelExpanded(expanded);
}, []);
setSkillPanelExpanded(expanded);
},
[pinWorkspaceHeightBeforeExpand],
);
const handleSessionPanelExpandedChange = useCallback(
(expanded: boolean) => {
if (expanded) {
pinWorkspaceHeightBeforeExpand();
}
setSessionPanelExpanded(expanded);
},
[pinWorkspaceHeightBeforeExpand],
);
const availability = useMemo<InstanceAvailability>(() => {
if (status?.availability) {
@@ -1167,26 +1238,27 @@ const InstanceDetailPage: React.FC = () => {
);
const renderLiteWorkspace = () => {
const pinnedWorkspaceHeight = workspaceHeightPx ?? 360;
const pinnedWorkspaceHeight = workspaceHeightPx;
return (
<div
className={`flex flex-col gap-2 ${
bottomPanelExpanded ? "min-h-0" : "min-h-0 flex-1 overflow-hidden"
ref={liteRootRef}
className={`flex min-h-0 flex-1 flex-col gap-2 ${
bottomPanelExpanded ? "" : "overflow-hidden"
}`}
>
{renderHeaderSection(shareLinkControl)}
{renderActionMessage()}
<div ref={liteHeaderRef} className="flex shrink-0 flex-col gap-2">
{renderHeaderSection(shareLinkControl)}
{renderActionMessage()}
</div>
<section
ref={workspaceSectionRef}
style={
bottomPanelExpanded
pinnedWorkspaceHeight
? { height: pinnedWorkspaceHeight, minHeight: pinnedWorkspaceHeight, flexShrink: 0 }
: undefined
: { minHeight: 420, flex: 1 }
}
className={`grid shrink-0 gap-4 overflow-hidden max-xl:h-auto max-xl:min-h-[420px] max-xl:overflow-y-auto xl:grid-cols-[minmax(0,1fr)_minmax(360px,28rem)] xl:grid-rows-[minmax(0,1fr)] ${
bottomPanelExpanded ? "" : "min-h-0 flex-1"
}`}
className="grid shrink-0 grid-cols-1 grid-rows-[minmax(0,1fr)] gap-4 overflow-hidden min-h-[420px] xl:grid-cols-[minmax(0,1fr)_minmax(360px,28rem)]"
>
<div className="h-full min-h-0 min-w-0">
<InstanceServiceFrame
@@ -1206,7 +1278,7 @@ const InstanceDetailPage: React.FC = () => {
</div>
)}
</section>
<div className="flex shrink-0 flex-col gap-2">
<div ref={liteBottomRef} className="flex shrink-0 flex-col gap-2">
<InstanceSkillHubPanel
instance={instance}
onRuntimeDetailsChange={setRuntimeDetails}
@@ -7,6 +7,14 @@ import {
findOpenClawChannelTemplate,
OPENCLAW_CHANNEL_TEMPLATES,
} from "../../lib/openclawChannelTemplates";
import {
buildScheduledTaskContent,
patchFromSchedulePreset,
readScheduledTaskFormState,
resolveSchedulePreset,
validateScheduledTaskContent,
type ScheduledTaskSchedulePreset,
} from "../../lib/openclawScheduledTaskForm";
import { openclawConfigService } from "../../services/openclawConfigService";
import { skillService } from "../../services/skillService";
import { skillHubService } from "../../services/skillHubService";
@@ -36,6 +44,7 @@ const CONFIG_CENTER_RESOURCE_TYPES = OPENCLAW_RESOURCE_TYPES.filter(
const CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES: OpenClawResourceType[] = [
"channel",
"skill",
"scheduled_task",
];
const CONFIG_CENTER_PAGE_SIZE = 8;
@@ -130,9 +139,30 @@ const defaultContentByType: Record<OpenClawResourceType, string> = {
{
schemaVersion: 1,
kind: "scheduled_task",
format: "task/default@v1",
format: "task/openclaw-cron@v1",
dependsOn: [],
config: {},
config: {
name: "daily-brief",
description: "",
enabled: true,
deleteAfterRun: false,
schedule: {
kind: "cron",
expr: "0 9 * * *",
tz: "Asia/Shanghai",
},
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "Summarize overnight updates and send a short brief.",
},
delivery: {
mode: "announce",
channel: "last",
bestEffort: true,
},
},
},
null,
2,
@@ -895,6 +925,10 @@ const OpenClawConfigCenterPage: React.FC = () => {
useState("");
const [channelEditorMode, setChannelEditorMode] =
useState<ChannelEditorMode>("form");
const [scheduledTaskEditorMode, setScheduledTaskEditorMode] =
useState<ChannelEditorMode>("form");
const [scheduledTaskJobNameLocked, setScheduledTaskJobNameLocked] =
useState(false);
const [skillUploadFile, setSkillUploadFile] = useState<File | null>(null);
const [importPreviewItems, setImportPreviewItems] = useState<SkillImportPreviewItem[]>([]);
const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -1096,6 +1130,13 @@ const OpenClawConfigCenterPage: React.FC = () => {
: null,
[resourceForm.contentText, supportedChannelEditor],
);
const scheduledTaskForm = useMemo(
() =>
resourceForm.resource_type === "scheduled_task"
? readScheduledTaskFormState(resourceForm.contentText)
: null,
[resourceForm.contentText, resourceForm.resource_type],
);
const selectedResourceTypeOption = useMemo(
() => resourceTypeOptions.find((item) => item.value === resourceType),
[resourceType, resourceTypeOptions],
@@ -1132,6 +1173,8 @@ const OpenClawConfigCenterPage: React.FC = () => {
setSelectedResourceId(undefined);
setSelectedChannelTemplateId("");
setChannelEditorMode("form");
setScheduledTaskEditorMode("form");
setScheduledTaskJobNameLocked(false);
setResourceForm(newResourceForm(resourceType));
setResourceEditorOpen(true);
};
@@ -1142,8 +1185,21 @@ const OpenClawConfigCenterPage: React.FC = () => {
setSelectedResourceId(item.id);
setSelectedChannelTemplateId("");
setChannelEditorMode("form");
setScheduledTaskEditorMode("form");
const nextForm = resourceFormFromItem(item);
if (item.resource_type === "scheduled_task") {
const taskForm = readScheduledTaskFormState(nextForm.contentText);
setScheduledTaskJobNameLocked(
Boolean(
taskForm?.name.trim() &&
taskForm.name.trim() !== item.name.trim(),
),
);
} else {
setScheduledTaskJobNameLocked(false);
}
setResourceType(item.resource_type);
setResourceForm(resourceFormFromItem(item));
setResourceForm(nextForm);
setResourceEditorOpen(true);
};
@@ -1190,6 +1246,22 @@ const OpenClawConfigCenterPage: React.FC = () => {
setError(null);
setNotice(null);
let contentText = resourceForm.contentText;
if (resourceForm.resource_type === "scheduled_task") {
const resourceName = resourceForm.name.trim();
if (resourceName && !scheduledTaskJobNameLocked) {
contentText = buildScheduledTaskContent(contentText, {
name: resourceName,
});
}
const validationError = validateScheduledTaskContent(contentText);
if (validationError) {
throw new Error(
t(`openClawResourcesPage.scheduledTask.errors.${validationError}`),
);
}
}
const payload: UpsertOpenClawConfigResourceRequest = {
resource_type: resourceForm.resource_type,
resource_key: resourceForm.resource_key.trim(),
@@ -1201,10 +1273,10 @@ const OpenClawConfigCenterPage: React.FC = () => {
resourceForm.resource_type === "channel"
? buildChannelEnvelopeForRequest(
resourceForm.resource_key,
resourceForm.contentText,
contentText,
t("openClawResourcesPage.invalidChannelJson"),
)
: JSON.parse(resourceForm.contentText),
: JSON.parse(contentText),
};
const saved = resourceForm.id
@@ -2135,6 +2207,10 @@ const OpenClawConfigCenterPage: React.FC = () => {
if (nextType !== "channel") {
setSelectedChannelTemplateId("");
}
if (nextType === "scheduled_task") {
setScheduledTaskEditorMode("form");
setScheduledTaskJobNameLocked(false);
}
setResourceForm((current) => ({
...current,
resource_type: nextType,
@@ -2173,12 +2249,25 @@ const OpenClawConfigCenterPage: React.FC = () => {
</label>
<input
value={resourceForm.name}
onChange={(e) =>
setResourceForm((current) => ({
...current,
name: e.target.value,
}))
}
onChange={(e) => {
const nextName = e.target.value;
setResourceForm((current) => {
if (
current.resource_type !== "scheduled_task" ||
scheduledTaskJobNameLocked
) {
return { ...current, name: nextName };
}
return {
...current,
name: nextName,
contentText: buildScheduledTaskContent(
current.contentText,
{ name: nextName },
),
};
});
}}
className="app-input mt-1 w-full"
/>
</div>
@@ -2405,6 +2494,568 @@ const OpenClawConfigCenterPage: React.FC = () => {
</div>
)}
</div>
) : resourceForm.resource_type === "scheduled_task" ? (
<div className="rounded-2xl border border-[#eadfd8] bg-[#fffaf7] p-4">
<div>
<div className="text-sm font-medium text-gray-700">
{t("openClawResourcesPage.scheduledTask.editorTitle")}
</div>
<p className="mt-1 text-xs leading-5 text-gray-600">
{t("openClawResourcesPage.scheduledTask.editorHint")}
</p>
</div>
{scheduledTaskForm ? (
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
{t("openClawResourcesPage.scheduledTask.simple.prompt")}
</label>
<textarea
value={
scheduledTaskForm.payloadKind === "systemEvent"
? scheduledTaskForm.payloadText
: scheduledTaskForm.payloadMessage
}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
scheduledTaskForm.payloadKind === "systemEvent"
? { payloadText: e.target.value }
: { payloadMessage: e.target.value },
),
}))
}
className="app-input mt-1 min-h-[100px] w-full"
placeholder={t(
"openClawResourcesPage.scheduledTask.simple.promptPlaceholder",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">
{t("openClawResourcesPage.scheduledTask.simple.when")}
</label>
<select
value={resolveSchedulePreset(scheduledTaskForm)}
onChange={(e) => {
const preset = e.target
.value as ScheduledTaskSchedulePreset;
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
patchFromSchedulePreset(preset),
),
}));
}}
className="app-input mt-1 w-full"
>
<option value="daily9">
{t(
"openClawResourcesPage.scheduledTask.presets.daily9",
)}
</option>
<option value="hourly">
{t(
"openClawResourcesPage.scheduledTask.presets.hourly",
)}
</option>
<option value="every5m">
{t(
"openClawResourcesPage.scheduledTask.presets.every5m",
)}
</option>
<option value="custom">
{t(
"openClawResourcesPage.scheduledTask.presets.custom",
)}
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.simple.where",
)}
</label>
<select
value={scheduledTaskForm.deliveryMode}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{
deliveryMode: e.target
.value as typeof scheduledTaskForm.deliveryMode,
...(e.target.value === "announce"
? { deliveryChannel: "last" }
: {}),
},
),
}))
}
className="app-input mt-1 w-full"
>
<option value="announce">
{t(
"openClawResourcesPage.scheduledTask.deliveryOptions.announce",
)}
</option>
<option value="webhook">
{t(
"openClawResourcesPage.scheduledTask.deliveryOptions.webhook",
)}
</option>
<option value="none">
{t(
"openClawResourcesPage.scheduledTask.deliveryOptions.none",
)}
</option>
</select>
</div>
</div>
{resolveSchedulePreset(scheduledTaskForm) === "custom" &&
scheduledTaskForm.scheduleKind === "cron" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.expr",
)}
</label>
<input
value={scheduledTaskForm.scheduleExpr}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ scheduleExpr: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
placeholder="0 9 * * *"
/>
</div>
)}
{resolveSchedulePreset(scheduledTaskForm) === "custom" &&
scheduledTaskForm.scheduleKind !== "cron" && (
<div className="rounded-xl border border-[#eadfd8] bg-white px-4 py-3 text-sm text-[#6e6460]">
{t(
"openClawResourcesPage.scheduledTask.simple.customAdvancedScheduleHint",
)}
</div>
)}
{scheduledTaskForm.deliveryMode === "webhook" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.deliveryTo",
)}
</label>
<input
value={scheduledTaskForm.deliveryTo}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ deliveryTo: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
placeholder="https://example.com/hook"
/>
</div>
)}
<details className="rounded-xl border border-[#eadfd8] bg-white px-4 py-3">
<summary className="cursor-pointer text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.advancedTitle",
)}
</summary>
<p className="mt-2 text-xs leading-5 text-gray-600">
{t(
"openClawResourcesPage.scheduledTask.advancedHint",
)}
</p>
<div className="mt-3 inline-flex rounded-full border border-[#eadfd8] bg-[#fffaf7] p-1">
{(["form", "json"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setScheduledTaskEditorMode(mode)}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
scheduledTaskEditorMode === mode
? "bg-[#171212] text-white"
: "text-[#6e6460] hover:bg-[#f5ece7]"
}`}
>
{mode === "form"
? t("openClawResourcesPage.editorModes.form")
: t("openClawResourcesPage.editorModes.json")}
</button>
))}
</div>
{scheduledTaskEditorMode === "json" ? (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
{t("openClawResourcesPage.contentJson")}
</label>
<textarea
value={resourceForm.contentText}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: e.target.value,
}))
}
className="app-input mt-1 min-h-[280px] w-full font-mono text-xs"
spellCheck={false}
/>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.name",
)}
</label>
<input
value={scheduledTaskForm.name}
onChange={(e) => {
setScheduledTaskJobNameLocked(true);
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ name: e.target.value },
),
}));
}}
className="app-input mt-1 w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.jobDescription",
)}
</label>
<input
value={scheduledTaskForm.description}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ description: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<input
type="checkbox"
checked={scheduledTaskForm.deleteAfterRun}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ deleteAfterRun: e.target.checked },
),
}))
}
/>
{t(
"openClawResourcesPage.scheduledTask.fields.deleteAfterRun",
)}
</label>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.scheduleKind",
)}
</label>
<select
value={scheduledTaskForm.scheduleKind}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{
scheduleKind: e.target
.value as typeof scheduledTaskForm.scheduleKind,
},
),
}))
}
className="app-input mt-1 w-full"
>
<option value="cron">cron</option>
<option value="every">every</option>
<option value="at">at</option>
</select>
</div>
{scheduledTaskForm.scheduleKind === "cron" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.expr",
)}
</label>
<input
value={scheduledTaskForm.scheduleExpr}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ scheduleExpr: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.tz",
)}
</label>
<input
value={scheduledTaskForm.scheduleTz}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ scheduleTz: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
/>
</div>
</>
)}
{scheduledTaskForm.scheduleKind === "every" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.everyMs",
)}
</label>
<input
value={scheduledTaskForm.scheduleEveryMs}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ scheduleEveryMs: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
/>
</div>
)}
{scheduledTaskForm.scheduleKind === "at" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.at",
)}
</label>
<input
value={scheduledTaskForm.scheduleAt}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ scheduleAt: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
placeholder="2026-07-23T10:00:00Z"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.sessionTarget",
)}
</label>
<select
value={scheduledTaskForm.sessionTarget}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{
sessionTarget: e.target
.value as typeof scheduledTaskForm.sessionTarget,
},
),
}))
}
className="app-input mt-1 w-full"
>
<option value="isolated">isolated</option>
<option value="main">main</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.wakeMode",
)}
</label>
<select
value={scheduledTaskForm.wakeMode}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{
wakeMode: e.target
.value as typeof scheduledTaskForm.wakeMode,
},
),
}))
}
className="app-input mt-1 w-full"
>
<option value="now">now</option>
<option value="next-heartbeat">
next-heartbeat
</option>
</select>
</div>
{scheduledTaskForm.payloadKind === "agentTurn" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.payloadModel",
)}
</label>
<input
value={scheduledTaskForm.payloadModel}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ payloadModel: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
placeholder="optional model override"
/>
</div>
)}
{scheduledTaskForm.deliveryMode === "announce" && (
<div>
<label className="block text-sm font-medium text-gray-700">
{t(
"openClawResourcesPage.scheduledTask.fields.deliveryChannel",
)}
</label>
<input
value={scheduledTaskForm.deliveryChannel}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{ deliveryChannel: e.target.value },
),
}))
}
className="app-input mt-1 w-full"
/>
</div>
)}
{(scheduledTaskForm.deliveryMode === "announce" ||
scheduledTaskForm.deliveryMode === "webhook") && (
<label className="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<input
type="checkbox"
checked={scheduledTaskForm.deliveryBestEffort}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: buildScheduledTaskContent(
current.contentText,
{
deliveryBestEffort: e.target.checked,
},
),
}))
}
/>
{t(
"openClawResourcesPage.scheduledTask.fields.deliveryBestEffort",
)}
</label>
)}
</div>
)}
</details>
</div>
) : (
<div className="mt-4 space-y-4">
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{t(
"openClawResourcesPage.scheduledTask.errors.invalid_json",
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t("openClawResourcesPage.contentJson")}
</label>
<textarea
value={resourceForm.contentText}
onChange={(e) =>
setResourceForm((current) => ({
...current,
contentText: e.target.value,
}))
}
className="app-input mt-1 min-h-[280px] w-full font-mono text-xs"
spellCheck={false}
/>
</div>
</div>
)}
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700">
+73 -30
View File
@@ -33,6 +33,7 @@ const SkillHubPage: React.FC = () => {
const [selectedTag, setSelectedTag] = useState('');
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [publishSkillId, setPublishSkillId] = useState<number | null>(null);
const [publishMode, setPublishMode] = useState<'publish' | 'publish-as-new'>('publish');
const [editTagsSkillId, setEditTagsSkillId] = useState<number | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [installSkillId, setInstallSkillId] = useState<number | null>(null);
@@ -193,6 +194,9 @@ const SkillHubPage: React.FC = () => {
setPendingUploadFile(file);
setImportPreviewItems(preview);
setImportDialogOpen(true);
// Release upload loading while the user decides; otherwise conflict
// dialog buttons stay disabled (loading === upload) and deadlock.
setActionLoading('');
pendingImportResolverRef.current = { resolve, reject };
});
};
@@ -216,6 +220,9 @@ const SkillHubPage: React.FC = () => {
const results = await importSingleArchive(file);
allResults.push(...results);
} catch (err: unknown) {
if (err instanceof Error && err.message === 'import_cancelled') {
continue;
}
errors.push(`${file.name}: ${(err as { response?: { data?: { error?: string } } })?.response?.data?.error || t('skillHubPage.errors.upload')}`);
}
}
@@ -273,10 +280,16 @@ const SkillHubPage: React.FC = () => {
try {
setActionLoading(`publish-${publishSkillId}`);
setError(null);
await skillHubService.publishSkill(publishSkillId, selectedTagIds);
if (publishMode === 'publish-as-new') {
await skillHubService.publishSkillAsNew(publishSkillId, selectedTagIds);
setNotice(t('skillHubPage.notices.publishedAsNew'));
} else {
await skillHubService.publishSkill(publishSkillId, selectedTagIds);
setNotice(t('skillHubPage.notices.published'));
}
setPublishSkillId(null);
setPublishMode('publish');
setSelectedTagIds([]);
setNotice(t('skillHubPage.notices.published'));
await refreshAll();
} catch (err: unknown) {
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error || t('skillHubPage.errors.publish'));
@@ -460,20 +473,20 @@ const SkillHubPage: React.FC = () => {
) : null}
</div>
<div className="flex flex-wrap gap-2">
{tab === 'catalog' || tab === 'mine' ? (
<button type="button" className="app-button-primary" onClick={() => setInstallSkillId(skill.id)}>
{t('skillHubPage.installBatch')}
</button>
) : null}
{tab === 'catalog' ? (
<>
<button type="button" className="app-button-primary" onClick={() => setInstallSkillId(skill.id)}>
{t('skillHubPage.installBatch')}
</button>
<button
type="button"
className="app-button-secondary"
disabled={actionLoading === `download-${skill.id}`}
onClick={() => void handleDownload(skill)}
>
{t('skillHubPage.download')}
</button>
</>
<button
type="button"
className="app-button-secondary"
disabled={actionLoading === `download-${skill.id}`}
onClick={() => void handleDownload(skill)}
>
{t('skillHubPage.download')}
</button>
) : null}
{(tab === 'mine' || tab === 'admin') && skill.visibility === 'public' ? (
<button
@@ -486,17 +499,34 @@ const SkillHubPage: React.FC = () => {
</button>
) : null}
{canPublishManage && skill.visibility !== 'public' ? (
<button
type="button"
className="app-button-secondary"
disabled={!skill.publishable || actionLoading === `publish-${skill.id}`}
onClick={() => {
setPublishSkillId(skill.id);
setSelectedTagIds((skill.tags || []).filter((tag) => !tag.admin_only).map((tag) => tag.id));
}}
>
{t('skillHubPage.publish')}
</button>
<>
<button
type="button"
className="app-button-secondary"
disabled={!skill.publishable || actionLoading === `publish-${skill.id}`}
onClick={() => {
setPublishMode('publish');
setPublishSkillId(skill.id);
setSelectedTagIds((skill.tags || []).filter((tag) => !tag.admin_only).map((tag) => tag.id));
}}
>
{skill.published_at ? t('skillHubPage.replacePublish') : t('skillHubPage.publish')}
</button>
{skill.published_at ? (
<button
type="button"
className="app-button-secondary"
disabled={!skill.publishable || actionLoading === `publish-${skill.id}`}
onClick={() => {
setPublishMode('publish-as-new');
setPublishSkillId(skill.id);
setSelectedTagIds((skill.tags || []).filter((tag) => !tag.admin_only).map((tag) => tag.id));
}}
>
{t('skillHubPage.publishAsNew')}
</button>
) : null}
</>
) : null}
{canPublishManage && skill.visibility === 'public' ? (
<>
@@ -607,7 +637,7 @@ const SkillHubPage: React.FC = () => {
<button
type="button"
className="app-button-primary whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-50"
disabled={uploadFiles.length === 0 || actionLoading === 'upload'}
disabled={uploadFiles.length === 0 || actionLoading === 'upload' || importDialogOpen}
onClick={() => void handleUpload()}
>
{t('skillHubPage.upload')}
@@ -630,7 +660,9 @@ const SkillHubPage: React.FC = () => {
{publishSkillId !== null ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="w-full max-w-lg rounded-[24px] bg-white p-6 shadow-xl">
<h2 className="text-lg font-semibold text-[#1d1713]">{t('skillHubPage.publish')}</h2>
<h2 className="text-lg font-semibold text-[#1d1713]">
{publishMode === 'publish-as-new' ? t('skillHubPage.publishAsNew') : t('skillHubPage.publish')}
</h2>
<p className="mt-2 text-sm text-[#6f6158]">{t('skillHubPage.selectTags')}</p>
<div className="mt-4 flex flex-wrap gap-2">
{visibleTags.map((tag) => (
@@ -641,8 +673,19 @@ const SkillHubPage: React.FC = () => {
))}
</div>
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="app-button-secondary" onClick={() => setPublishSkillId(null)}>{t('common.cancel')}</button>
<button type="button" className="app-button-primary" onClick={() => void handlePublish()}>{t('skillHubPage.publish')}</button>
<button
type="button"
className="app-button-secondary"
onClick={() => {
setPublishSkillId(null);
setPublishMode('publish');
}}
>
{t('common.cancel')}
</button>
<button type="button" className="app-button-primary" onClick={() => void handlePublish()}>
{publishMode === 'publish-as-new' ? t('skillHubPage.publishAsNew') : t('skillHubPage.publish')}
</button>
</div>
</div>
</div>
+20
View File
@@ -61,6 +61,11 @@ export const skillHubService = {
return response.data.data;
},
publishSkillAsNew: async (skillId: number, tagIds: number[]): Promise<Skill> => {
const response = await api.post(`/skill-hub/skills/${skillId}/publish-as-new`, { tag_ids: tagIds });
return response.data.data;
},
unpublishSkill: async (skillId: number): Promise<Skill> => {
const response = await api.post(`/skill-hub/skills/${skillId}/unpublish`);
return response.data.data;
@@ -99,6 +104,21 @@ export const skillHubService = {
return response.data.data;
},
restoreInstanceSkill: async (instanceId: number, skillId: number): Promise<InstanceSkill> => {
const response = await api.post(`/instances/${instanceId}/skills/${skillId}/restore`);
return response.data.data;
},
saveBackInstanceSkillToLibrary: async (instanceId: number, skillId: number): Promise<Skill> => {
const response = await api.post(`/instances/${instanceId}/skills/${skillId}/save-back-to-library`);
return response.data.data;
},
saveForeignInstanceSkillToMyLibrary: async (instanceId: number, skillId: number): Promise<Skill> => {
const response = await api.post(`/instances/${instanceId}/skills/${skillId}/save-to-my-library`);
return response.data.data;
},
retryPackageCollect: async (instanceId: number, skillId: number): Promise<void> => {
await api.post(`/instances/${instanceId}/skills/${skillId}/retry-package-collect`);
},
+2
View File
@@ -109,6 +109,8 @@ export interface InstanceSkill {
install_path?: string;
workspace_dir?: string;
observed_hash?: string;
installed_content_hash?: string;
content_diverged?: boolean;
status: string;
last_seen_at?: string;
removed_at?: string;