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:
@@ -93,3 +93,4 @@ TEAM_PROFILES_LOCAL_TESTING.md
|
||||
/clawmanager-team-profiles-test.yaml
|
||||
/clawmanager-tenant.yaml
|
||||
.codex
|
||||
/litellm-five-highlights-design.md
|
||||
@@ -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] = ©
|
||||
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 ©, 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 ©, 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 = ©
|
||||
}
|
||||
}
|
||||
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] = ©
|
||||
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: ¤tVersionID,
|
||||
},
|
||||
},
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -623,8 +623,8 @@ AI 审计页面用于查看最近的受管模型调用记录,帮助管理员
|
||||
|
||||
- **通道**
|
||||
- **技能**
|
||||
- **定时任务**
|
||||
- **智能体(即将上线)**
|
||||
- **定时任务(即将上线)**
|
||||
|
||||
页面右上角支持:
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"当前仅支持配置 Channel。Skill、Agent 和 Scheduled Task 的编辑器会在后续版本提供。",
|
||||
"当前已支持配置 Channel、Skill 与 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user