diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index f3c380848416..3bc67f4d2aad 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -87,8 +87,9 @@ Iterate within the three-attempt budget; all attempts remain recorded. Build `mantis-evidence.json` with `scripts/mantis/build-telegram-desktop-proof-evidence.mts` as before, using each lane's generated `telegram-user-crabbox-session-summary.json`. Edit only the -human summary/expected wording. A failure or block sets `comparison.pass: false` -and names the concrete product defect or missing primitive. +human summary/expected wording. Name the concrete product defect or missing +primitive when a lane fails or blocks; the workflow derives the outcome from +trusted lane facts. ```bash node --import tsx scripts/mantis/build-telegram-desktop-proof-evidence.mts \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecd70ba1a6d5..2314f5863172 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,9 @@ jobs: docs_only: ${{ steps.manifest.outputs.docs_only }} docs_changed: ${{ steps.manifest.outputs.docs_changed }} run_node: ${{ steps.manifest.outputs.run_node }} + # docker-seed-e2e-contract-v1: frozen targets without this marker skip the lane. + run_docker_seed_e2e: ${{ steps.manifest.outputs.run_docker_seed_e2e }} + docker_seed_lanes: ${{ steps.manifest.outputs.docker_seed_lanes }} run_macos: ${{ steps.manifest.outputs.run_macos }} run_android: ${{ steps.manifest.outputs.run_android }} run_skills_python: ${{ steps.manifest.outputs.run_skills_python }} @@ -636,6 +639,7 @@ jobs: : ""; const supportsOpenClawKitTests = targetWorkflow.includes("openclawkit-tests-contract-v1"); const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2"); + const supportsDockerSeedE2e = targetWorkflow.includes("docker-seed-e2e-contract-v1"); const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi; const supportsFormatCheck = targetWorkflow.split("pnpm format:check").length - 1 >= 2; @@ -671,6 +675,13 @@ jobs: } const compactPullRequest = isCanonicalRepository && eventName === "pull_request"; + const dockerSeedLanes = + compactPullRequest && + changedPaths && + supportsDockerSeedE2e && + typeof changedNodeTestPlan.resolveChangedDockerSeedLanes === "function" + ? changedNodeTestPlan.resolveChangedDockerSeedLanes(changedPaths) + : []; // Canonical pushes also use compact bins: 80+ single-group jobs // drain the runner pool for minutes, and per-shard check names on // main have no branch-protection consumers. Dispatch (release @@ -808,6 +819,8 @@ jobs: docs_only: docsOnly, docs_changed: docsChanged, run_node: runNode, + run_docker_seed_e2e: dockerSeedLanes.length > 0, + docker_seed_lanes: dockerSeedLanes.join(" "), run_macos: runMacos, run_android: runAndroid, run_skills_python: runSkillsPython, @@ -4060,6 +4073,33 @@ jobs: ;; esac + docker-seed-e2e: + permissions: + contents: read + name: docker-seed-e2e + needs: [preflight] + if: needs.preflight.outputs.run_docker_seed_e2e == 'true' + runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.event_name == 'pull_request' && (github.run_attempt > 1 || github.event.pull_request.head.repo.full_name != github.repository)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }} + timeout-minutes: 60 + steps: + - *linux_node_checkout_step + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: "24.x" + install-bun: "false" + dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }} + use-actions-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }} + + - name: Run changed Docker seed owner lanes + env: + OPENCLAW_DOCKER_ALL_LANES: ${{ needs.preflight.outputs.docker_seed_lanes }} + OPENCLAW_DOCKER_ALL_LIVE_MODE: skip + OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1" + OPENCLAW_DOCKER_ALL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }} + OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }} + run: pnpm test:docker:all + ci-gate: permissions: contents: read @@ -4092,6 +4132,7 @@ jobs: - macos-swift - ios-build - android + - docker-seed-e2e if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -4128,6 +4169,7 @@ jobs: macos-swift=${{ needs.macos-swift.result }} ios-build=${{ needs.ios-build.result }} android=${{ needs.android.result }} + docker-seed-e2e=${{ needs.docker-seed-e2e.result }} run: | set -euo pipefail diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index 520ed419f220..1079e42e5524 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -740,21 +740,23 @@ jobs: sudo install -m 0755 "$tdlib_dir/tdlib-v1.8.0-linux-x64/lib/libtdjson.so" /usr/local/lib/libtdjson.so # The Convex credential is the real mutex for the shared Telegram account: # a concurrent holder fails this acquire, so no separate run-level lock is - # needed. Retry while another run finishes, then fail with a clear reason. + # needed. Observed 2026-08: a complete proof held the account for 34m; + # four hours covers burst backfills while reserving roughly two hours + # of the job limit for proof, publication, and cleanup. echo "lease_file=$credential_dir/lease.json" >> "$GITHUB_OUTPUT" - deadline=$(( SECONDS + 15 * 60 )) + lease_deadline=$(( SECONDS + 4 * 60 * 60 )) until node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore \ --user-driver-dir "$credential_dir/user-driver" \ --desktop-workdir "$credential_dir/desktop" \ --lease-file "$credential_dir/lease.json" \ --payload-output "$credential_dir/payload.json" \ --credential-role ci; do - if (( SECONDS >= deadline )); then - echo "::error::The shared QA Telegram account is still leased by another run after 15 minutes." >&2 + if (( SECONDS >= lease_deadline )); then + echo "::error::The shared QA Telegram account remained busy for four hours." >&2 exit 1 fi - echo "Shared QA Telegram account is busy; retrying in 60s." >&2 - sleep 60 + echo "Shared QA Telegram account is busy; retrying in 15s." >&2 + sleep 15 done chmod 0700 "$credential_dir" "$credential_dir/user-driver" sut_credential_dir="/tmp/openclaw-mantis-sut-credential-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" @@ -975,20 +977,16 @@ jobs: select((.summary | type) == "string" and (.summary | length) <= 4000) | select((.comparison.baseline.expected | type) == "string" and (.comparison.baseline.expected | length) <= 1000) | select((.comparison.candidate.expected | type) == "string" and (.comparison.candidate.expected | length) <= 1000) | - select((.comparison.pass | type) == "boolean") | - select((.comparison.candidate.fixed | type) == "boolean") | { summary, baselineExpected: .comparison.baseline.expected, - candidateExpected: .comparison.candidate.expected, - comparisonPass: .comparison.pass, - candidateFixed: .comparison.candidate.fixed + candidateExpected: .comparison.candidate.expected } ' "$agent_manifest" > "$judgment" baseline_status="$(sudo jq -r '.comparison.baseline.status' "$agent_manifest")" candidate_status="$(sudo jq -r '.comparison.candidate.status' "$agent_manifest")" - [[ "$baseline_status" == "pass" || "$baseline_status" == "fail" ]] - [[ "$candidate_status" == "pass" || "$candidate_status" == "fail" ]] + [[ "$baseline_status" == "pass" || "$baseline_status" == "fail" || "$baseline_status" == "blocked" ]] + [[ "$candidate_status" == "pass" || "$candidate_status" == "fail" || "$candidate_status" == "blocked" ]] copy_verified_artifacts() { local lane="$1" local facts_file="$2" @@ -1047,6 +1045,18 @@ jobs: (.artifacts.trimmedVideoCropped.bytes > 10000) ' "$verdict" >/dev/null fi + if [[ "$fact_status" == "blocked" ]]; then + sudo jq -e ' + (.blocked.name | type) == "string" and (.blocked.name | length) > 0 and (.blocked.name | length) <= 200 and + (.blocked.reason | type) == "string" and (.blocked.reason | length) > 0 and (.blocked.reason | length) <= 2000 + ' "$verdict" >/dev/null + lane_status="blocked" + elif [[ "$fact_status" == "complete" ]]; then + [[ "$lane_status" == "pass" || "$lane_status" == "fail" ]] + else + lane_status="fail" + fi + printf -v "${lane}_status" '%s' "$lane_status" if [[ "$pre_attestation_failure" != "true" ]]; then sudo jq -e --arg lane "$lane" --arg sha "$expected_sha" \ '.lane == $lane and .sha == $sha' \ @@ -1104,15 +1114,16 @@ jobs: sudo jq --slurpfile judgment "$judgment" ' .summary = $judgment[0].summary | .comparison.baseline.expected = $judgment[0].baselineExpected | - .comparison.candidate.expected = $judgment[0].candidateExpected | - .comparison.pass = $judgment[0].comparisonPass | - .comparison.candidate.fixed = $judgment[0].candidateFixed + .comparison.candidate.expected = $judgment[0].candidateExpected ' "$manifest" | sudo tee "$trusted_manifest" >/dev/null sudo mv "$trusted_manifest" "$manifest" jq -e ' - (.comparison.pass == false) or - (.comparison.baseline.status == "pass" and .comparison.candidate.status == "pass") + (.comparison.outcome == "pass" and .comparison.pass == true and + .comparison.baseline.status == "pass" and .comparison.candidate.status == "pass") or + (.comparison.outcome == "blocked" and .comparison.pass == false and + (.comparison.baseline.status == "blocked" or .comparison.candidate.status == "blocked")) or + (.comparison.outcome == "fail" and .comparison.pass == false) ' "$manifest" >/dev/null token="$(sudo jq -r '.sutToken' "$SUT_CREDENTIAL_DIR/credential.json")" @@ -1223,7 +1234,7 @@ jobs: echo "Mantis agent did not produce ${manifest}." >&2 exit 1 fi - comparison_status="$(jq -r 'if .comparison.pass then "pass" else "fail" end' "$manifest")" + comparison_status="$(jq -er '.comparison.outcome | select(. == "pass" or . == "fail" or . == "blocked")' "$manifest")" echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT" - name: Upload Mantis Telegram desktop artifacts @@ -1326,7 +1337,7 @@ jobs: } - name: Fail when Mantis Telegram desktop proof failed - if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' }} + if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' && steps.inspect.outputs.comparison_status != 'blocked' }} env: COMPARISON_STATUS: ${{ steps.inspect.outputs.comparison_status }} run: | diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index 6d0ba80828bd..e08e8304894d 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -47,19 +47,25 @@ enum CommandResolver { return ["/bin/sh", "-c", script] } - static func projectRoot() -> URL { - if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey), + static func projectRoot( + defaults: UserDefaults = AppDefaults.standard, + profile: AppProfile = .current, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + if let stored = defaults.string(forKey: projectRootDefaultsKey), let url = expandPath(stored), FileManager().fileExists(atPath: url.path) { return url } - let fallback = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Projects/openclaw") + if profile.isActive { + return profile.stateDirectoryURL(homeDirectory: homeDirectory) + } + let fallback = homeDirectory.appendingPathComponent("Projects/openclaw") if FileManager().fileExists(atPath: fallback.path) { return fallback } - return FileManager().homeDirectoryForCurrentUser + return homeDirectory } static func setProjectRoot(_ path: String) { diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index d8e940c97efd..422d69a59096 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -211,7 +211,7 @@ final class ControlChannel { self.setStateThrottled(.connected) PresenceReporter.shared.sendImmediate(reason: "connect") } catch { - let message = self.friendlyGatewayMessage(error) + let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict()) self.setStateThrottled(.degraded(message)) } } @@ -237,7 +237,7 @@ final class ControlChannel { self.setStateThrottled(.connected) return payload } catch { - let message = self.friendlyGatewayMessage(error) + let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict()) self.setStateThrottled(.degraded(message)) throw ControlChannelError.badResponse(message) } @@ -266,7 +266,7 @@ final class ControlChannel { self.setStateThrottled(.connected) return data } catch { - let message = self.friendlyGatewayMessage(error) + let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict()) self.setStateThrottled(.degraded(message)) throw ControlChannelError.badResponse(message) } @@ -283,13 +283,13 @@ final class ControlChannel { self.setStateThrottled(.connected) return data } catch { - let message = self.friendlyGatewayMessage(error) + let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict()) self.setStateThrottled(.degraded(message)) throw ControlChannelError.badResponse(message) } } - private func friendlyGatewayMessage(_ error: Error) -> String { + static func friendlyGatewayMessage(_ error: Error, configRoot: [String: Any]) -> String { // Map URLSession/WS errors into user-facing, actionable text. if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { return desc @@ -299,12 +299,24 @@ final class ControlChannel { return authIssue.statusMessage } + let mode = ConnectionModeResolver.resolve(root: configRoot).mode + let transport = GatewayRemoteConfig.resolveTransportResolution(root: configRoot) + let localPort = GatewayEnvironment.gatewayPort() + let directURL = mode == .remote && transport.transport == .direct ? transport.directURL : nil + let endpoint = if let url = directURL, let host = url.host, + let port = GatewayRemoteConfig.defaultPort(for: url) + { + "\(host.contains(":") && !host.hasPrefix("[") ? "[" + host + "]" : host):\(port)" + } else { + "localhost:\(localPort)" + } + // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. if let urlErr = error as? URLError, urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures { let reason = urlErr.failureURLString ?? urlErr.localizedDescription - let tokenKey = CommandResolver.connectionModeIsRemote() + let tokenKey = mode == .remote ? "gateway.remote.token" : "gateway.auth.token" return @@ -320,34 +332,37 @@ final class ControlChannel { if nsError.domain == "Gateway", nsError.localizedDescription.contains("hello failed (unexpected response)") { - let port = GatewayEnvironment.gatewayPort() + if directURL != nil { + return "Gateway handshake got non-gateway data on \(endpoint); check the Gateway URL and server." + } return """ - Gateway handshake got non-gateway data on localhost:\(port). + Gateway handshake got non-gateway data on \(endpoint). Another process is using that port or the SSH forward failed. - Stop the local gateway/port-forward on \(port) and retry Remote mode. + Stop the local gateway/port-forward on \(localPort) and retry Remote mode. """ } if let urlError = error as? URLError { - let port = GatewayEnvironment.gatewayPort() switch urlError.code { case .cancelled: - return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." + return "Gateway connection was closed; start the gateway (\(endpoint)) and retry." case .cannotFindHost, .cannotConnectToHost: - let isRemote = CommandResolver.connectionModeIsRemote() - if isRemote { + if directURL != nil { + return "Cannot reach gateway at \(endpoint); check the Gateway URL and remote gateway." + } + if mode == .remote { return """ - Cannot reach gateway at localhost:\(port). + Cannot reach gateway at \(endpoint). Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running. """ } - return "Cannot reach gateway at localhost:\(port); ensure the gateway is running." + return "Cannot reach gateway at \(endpoint); ensure the gateway is running." case .networkConnectionLost: return "Gateway connection dropped; gateway likely restarted—retry." case .timedOut: - return "Gateway request timed out; check gateway on localhost:\(port)." + return "Gateway request timed out; check gateway on \(endpoint)." case .notConnectedToInternet: - if Self.isLikelyLocalNetworkPermissionBlock() { + if Self.isLikelyLocalNetworkPermissionBlock(configRoot: configRoot) { return """ macOS is blocking OpenClaw Local Network access. Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app. @@ -360,8 +375,7 @@ final class ControlChannel { } if nsError.domain == "Gateway", nsError.code == 5 { - let port = GatewayEnvironment.gatewayPort() - return "Gateway request timed out; check the gateway process on localhost:\(port)." + return "Gateway request timed out; check the gateway process on \(endpoint)." } let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription @@ -370,10 +384,9 @@ final class ControlChannel { return "Gateway error: \(trimmed)" } - private static func isLikelyLocalNetworkPermissionBlock() -> Bool { - let root = OpenClawConfigFile.loadDict() - let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root) - guard ConnectionModeResolver.resolve(root: root).mode == .remote, + private static func isLikelyLocalNetworkPermissionBlock(configRoot: [String: Any]) -> Bool { + let resolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot) + guard ConnectionModeResolver.resolve(root: configRoot).mode == .remote, resolution.transport == .direct, let url = resolution.directURL, url.scheme?.lowercased() == "ws", @@ -412,10 +425,10 @@ final class ControlChannel { if mode == .remote { do { let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") + self.logger.info("control channel recovery ensured remote endpoint port=\(port, privacy: .public)") } catch { self.logger.error( - "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") + "control channel remote endpoint failed \(error.localizedDescription, privacy: .public)") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 6e445eb9a159..fe6d0107a5b5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -22,6 +22,30 @@ import Testing return (tmp, pnpmPath) } + @Test func `named profiles do not inherit the default development checkout`() throws { + let home = try makeTempDirForTests() + let checkout = home.appendingPathComponent("Projects/openclaw") + try FileManager.default.createDirectory(at: checkout, withIntermediateDirectories: true) + let defaults = self.makeDefaults() + let defaultProfile = AppProfile(environment: [:]) + let namedProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "isolated"]) + + #expect(CommandResolver.projectRoot( + defaults: defaults, + profile: defaultProfile, + homeDirectory: home).path == checkout.path) + #expect(CommandResolver.projectRoot( + defaults: defaults, + profile: namedProfile, + homeDirectory: home).path == home.appendingPathComponent(".openclaw-isolated").path) + + defaults.set(checkout.path, forKey: "openclaw.gatewayProjectRootPath") + #expect(CommandResolver.projectRoot( + defaults: defaults, + profile: namedProfile, + homeDirectory: home).path == checkout.path) + } + @Test func `prefers open claw binary`() async throws { let defaults = self.makeLocalDefaults() diff --git a/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift index 9c07993855dd..2afe5a195797 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift @@ -4,7 +4,7 @@ import Testing struct ControlChannelStateDebouncerTests { @Test func `terminal states apply immediately`() { - let start = Date(timeIntervalSince1970: 1_000) + let start = Date(timeIntervalSince1970: 1000) var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) let degradedDelay = debouncer.delayBeforeApplying( @@ -27,7 +27,7 @@ struct ControlChannelStateDebouncerTests { } @Test func `nonterminal states are debounced within interval`() { - let start = Date(timeIntervalSince1970: 1_000) + let start = Date(timeIntervalSince1970: 1000) var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) let soonDelay = debouncer.delayBeforeApplying( @@ -45,7 +45,7 @@ struct ControlChannelStateDebouncerTests { } @Test func `deferred apply resets debounce window`() { - let start = Date(timeIntervalSince1970: 1_000) + let start = Date(timeIntervalSince1970: 1000) var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start) debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5)) @@ -58,3 +58,134 @@ struct ControlChannelStateDebouncerTests { #expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001) } } + +@MainActor +struct ControlChannelGatewayMessageTests { + @Test(arguments: [ + URLError.Code.cannotFindHost, + URLError.Code.cannotConnectToHost, + URLError.Code.cancelled, + URLError.Code.timedOut, + ]) + func `direct gateway failures identify their actual endpoint`(code: URLError.Code) { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "ws://127.0.0.1:42674", + ], + ], + ] + + let message = ControlChannel.friendlyGatewayMessage(URLError(code), configRoot: root) + + #expect(message.contains("127.0.0.1:42674")) + #expect(!message.contains("SSH")) + } + + @Test func `direct gateway diagnostics never expose URL credentials`() { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://user:secret@gateway.example:9443/path?token=private", + ], + ], + ] + + let message = ControlChannel.friendlyGatewayMessage( + URLError(.cannotConnectToHost), + configRoot: root) + + #expect(message.contains("gateway.example:9443")) + #expect(!message.contains("secret")) + #expect(!message.contains("private")) + } + + @Test func `direct secure gateway diagnostics use the default TLS port`() { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://gateway.example", + ], + ], + ] + + let message = ControlChannel.friendlyGatewayMessage( + URLError(.cannotConnectToHost), + configRoot: root) + + #expect(message.contains("gateway.example:443")) + } + + @Test func `direct IPv6 gateway diagnostics bracket the endpoint host`() { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://[fd12:3456:789a::1]:9443", + ], + ], + ] + + let message = ControlChannel.friendlyGatewayMessage( + URLError(.cannotConnectToHost), + configRoot: root) + + #expect(message.contains("[fd12:3456:789a::1]:9443")) + } + + @Test func `SSH gateway failures preserve tunnel recovery guidance`() { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": ["transport": "ssh"], + ], + ] + + let message = ControlChannel.friendlyGatewayMessage( + URLError(.cannotConnectToHost), + configRoot: root) + + #expect(message.contains("localhost:")) + #expect(message.contains("SSH tunnel")) + } + + @Test func `direct gateway handshake failures identify the remote endpoint`() { + let root: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://gateway.example:9443", + ], + ], + ] + let error = NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"]) + + let message = ControlChannel.friendlyGatewayMessage(error, configRoot: root) + + #expect(message.contains("gateway.example:9443")) + #expect(!message.contains("SSH")) + } + + @Test func `local gateway failures preserve local recovery guidance`() { + let root: [String: Any] = ["gateway": ["mode": "local"]] + + let message = ControlChannel.friendlyGatewayMessage( + URLError(.cannotConnectToHost), + configRoot: root) + + #expect(message.contains("localhost:")) + #expect(message.contains("ensure the gateway is running")) + #expect(!message.contains("SSH")) + } +} diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index a99cae74dc30..4dde7f1fe924 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -850,7 +850,7 @@ Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`. - `cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window. + `cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling. On upgrade, run `openclaw doctor --fix` to import historical `~/.openclaw/cron/jobs.json`, `jobs-state.json`, `jobs-quarantine.json`, and `runs/*.jsonl` files into SQLite and archive the originals with a `.migrated` suffix. Malformed job rows remain recoverable in SQLite while valid jobs keep running. diff --git a/docs/ci.md b/docs/ci.md index 5d6c66795dbf..3cc52aa816d2 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -42,6 +42,7 @@ dispatch. | `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes | | `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes | | `checks-node-*` | Changed-target Node tests on pull requests; compact integration shards on `main`; metadata-complete compact fallback on broad PRs; full named shards on manual and release runs | Node-relevant changes | +| `docker-seed-e2e` | One Docker scheduler job for the executable `mcp-channels`, `cron-mcp-cleanup`, and `mcp-code-mode-gateway` owner lanes | PR changes to their seed helpers or CI gate owners | | `check-*` | Sharded main local gate equivalent: guards, transient npm-lock validation, bundled-channel config metadata, prod types, lint, dependencies, test types | Node-relevant changes | | `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture; the pure-reporting plugin SDK API diff runs on manual and release dispatches only | Node-relevant changes | | `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Full Release Validation and manual dispatches only | @@ -57,6 +58,14 @@ dispatch. | `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | | `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | +The rare path-triggered `docker-seed-e2e` job selects only the executable +owners of changed seed helpers and runs them through one scheduler invocation. +Trusted same-repository pull requests use one 16-vCPU Blacksmith runner with +main and tail parallelism set to 3; GitHub-hosted, fork, and retry paths run the +same selected lanes serially. The job is part of `openclaw/ci-gate`. It adds at +most one runner registration during an affected pull-request window and adds no +registrations for unrelated pull requests. + Standalone Periphery workflows enforce zero dead-code findings for the iOS and macOS apps. The shared OpenClawKit workflow scans both consumers in parallel and reports a declaration only when Periphery emits the same Swift USR from both builds. Its generated `OpenClawProtocol/GatewayModels.swift` schema contract is retained as generator-owned code rather than treated as app-local dead code. ## Fail-fast order diff --git a/docs/cli/cron.md b/docs/cli/cron.md index ef038c8728d6..aca6580503c7 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -233,7 +233,7 @@ The scheduler does not classify final-output prose or approval-looking refusal p Retention behavior: - `cron.sessionRetention` (default `24h`, or `false` to disable; a zero duration such as `"0h"` also disables) prunes completed isolated run sessions. -- Run history keeps the newest 2000 terminal rows per job. Lost rows retain the standard 24-hour lost-task cleanup window. +- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling. ## Migrating older jobs diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index a61f0cf0e667..db20a91a78f3 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -215,8 +215,10 @@ openclaw sessions cleanup --json - Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, trajectory rows, and legacy trajectory sidecars. It does not - prune cron run history, which automatically keeps the newest 2000 rows per job - ([Cron configuration](/automation/cron-jobs#configuration)). + prune cron run history. Task maintenance retains terminal cron history for 7 + days (`lost` rows for 24 hours) and enforces the newest 2000 rows per job and + history class as an additional ceiling ([Task maintenance](/automation/tasks#automatic-maintenance), + [Cron configuration](/automation/cron-jobs#configuration)). - Cleanup also prunes unreferenced legacy/archive transcript artifacts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; artifacts still referenced by SQLite diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 075c8fc1affa..eab1e62ba652 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1646,7 +1646,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway - `enabled`: execute stored automation jobs (default: `true`). Set `false` to pause all automation execution without deleting jobs. - `triggers.enabled`: run event-driven automation triggers (default: `true`). Set `false` to disable condition triggers, script payloads, and stream schedules. - `sessionRetention`: how long to keep completed isolated automation run sessions before pruning SQLite session rows. Also controls cleanup of archived deleted automation transcripts. Default: `24h`; set `false` or a zero duration such as `"0h"` to disable (negative durations are invalid). -- Run history automatically keeps the newest 2000 terminal rows per job. Lost rows retain their 24-hour cleanup window. +- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling. - `webhookToken`: bearer token used for automation webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. - `webhookSsrfPolicy`: shared outbound SSRF policy for primary, completion, failure-destination, and failure-alert webhooks. Private/internal targets are blocked when omitted. Prefer exact `allowedHostnames`; use `dangerouslyAllowPrivateNetwork: true` only for trusted private-network receivers. The narrow fake-IP proxy flags are `allowRfc2544BenchmarkRange` and `allowIpv6UniqueLocalRange`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9b06ff900a92..14bcdc3a1fe8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -415,7 +415,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`. ``` - `sessionRetention`: prune completed isolated run sessions from SQLite session rows (default `24h`; set `false` or a zero duration such as `"0h"` to disable). - - Run history automatically keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window. + - Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling. - See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 070183abe099..70ba64f368c1 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -110,7 +110,7 @@ artifacts before importing. Isolated cron runs create their own session entries/transcripts with dedicated retention: - `cron.sessionRetention` (default `"24h"`) prunes old isolated cron run sessions from the store; `false` or a zero duration such as `"0h"` disables. -- Run history keeps the newest 2000 terminal rows per cron job. Lost rows retain their 24-hour cleanup window. +- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling. When cron force-creates a new isolated run session, it sanitizes the previous `cron:` session entry before writing the new row: it carries safe preferences (thinking/fast/verbose/reasoning settings, labels, display name) and explicit user-selected model/auth overrides, but drops ambient conversation context (channel/group routing, send/queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot inherit stale delivery or runtime authority from an older run. diff --git a/extensions/browser/plugin-registration.node-host-laziness.test.ts b/extensions/browser/plugin-registration.node-host-laziness.test.ts new file mode 100644 index 000000000000..ced37c494ac8 --- /dev/null +++ b/extensions/browser/plugin-registration.node-host-laziness.test.ts @@ -0,0 +1,27 @@ +import { expect, it, vi } from "vitest"; + +const cleanupMocks = vi.hoisted(() => ({ + ensureBrowserProxyUploadCleanup: vi.fn(async () => undefined), +})); + +vi.mock("./register.runtime.js", () => { + throw new Error("node-host availability must not load the broad browser runtime"); +}); + +vi.mock("./src/browser-proxy-upload-cleanup.runtime.js", () => ({ + ensureBrowserProxyUploadCleanup: cleanupMocks.ensureBrowserProxyUploadCleanup, +})); + +const { browserPluginNodeHostCommands } = await import("./plugin-registration.js"); + +it("starts node-host upload cleanup without loading the broad browser runtime", async () => { + const uploadCommand = browserPluginNodeHostCommands.find( + (command) => command.command === "browser.proxy.upload.v1", + ); + + uploadCommand?.watchAvailability?.({ config: {}, env: {} }, vi.fn()); + + await vi.waitFor(() => { + expect(cleanupMocks.ensureBrowserProxyUploadCleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index f993dd10afce..35e77fd4d912 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -45,6 +45,9 @@ const logger = createSubsystemLogger("browser"); const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule( () => import("./register.runtime.js"), ); +const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeModule( + () => import("./src/browser-proxy-upload-cleanup.runtime.js"), +); function deriveChatTypeFromSessionKey( sessionKey: string | undefined, @@ -201,7 +204,7 @@ function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeH ...(command === BROWSER_PROXY_UPLOAD_COMMAND ? { watchAvailability: () => { - void loadBrowserRegistrationRuntimeModule() + void loadBrowserUploadCleanupRuntimeModule() .then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup()) .catch((error: unknown) => { logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`); diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts index a65baac6a603..a55e9818d4de 100644 --- a/extensions/browser/register.runtime.ts +++ b/extensions/browser/register.runtime.ts @@ -3,7 +3,6 @@ * registration lazy-load these exports when browser runtime behavior is needed. */ export { createBrowserTool } from "./src/browser-tool.js"; -export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js"; export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js"; export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js"; export { createBrowserPluginService } from "./src/plugin-service.js"; diff --git a/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts b/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts new file mode 100644 index 000000000000..bbbec63b5bd8 --- /dev/null +++ b/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts @@ -0,0 +1 @@ +export { ensureBrowserProxyUploadCleanup } from "./browser-proxy-upload.js"; diff --git a/extensions/qa-lab/src/confidence-report.test.ts b/extensions/qa-lab/src/confidence-report.test.ts index 19b4235db0fb..1d28e70e25a0 100644 --- a/extensions/qa-lab/src/confidence-report.test.ts +++ b/extensions/qa-lab/src/confidence-report.test.ts @@ -37,6 +37,38 @@ describe("qa confidence report", () => { return filePath; } + async function buildStrictSuiteReport(payload: Record, withBackfill = false) { + await writeJson("report-only/qa-suite-summary.json", payload); + const lanes: QaConfidenceManifest["lanes"] = [ + { + id: "report-only", + title: "Report-only", + kind: "qa-suite-summary", + artifact: "report-only/qa-suite-summary.json", + required: true, + ...(withBackfill ? { skipBackfillLane: "backfill" } : {}), + }, + ]; + if (withBackfill) { + await writeJson("backfill/qa-suite-summary.json", { + counts: { total: 1, passed: 1, failed: 0, skipped: 0 }, + }); + lanes.push({ + id: "backfill", + title: "Passing backfill", + kind: "qa-suite-summary", + artifact: "backfill/qa-suite-summary.json", + required: true, + }); + } + return buildQaConfidenceReport({ + manifest: { version: 1, profile: "confidence-regression", lanes }, + artifactRoot: tempRoot, + strictZeroUnknowns: true, + strictGlobalPass: true, + }); + } + it("passes strict zero-unknowns when every lane passes or has an allowed blocked verdict", async () => { await writeJson("tool-defaults/qa-suite-summary.json", { counts: { total: 20, passed: 18, skipped: 2, failed: 0 }, @@ -276,31 +308,11 @@ describe("qa confidence report", () => { }); it("fails strict global pass for skipped suite rows until a backfill lane passes", async () => { - await writeJson("report-only/qa-suite-summary.json", { + const report = await buildStrictSuiteReport({ counts: { total: 3, passed: 2, skipped: 1, failed: 0 }, scenarios: [], }); - const report = await buildQaConfidenceReport({ - manifest: { - version: 1, - profile: "codex-100", - lanes: [ - { - id: "report-only", - title: "Report-only", - kind: "qa-suite-summary", - artifact: "report-only/qa-suite-summary.json", - required: true, - }, - ], - }, - artifactRoot: tempRoot, - strictZeroUnknowns: true, - strictGlobalPass: true, - generatedAt: "2026-05-12T00:00:00.000Z", - }); - expect(report.zeroUnknowns).toBe(true); expect(report.globalPass).toBe(false); expect(report.failures).toEqual([ @@ -308,6 +320,82 @@ describe("qa confidence report", () => { ]); }); + it.each([ + ["count-backed", "skip"], + ["count-backed", "skipped"], + ["legacy", "skip"], + ["legacy", "skipped"], + ["unverified-pass-count", "skip"], + ["unverified-pass-count", "skipped"], + ] as const)( + "rejects %s suites containing only %s scenarios despite a passing backfill", + async (format, skippedStatus) => { + const report = await buildStrictSuiteReport( + { + ...(format === "count-backed" + ? { counts: { total: 1, passed: 0, failed: 0, skipped: 1 } } + : format === "unverified-pass-count" + ? { counts: { passed: 1, failed: 0 } } + : {}), + scenarios: [{ name: "never executed", status: skippedStatus }], + }, + true, + ); + + expect(report.pass).toBe(false); + expect(report.globalPass).toBe(false); + expect(report.lanes[0]).toMatchObject({ status: "unknown" }); + expect(report.lanes[0]?.details).toContain("no executed scenarios"); + expect(report.lanes[1]).toMatchObject({ status: "pass" }); + }, + ); + + it.each([ + ["skip", undefined], + ["skipped", undefined], + ["count-reported skip", 1], + ] as const)( + "requires a passing backfill for legacy suites containing a pass and %s", + async (skippedStatus, explicitSkippedCount) => { + const artifact = { + ...(explicitSkippedCount === undefined + ? {} + : { counts: { skipped: explicitSkippedCount } }), + scenarios: [ + { name: "executed", status: "pass" }, + ...(explicitSkippedCount === undefined + ? [{ name: "not executed", status: skippedStatus }] + : []), + ], + }; + + for (const hasBackfill of [false, true]) { + const report = await buildStrictSuiteReport(artifact, hasBackfill); + + expect(report.pass).toBe(hasBackfill); + expect(report.globalPass).toBe(hasBackfill); + expect(report.lanes[0]).toMatchObject({ status: "pass", skippedCount: 1 }); + if (hasBackfill) { + expect(report.lanes[0]).toMatchObject({ skipBackfilled: true }); + } else { + expect(report.failures).toEqual([ + "report-only has 1 skipped row(s) with no passing backfill lane", + ]); + } + } + }, + ); + + it("accepts a positive count-only suite without scenario rows", async () => { + const report = await buildStrictSuiteReport({ + counts: { total: 1, passed: 1, failed: 0, skipped: 0 }, + }); + + expect(report.pass).toBe(true); + expect(report.globalPass).toBe(true); + expect(report.lanes[0]).toMatchObject({ status: "pass" }); + }); + it("infers skipped suite rows from totals and scenario status", async () => { for (const [artifact, expectedDetail] of [ [{ counts: { total: 3, passed: 2, failed: 0 }, scenarios: [] }, "counts.skipped=1"], @@ -322,27 +410,7 @@ describe("qa confidence report", () => { "counts.skipped=1", ], ] as const) { - await writeJson("report-only/qa-suite-summary.json", artifact); - - const report = await buildQaConfidenceReport({ - manifest: { - version: 1, - profile: "codex-100", - lanes: [ - { - id: "report-only", - title: "Report-only", - kind: "qa-suite-summary", - artifact: "report-only/qa-suite-summary.json", - required: true, - }, - ], - }, - artifactRoot: tempRoot, - strictZeroUnknowns: true, - strictGlobalPass: true, - generatedAt: "2026-05-12T00:00:00.000Z", - }); + const report = await buildStrictSuiteReport(artifact); expect(report.globalPass).toBe(false); expect(report.failures).toEqual([ @@ -369,27 +437,7 @@ describe("qa confidence report", () => { "unsupported non-pass status", ], ] as const) { - await writeJson("report-only/qa-suite-summary.json", artifact); - - const report = await buildQaConfidenceReport({ - manifest: { - version: 1, - profile: "codex-100", - lanes: [ - { - id: "report-only", - title: "Report-only", - kind: "qa-suite-summary", - artifact: "report-only/qa-suite-summary.json", - required: true, - }, - ], - }, - artifactRoot: tempRoot, - strictZeroUnknowns: true, - strictGlobalPass: true, - generatedAt: "2026-05-12T00:00:00.000Z", - }); + const report = await buildStrictSuiteReport(artifact); expect(report.pass).toBe(false); expect(report.globalPass).toBe(false); @@ -468,49 +516,17 @@ describe("qa confidence report", () => { }); it("passes strict global pass when skipped suite rows are backfilled by a passing lane", async () => { - await writeJson("report-only/qa-suite-summary.json", { - counts: { total: 3, passed: 2, skipped: 1, failed: 0 }, - scenarios: [], - }); - await writeJson("live-backfill/qa-suite-summary.json", { - counts: { total: 1, passed: 1, skipped: 0, failed: 0 }, - scenarios: [], - }); - - const report = await buildQaConfidenceReport({ - manifest: { - version: 1, - profile: "codex-100", - lanes: [ - { - id: "report-only", - title: "Report-only", - kind: "qa-suite-summary", - artifact: "report-only/qa-suite-summary.json", - required: true, - skipBackfillLane: "live-backfill", - }, - { - id: "live-backfill", - title: "Live backfill", - kind: "qa-suite-summary", - artifact: "live-backfill/qa-suite-summary.json", - required: true, - }, - ], - }, - artifactRoot: tempRoot, - strictZeroUnknowns: true, - strictGlobalPass: true, - generatedAt: "2026-05-12T00:00:00.000Z", - }); + const report = await buildStrictSuiteReport( + { counts: { total: 3, passed: 2, skipped: 1, failed: 0 }, scenarios: [] }, + true, + ); expect(report.pass).toBe(true); expect(report.zeroUnknowns).toBe(true); expect(report.globalPass).toBe(true); expect(report.lanes[0]).toMatchObject({ skippedCount: 1, - skipBackfillLane: "live-backfill", + skipBackfillLane: "backfill", skipBackfilled: true, }); }); diff --git a/extensions/qa-lab/src/confidence-report.ts b/extensions/qa-lab/src/confidence-report.ts index 6e9d2be2ccb0..c51b5077617c 100644 --- a/extensions/qa-lab/src/confidence-report.ts +++ b/extensions/qa-lab/src/confidence-report.ts @@ -390,9 +390,8 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation { const failedCount = readCount(counts?.failed); const explicitSkippedCount = readCount(counts?.skipped); const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : undefined; - const failedScenarios = scenarios?.filter( - (scenario) => isRecord(scenario) && scenario.status === "fail", - ); + const failedScenarioCount = + scenarios?.filter((scenario) => isRecord(scenario) && scenario.status === "fail").length ?? 0; const skippedScenarioCount = scenarios?.filter( (scenario) => @@ -407,14 +406,19 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation { scenario.status !== "skip" && scenario.status !== "skipped"), ).length ?? 0; - const hasScenarioRows = scenarios !== undefined && scenarios.length > 0; + const hasExecutedScenarios = + (failedCount ?? 0) > 0 || + scenarios?.some( + (scenario) => + isRecord(scenario) && (scenario.status === "pass" || scenario.status === "fail"), + ) === true || + ((scenarios?.length ?? 0) === 0 && (passedCount ?? 0) > 0); const gatewayLogSentinels = collectGatewayLogSentinels(payload); if (gatewayLogSentinels.length > 0) { const allEnvironmentBlocked = gatewayLogSentinels.every( (finding) => finding.verdict === "environment-blocked", ); - const suiteHasFailures = - (failedCount !== undefined && failedCount > 0) || (failedScenarios?.length ?? 0) > 0; + const suiteHasFailures = (failedCount ?? 0) > 0 || failedScenarioCount > 0; if (allEnvironmentBlocked && suiteHasFailures) { return { passed: false, @@ -436,31 +440,42 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation { details: `gateway log sentinel(s): ${formatGatewayLogSentinelSummary(gatewayLogSentinels)}`, }; } + if ( + failedCount !== undefined && + scenarios !== undefined && + Math.floor(failedCount) !== failedScenarioCount + ) { + return { + passed: false, + status: "unknown", + details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max( + 0, + Math.floor(failedCount), + )}, failed scenarios=${failedScenarioCount}`, + }; + } + if (unknownBlockingScenarioCount > 0) { + return { + passed: false, + status: "unknown", + details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`, + }; + } + if (failedCount === undefined && scenarios === undefined) { + return { + passed: false, + status: "unknown", + details: "qa-suite-summary missing counts.failed and scenarios[]", + }; + } + if (!hasExecutedScenarios) { + return { + passed: false, + status: "unknown", + details: "qa-suite-summary has no executed scenarios", + }; + } if (failedCount !== undefined) { - if (failedCount === 0 && !(totalCount !== undefined && totalCount > 0) && !hasScenarioRows) { - return { - passed: false, - status: "unknown", - details: "qa-suite-summary has no executed scenarios", - }; - } - if (failedScenarios !== undefined && Math.floor(failedCount) !== failedScenarios.length) { - return { - passed: false, - status: "unknown", - details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max( - 0, - Math.floor(failedCount), - )}, failed scenarios=${failedScenarios.length}`, - }; - } - if (unknownBlockingScenarioCount > 0) { - return { - passed: false, - status: "unknown", - details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`, - }; - } const inferredSkippedCount = totalCount === undefined || passedCount === undefined ? undefined @@ -483,41 +498,11 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation { ...(skippedCount === 0 ? {} : { skippedCount: Math.max(0, Math.floor(skippedCount)) }), }; } - if (!Array.isArray(payload.scenarios)) { - return { - passed: false, - status: "unknown", - details: "qa-suite-summary missing counts.failed and scenarios[]", - }; - } - if (payload.scenarios.length === 0) { - return { - passed: false, - status: "unknown", - details: "qa-suite-summary has no executed scenarios", - }; - } - const fallbackFailedScenarios = payload.scenarios.filter( - (scenario) => isRecord(scenario) && scenario.status === "fail", - ); - const fallbackUnknownBlockingScenarios = payload.scenarios.filter( - (scenario) => - !isRecord(scenario) || - (scenario.status !== "pass" && - scenario.status !== "fail" && - scenario.status !== "skip" && - scenario.status !== "skipped"), - ); - if (fallbackUnknownBlockingScenarios.length > 0) { - return { - passed: false, - status: "unknown", - details: `qa-suite-summary has ${fallbackUnknownBlockingScenarios.length} scenario row(s) with unsupported non-pass status`, - }; - } + const skippedCount = Math.max(explicitSkippedCount ?? 0, skippedScenarioCount); return { - passed: fallbackFailedScenarios.length === 0, - details: `qa-suite-summary failed scenarios=${fallbackFailedScenarios.length}`, + passed: failedScenarioCount === 0, + details: `qa-suite-summary failed scenarios=${failedScenarioCount}`, + ...(skippedCount === 0 ? {} : { skippedCount }), }; } diff --git a/extensions/qa-lab/src/profile-selection.test.ts b/extensions/qa-lab/src/profile-selection.test.ts index 7d45e70842fc..b1446cc0634a 100644 --- a/extensions/qa-lab/src/profile-selection.test.ts +++ b/extensions/qa-lab/src/profile-selection.test.ts @@ -52,6 +52,28 @@ describe("taxonomy profile scenario selection", () => { ).not.toContainEqual(expect.objectContaining({ sourcePath: unrelatedRefs[0] })); }); + it("selects the packaged first-run journey for release container setup", () => { + const catalog = readQaScenarioPack().scenarios; + const report = readQaScorecardTaxonomyReport(catalog); + const onboarding = catalog.find( + (scenario) => scenario.id === "docker-npm-onboard-channel-agent", + ); + const systemAgent = catalog.find((scenario) => scenario.id === "docker-system-agent-first-run"); + + expect(onboarding?.coverage?.primary).toContain("containers.first-run-onboarding"); + expect(systemAgent?.coverage?.primary).not.toContain("containers.first-run-onboarding"); + expect(systemAgent?.coverage?.secondary).toContain("containers.first-run-onboarding"); + expect(report.validationIssues).not.toContainEqual( + expect.objectContaining({ + code: "coverage-id-missing-primary-inventory", + ref: "containers.first-run-onboarding", + }), + ); + expect(report.profiles.find((profile) => profile.id === "release")?.scenarioRefs).toContain( + onboarding?.sourcePath, + ); + }); + it("derives channel defaults from catalog metadata and lane constraints", () => { const liveTelegram = resolveLiveTransportQaScenarioIds({ channelId: "telegram", diff --git a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml index 1f8457591194..33bdc1892afb 100644 --- a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml +++ b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml @@ -7,7 +7,6 @@ scenario: coverage: primary: - containers.docker-backed-agent-sandbox-support - secondary: - containers.first-run-onboarding objective: Verify a package-installed Docker runner can complete non-interactive onboarding, configure a channel, start Gateway-backed agent behavior, and complete a mocked model turn. successCriteria: diff --git a/scripts/control-ui-mock-cron.ts b/scripts/control-ui-mock-cron.ts index 77edce9864e9..2017a0a92caa 100644 --- a/scripts/control-ui-mock-cron.ts +++ b/scripts/control-ui-mock-cron.ts @@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) { })); const status: CronStatus = { enabled: true, + triggersEnabled: true, jobs: jobs.length, nextWakeAtMs: overdueJob.state?.nextRunAtMs, }; diff --git a/scripts/e2e/telegram-mantis-lane.ts b/scripts/e2e/telegram-mantis-lane.ts index 8984affa344b..f8f8391f6c64 100644 --- a/scripts/e2e/telegram-mantis-lane.ts +++ b/scripts/e2e/telegram-mantis-lane.ts @@ -507,7 +507,7 @@ function publishTerminalLaneFacts(params: { facts: unknown; lane: Lane; roots: Roots; - status: "fail" | "pass"; + status: "blocked" | "fail" | "pass"; sutAttestation?: SutAttestation; }): void { const privatePublished = path.join(params.roots.sessionRoot, "published", params.lane); @@ -1309,7 +1309,7 @@ async function finalize( facts, lane: state.lane, roots, - status: status === "complete" ? "pass" : "fail", + status: status === "complete" ? "pass" : status === "blocked" ? "blocked" : "fail", sutAttestation: state.sut.sutAttestation, }); fs.rmSync(activeFile(roots.sessionRoot, state.lane), { force: true }); diff --git a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts index 1175a27fe245..f7cb6a4f678e 100644 --- a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts +++ b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts @@ -1,10 +1,4 @@ // Packed Plugin Sdk Type Smoke script supports OpenClaw repository automation. -import type { - MemoryReadResult, - MemorySearchManager, -} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; -import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; - type PublicPluginSdkModules = [ typeof import("openclaw/plugin-sdk/core"), typeof import("openclaw/plugin-sdk/channel-entry-contract"), @@ -14,31 +8,5 @@ type PublicPluginSdkModules = [ ]; const resolvedModules = null as unknown as PublicPluginSdkModules; -declare const canonicalManagerRest: Omit; -declare const canonicalReadResult: MemoryReadResult; - -const legacyManager = { - ...canonicalManagerRest, - async readFile({ relPath }: { relPath: string }) { - return { text: "", path: relPath }; - }, -}; -const legacyRuntime = { - async getMemorySearchManager() { - return { manager: legacyManager }; - }, - resolveMemoryBackendConfig() { - return { backend: "builtin" as const }; - }, -} satisfies MemoryPluginRuntime; -type BareLegacyReadResult = { text: ""; path: string }; -const canonicalRejectsBareLegacy: BareLegacyReadResult extends MemoryReadResult ? false : true = - true; void resolvedModules; -void legacyRuntime; -void canonicalReadResult.from; -void canonicalReadResult.lines; -void canonicalReadResult.truncated; -void canonicalReadResult.nextFrom; -void canonicalRejectsBareLegacy; diff --git a/scripts/lib/ci-changed-node-test-plan.mts b/scripts/lib/ci-changed-node-test-plan.mts index 22ab1b39008d..486cbc333d82 100644 --- a/scripts/lib/ci-changed-node-test-plan.mts +++ b/scripts/lib/ci-changed-node-test-plan.mts @@ -45,6 +45,21 @@ const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12; // integration tests past the global timeout. const SERIAL_CHANGED_TARGET_RE = /^extensions\/memory-core\//u; const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts"; +const DOCKER_SEED_LANE_ORDER = [ + "mcp-channels", + "cron-mcp-cleanup", + "mcp-code-mode-gateway", +] as const; +type DockerSeedLane = (typeof DOCKER_SEED_LANE_ORDER)[number]; +const DOCKER_SEED_LANES_BY_PATH: Readonly> = { + ".github/workflows/ci.yml": DOCKER_SEED_LANE_ORDER, + "scripts/e2e/cron-mcp-cleanup-seed.ts": ["cron-mcp-cleanup"], + "scripts/e2e/docker-openai-seed.ts": DOCKER_SEED_LANE_ORDER, + "scripts/e2e/lib/mcp-code-mode-probe-server.ts": ["mcp-code-mode-gateway"], + "scripts/e2e/mcp-channels-seed.ts": ["mcp-channels"], + "scripts/e2e/mcp-code-mode-gateway-seed.ts": ["mcp-code-mode-gateway"], + "scripts/lib/ci-changed-node-test-plan.mts": DOCKER_SEED_LANE_ORDER, +}; const publicPluginSdkEntrySources = Object.values( buildPluginSdkEntrySources(publicPluginSdkEntrypoints), ); @@ -61,6 +76,17 @@ const splitNodeTestConfigs = new Set( fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs), ); +export function resolveChangedDockerSeedLanes(changedPaths: string[]) { + const selected = new Set(); + for (const changedPath of changedPaths) { + const normalizedPath = changedPath.replaceAll("\\", "/"); + for (const lane of DOCKER_SEED_LANES_BY_PATH[normalizedPath] ?? []) { + selected.add(lane); + } + } + return DOCKER_SEED_LANE_ORDER.filter((lane) => selected.has(lane)); +} + function isTestOnlyPath(changedPath: string) { return ( isTestFileTarget(changedPath) || diff --git a/scripts/lib/npm-pack-budget.mts b/scripts/lib/npm-pack-budget.mts index fbd7882547c4..b1d5e409a5e1 100644 --- a/scripts/lib/npm-pack-budget.mts +++ b/scripts/lib/npm-pack-budget.mts @@ -5,8 +5,9 @@ import { resolveNpmJsonEntries } from "./npm-json-output.mts"; // startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime // dependencies, including crypto wasm, so packaged installs do not miss Docker // and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat -// level while allowing that mirrored runtime surface. -const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024; +// level while allowing that mirrored runtime surface and the installed agent's +// bundled user documentation. +const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 204 * 1024 * 1024; function formatMiB(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; diff --git a/scripts/mantis/build-telegram-desktop-proof-evidence.mts b/scripts/mantis/build-telegram-desktop-proof-evidence.mts index cc42e48ff013..1dd1544745cc 100644 --- a/scripts/mantis/build-telegram-desktop-proof-evidence.mts +++ b/scripts/mantis/build-telegram-desktop-proof-evidence.mts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; type CliArgs = Record; type LaneName = "baseline" | "candidate"; +type LaneStatus = "blocked" | "fail" | "pass"; type SessionSummary = { artifacts?: Partial< Record< @@ -44,6 +45,7 @@ type TelegramDesktopProofManifest = { comparison: { baseline: { expected: string; status: string; ref?: string; sha?: string }; candidate: { expected: string; status: string; fixed: boolean; ref?: string; sha?: string }; + outcome: LaneStatus; pass: boolean; }; artifacts: EvidenceArtifact[]; @@ -195,8 +197,8 @@ function copyLaneArtifacts({ }); } -function laneStatus(lane: LoadedLane) { - return lane.status === "pass" ? "pass" : "fail"; +function laneStatus(lane: LoadedLane): LaneStatus { + return lane.status === "pass" || lane.status === "blocked" ? lane.status : "fail"; } function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expectedSha: string) { @@ -216,7 +218,7 @@ function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expect throw new Error(`SUT attestation mismatch for ${expectedLane}.`); } -function laneArtifactEntries(statuses: Record): EvidenceArtifact[] { +function laneArtifactEntries(statuses: Record): EvidenceArtifact[] { return LANES.flatMap(({ altPrefix, label, lane }) => [ { alt: `${altPrefix} native Telegram Desktop proof GIF`, @@ -287,7 +289,12 @@ function buildTelegramDesktopProofManifest({ }): TelegramDesktopProofManifest { const baselineStatus = laneStatus(baseline); const candidateStatus = laneStatus(candidate); - const pass = baselineStatus === "pass" && candidateStatus === "pass"; + const outcome = + baselineStatus === "fail" || candidateStatus === "fail" + ? "fail" + : baselineStatus === "blocked" || candidateStatus === "blocked" + ? "blocked" + : "pass"; return { schemaVersion: 1, id: "telegram-desktop-proof", @@ -309,7 +316,8 @@ function buildTelegramDesktopProofManifest({ status: candidateStatus, fixed: candidateStatus === "pass", }, - pass, + outcome, + pass: outcome === "pass", }, artifacts: laneArtifactEntries({ baseline: baselineStatus, candidate: candidateStatus }), }; diff --git a/scripts/mantis/publish-pr-evidence.mjs b/scripts/mantis/publish-pr-evidence.mjs index 5553707d0a5e..7fa73f9c1536 100644 --- a/scripts/mantis/publish-pr-evidence.mjs +++ b/scripts/mantis/publish-pr-evidence.mjs @@ -27,7 +27,7 @@ import { readBoundedResponseText } from "../lib/bounded-response.mjs"; /** * @typedef {{ * artifacts: EvidenceArtifact[], - * comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, pass?: boolean }, + * comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, outcome?: "blocked" | "fail" | "pass", pass?: boolean }, * id: string, * manifestDir: string, * scenario: string, @@ -385,6 +385,10 @@ function publicSummary(manifest) { return manifest.summary ?? "Mantis captured QA evidence for this scenario."; } function overallStatus(manifest) { + const outcome = manifest.comparison?.outcome; + if (outcome === "blocked" || outcome === "fail" || outcome === "pass") { + return outcome; + } const pass = manifest.comparison?.pass; return typeof pass === "boolean" ? String(pass) : ""; } @@ -396,6 +400,9 @@ export function shouldPublishPrComment(manifest, { requestSource } = {}) { if (!isTelegramDesktopProof(manifest) || hasVisibleProofArtifacts(manifest)) { return true; } + if (manifest.comparison?.outcome === "blocked") { + return true; + } if (requestSource === "pull_request_target") { return false; } diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 8c56d7b4e3d0..0e119b4d5d80 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -2113,10 +2113,7 @@ const EXACT_TOOLING_TARGETS = new Map([ [".github/actions/setup-pnpm-store-cache/action.yml", [packageAcceptance, workflowGuards]], [".github/actions/setup-pnpm-store-cache/ensure-node.sh", ["setup-pnpm-store-cache-ensure-node"]], ["test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", [dockerE2e, pluginPrerelease]], - [ - "scripts/e2e/lib/mcp-code-mode-probe-server.ts", - ["docker-e2e-seeds", "mcp-code-mode-gateway-client"], - ], + ["scripts/e2e/lib/mcp-code-mode-probe-server.ts", ["mcp-code-mode-gateway-client"]], ["scripts/e2e/cron-cli-docker.sh", [dockerBuild, "docker-e2e-observability"]], ["scripts/ios-release-upload.sh", ["ios-release-wrapper-args", "ios-release-fastlane-gates"]], ["scripts/release-verify-beta.ts", ["release-wrapper-scripts"]], @@ -2782,10 +2779,6 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [ ), ["mcp-code-mode-gateway-client", "session-log-mentions"], ], - [ - /^(?:scripts\/e2e\/(?:mcp-channels|mcp-code-mode-gateway|cron-mcp-cleanup)-seed\.ts)$/u, - ["docker-e2e-seeds"], - ], [ /^scripts\/e2e\/(?:mcp-channels|cron-cli|cron-mcp-cleanup)-docker\.sh$/u, ["docker-e2e-observability"], @@ -2800,10 +2793,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [ "src/cron/active-jobs-manual-run.test.ts", ], ], - [ - /^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u, - ["cron-mcp-cleanup-docker-client", "docker-e2e-seeds"], - ], + [/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u, ["cron-mcp-cleanup-docker-client"]], [ /^test\/e2e\/qa-lab\/runtime\/mcp-channels\.fixture\.ts$/u, ["test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", "cron-mcp-cleanup-docker-client"], diff --git a/src/agents/harness/context-engine-turn-attempt.test.ts b/src/agents/harness/context-engine-turn-attempt.test.ts index 6e6c0fc6738c..99bf70f890ea 100644 --- a/src/agents/harness/context-engine-turn-attempt.test.ts +++ b/src/agents/harness/context-engine-turn-attempt.test.ts @@ -330,14 +330,16 @@ describe("accepted context-engine turn finalization", () => { ), ).toMatchObject({ state: "blocked", failure: "stale" }); + const nextAdmission = { + ...admission, + logicalTurnId: "logical-turn-next", + }; await drainPendingContextEngineTurnsBeforeRun({ - admission, + admission: nextAdmission, lease, warn, }); - expect(lease.degradeBeforeStart).toHaveBeenCalledWith( - "pending durable turn advancement could not be completed before the next turn", - ); + expect(lease.degradeBeforeStart).not.toHaveBeenCalled(); const sibling = await appendTranscriptMessage(target, { message: { role: "assistant", content: "sibling" }, diff --git a/src/agents/harness/context-engine-turn-outbox.test.ts b/src/agents/harness/context-engine-turn-outbox.test.ts index 670b3e4e5ec8..a6fbd7fe5c21 100644 --- a/src/agents/harness/context-engine-turn-outbox.test.ts +++ b/src/agents/harness/context-engine-turn-outbox.test.ts @@ -137,6 +137,50 @@ describe("context-engine turn outbox", () => { ).toBeUndefined(); }); + it("keeps a row pending when its persisted payload has no state", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-state-")); + tempDirs.push(stateDir); + const database = openOpenClawAgentDatabase({ + agentId: "main", + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + const payload = createPayload({ + advancementKey: "session-a:missing-state", + databasePath: database.path, + sequence: 1, + sessionId: "session-a", + }); + enqueueContextEngineTurnCommit({ database, engineId: "test", payload }); + database.db + .prepare( + "UPDATE context_engine_turn_outbox SET payload_json = '{}' WHERE advancement_key = ?", + ) + .run(payload.boundary.admission.logicalTurnId); + const commitTurn = vi.fn(async () => ({ status: "committed" as const })); + const engine = { + info: { id: "test", name: "Test" }, + ingest: async () => ({ ingested: true }), + assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }), + compact: async () => ({ ok: true, compacted: false }), + commitTurn, + } satisfies ContextEngine; + + const result = await drainContextEngineTurnOutbox({ + database, + engine, + engineId: "test", + warn: vi.fn(), + }); + + expect(result.pending).toBe(true); + expect(commitTurn).not.toHaveBeenCalled(); + expect( + database.db + .prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?") + .get(payload.boundary.admission.logicalTurnId), + ).toBeDefined(); + }); + it("drains prior work before fresh-turn assembly and records dispatch admission", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-recovery-")); tempDirs.push(stateDir); @@ -372,7 +416,7 @@ describe("context-engine turn outbox", () => { }); }); - it("retains unrecoverable accepted recovery as a blocking marker", async () => { + it("retains unrecoverable accepted recovery as a terminal marker without blocking later turns", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-blocked-")); tempDirs.push(stateDir); const database = openOpenClawAgentDatabase({ @@ -417,6 +461,17 @@ describe("context-engine turn outbox", () => { expect.stringContaining("blocked unrecoverable turn advancement"), ); + enqueueContextEngineTurnCommit({ + database, + engineId: "test", + payload: createPayload({ + advancementKey: "session-a:later-ready", + databasePath: database.path, + sequence: 3, + sessionId: "session-a", + }), + }); + const engine = { info: { id: "test", @@ -445,22 +500,43 @@ describe("context-engine turn outbox", () => { deferDisposalUntil: vi.fn(), dispose: vi.fn(async () => undefined), } satisfies ContextEngineLogicalTurnLease; + const currentAdmission = { + ...payload.boundary.admission, + logicalTurnId: "session-a:current", + }; await drainPendingContextEngineTurnsBeforeRun({ - admission: payload.boundary.admission, + admission: currentAdmission, lease, warn, }); - expect(engine.commitTurn).not.toHaveBeenCalled(); - expect(degradeBeforeStart).toHaveBeenCalledWith( - "pending durable turn advancement could not be completed before the next turn", + expect(engine.commitTurn).toHaveBeenCalledOnce(); + expect(engine.commitTurn).toHaveBeenCalledWith( + expect.objectContaining({ advancementKey: "session-a:later-ready" }), ); + expect(degradeBeforeStart).not.toHaveBeenCalled(); expect( database.db .prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?") .get(payload.boundary.admission.logicalTurnId), ).toBeDefined(); + expect( + database.db + .prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?") + .get("session-a:later-ready"), + ).toBeUndefined(); + expect( + JSON.parse( + ( + database.db + .prepare( + "SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?", + ) + .get(currentAdmission.logicalTurnId) as { payload_json: string } + ).payload_json, + ), + ).toMatchObject({ state: "admitted" }); }); it("does not let later same-session turns overtake a failed commit", async () => { diff --git a/src/agents/harness/context-engine-turn-outbox.ts b/src/agents/harness/context-engine-turn-outbox.ts index 904e357a6396..b0a840c4c112 100644 --- a/src/agents/harness/context-engine-turn-outbox.ts +++ b/src/agents/harness/context-engine-turn-outbox.ts @@ -75,6 +75,12 @@ function oldestOutboxEnqueueSequence() { return /* kysely-allow-raw: Aggregate the closed implicit-rowid expression used for enqueue order. */ sql`MIN(context_engine_turn_outbox.rowid)`; } +function outboxPayloadRequiresAdvancement() { + // Blocked rows are terminal audit evidence, not retryable work. Keep them + // inspectable without letting them hold later same-session turns behind them. + return /* kysely-allow-raw: Payload state is owned by the closed outbox union above. */ sql`json_extract(context_engine_turn_outbox.payload_json, '$.state') IS NOT 'blocked'`; +} + export function isRetryableContextEngineTurnReadFailure( kind: ContextEngineTurnReadFailureKind, ): kind is "projection-unavailable" { @@ -356,7 +362,8 @@ export async function drainContextEngineTurnOutbox(params: { // Use it instead of wall-clock timestamps, which can collide. .select(oldestOutboxEnqueueSequence().as("oldest_enqueue_sequence")) .where("engine_id", "=", params.engineId) - .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null); + .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null) + .where(outboxPayloadRequiresAdvancement()); if (params.sessionId) { pendingSessionsQuery = pendingSessionsQuery.where("session_id", "=", params.sessionId); } @@ -382,6 +389,7 @@ export async function drainContextEngineTurnOutbox(params: { .where("engine_id", "=", params.engineId) .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null) .where("session_id", "=", sessionId) + .where(outboxPayloadRequiresAdvancement()) .orderBy(outboxEnqueueSequence(), "asc") .limit(1), ); @@ -409,7 +417,8 @@ function hasPendingContextEngineTurn( .selectFrom("context_engine_turn_outbox") .select("advancement_key") .where("engine_id", "=", params.engineId) - .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null); + .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null) + .where(outboxPayloadRequiresAdvancement()); if (params.sessionId) { query = query.where("session_id", "=", params.sessionId); } diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts index 0b34194d3606..fbf7924c6363 100644 --- a/src/agents/model-suppression.test.ts +++ b/src/agents/model-suppression.test.ts @@ -18,7 +18,9 @@ import { getCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot, } from "../plugins/current-plugin-metadata-snapshot.js"; +import * as pluginControlPlaneContext from "../plugins/plugin-control-plane-context.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; +import * as pluginMetadataSnapshot from "../plugins/plugin-metadata-snapshot.js"; import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { buildShouldSuppressBuiltInModelCore, @@ -34,6 +36,7 @@ describe("model suppression", () => { }); afterEach(() => { + vi.restoreAllMocks(); setCurrentPluginMetadataSnapshot(undefined); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -131,7 +134,7 @@ describe("model suppression", () => { expect(secondResolver).toHaveBeenCalledOnce(); }); - it("keeps concurrent scoped suppression resolvers isolated from process-current metadata", async () => { + it("reuses each concurrent generation's suppression resolver across A/B/A interleaving", async () => { const config = {} satisfies OpenClawConfig; const snapshotA = createPluginMetadataSnapshot({ config, @@ -165,7 +168,14 @@ describe("model suppression", () => { }); markAReady(); await holdA; - return result; + return [ + result, + shouldSuppressBuiltInModelCore({ + provider: "openai", + id: "generation-model", + config, + }), + ]; }, ); await aReady; @@ -181,8 +191,77 @@ describe("model suppression", () => { ); releaseA(); - await expect(resultA).resolves.toBe(true); + await expect(resultA).resolves.toEqual([true, true]); expect(resultB).toBe(false); + expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2); + }); + + it("keys generation resolvers by config identity and workspace", () => { + const configA = {} satisfies OpenClawConfig; + const configB = {} satisfies OpenClawConfig; + const snapshot = createPluginMetadataSnapshot({ + config: configA, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined); + + const check = (config: OpenClawConfig, workspaceDir: string) => + withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => + shouldSuppressBuiltInModelCore({ + provider: "openai", + id: "generation-model", + config, + workspaceDir, + }), + ); + + expect(check(configA, "/workspace/a")).toBe(false); + expect(check(configB, "/workspace/a")).toBe(false); + expect(check(configA, "/workspace/b")).toBe(false); + expect(check(configA, "/workspace/a")).toBe(false); + expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(3); + }); + + it("does not recompute content fingerprints on a stable generation cache hit", () => { + const config = {} satisfies OpenClawConfig; + const snapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined); + const controlPlaneFingerprint = vi.spyOn( + pluginControlPlaneContext, + "resolvePluginControlPlaneFingerprint", + ); + const envFingerprint = vi.spyOn(pluginMetadataSnapshot, "resolvePluginMetadataEnvFingerprint"); + + withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => { + shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config }); + controlPlaneFingerprint.mockClear(); + envFingerprint.mockClear(); + + shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config }); + + expect(controlPlaneFingerprint).not.toHaveBeenCalled(); + expect(envFingerprint).not.toHaveBeenCalled(); + }); + }); + + it("rebuilds a generation resolver after plugin metadata lifecycle caches clear", () => { + const config = {} satisfies OpenClawConfig; + const snapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined); + + withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => { + shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config }); + clearPluginMetadataLifecycleCaches(); + shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config }); + }); + + expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2); }); it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => { diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index 34ef2d846bc9..c234a74dfa04 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -6,29 +6,41 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { + getCurrentPluginMetadataSnapshot, + isCurrentPluginMetadataSnapshotRuntimeGeneration, +} from "../plugins/current-plugin-metadata-snapshot.js"; import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js"; import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; type ManifestSuppressionResolver = ReturnType; -type CachedManifestSuppressionResolver = { +type CachedStandaloneManifestSuppressionResolver = { config: OpenClawConfig | undefined; controlPlaneFingerprint: string; cwd: string; envFingerprint: string; - metadataSnapshot: unknown; + metadataSnapshot: PluginMetadataSnapshot | undefined; resolver: ManifestSuppressionResolver; workspaceDir: string | undefined; }; -let cachedManifestSuppressionResolver: CachedManifestSuppressionResolver | undefined; +const configlessRuntimeGeneration = {}; +let runtimeGenerationResolvers = new WeakMap< + PluginMetadataSnapshot, + WeakMap> +>(); +let cachedStandaloneManifestSuppressionResolver: + | CachedStandaloneManifestSuppressionResolver + | undefined; /** Clear cached manifest suppression resolver state for tests and metadata lifecycle resets. */ function clearModelSuppressionResolverCache(): void { - cachedManifestSuppressionResolver = undefined; + runtimeGenerationResolvers = new WeakMap(); + cachedStandaloneManifestSuppressionResolver = undefined; } registerPluginMetadataProcessMemoLifecycleClear(clearModelSuppressionResolverCache); @@ -38,7 +50,33 @@ function resolveCachedManifestSuppressionResolver(params: { env: NodeJS.ProcessEnv; workspaceDir?: string; }): ManifestSuppressionResolver { - const cached = cachedManifestSuppressionResolver; + const metadataSnapshot = getCurrentPluginMetadataSnapshot(params); + if (metadataSnapshot && isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)) { + let byConfig = runtimeGenerationResolvers.get(metadataSnapshot); + if (!byConfig) { + byConfig = new WeakMap(); + runtimeGenerationResolvers.set(metadataSnapshot, byConfig); + } + const configKey = params.config ?? configlessRuntimeGeneration; + let byWorkspace = byConfig.get(configKey); + if (!byWorkspace) { + byWorkspace = new Map(); + byConfig.set(configKey, byWorkspace); + } + const cached = byWorkspace.get(params.workspaceDir); + if (cached) { + return cached; + } + const resolver = buildManifestBuiltInModelSuppressionResolver({ + env: params.env, + ...(params.config ? { config: params.config } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); + byWorkspace.set(params.workspaceDir, resolver); + return resolver; + } + + const cached = cachedStandaloneManifestSuppressionResolver; const controlPlaneFingerprint = resolvePluginControlPlaneFingerprint({ ...(params.config ? { config: params.config } : {}), env: params.env, @@ -46,7 +84,6 @@ function resolveCachedManifestSuppressionResolver(params: { }); const cwd = process.cwd(); const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env); - const metadataSnapshot = getCurrentPluginMetadataSnapshot(params); if ( cached !== undefined && cached.config === params.config && @@ -63,7 +100,7 @@ function resolveCachedManifestSuppressionResolver(params: { ...(params.config ? { config: params.config } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); - cachedManifestSuppressionResolver = { + cachedStandaloneManifestSuppressionResolver = { config: params.config, controlPlaneFingerprint, cwd, diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index f4d0dccf7354..128a2d2013b4 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -771,6 +771,23 @@ describe("cron cli", () => { }); }); + it.each(["", "not-a-url"])("rejects invalid cron add --webhook %j", async (value) => { + await expectCronCommandExit([ + "cron", + "add", + "Webhook reminder", + "--at", + "20m", + "--system-event", + "Summarize the latest status", + "--webhook", + value, + ]); + + expectRuntimeErrorContaining("--webhook must be a valid http(s) URL"); + expect(callGatewayFromCli).not.toHaveBeenCalled(); + }); + it("accepts Hermes-style positional cron schedule and message on cron create", async () => { const params = await runCronAddAndGetParams([ "0 2 * * *", diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 6f89b04948a4..e5ce6439ca04 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -8,6 +8,7 @@ import type { Command } from "commander"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js"; import type { CronJob } from "../../cron/types.js"; +import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; @@ -203,8 +204,12 @@ export function registerCronAddCommand(cron: Command) { const hasAnnounce = Boolean(opts.announce) || opts.deliver === true; const hasNoDeliver = opts.deliver === false; - const webhookUrl = normalizeOptionalString(opts.webhook); - const hasWebhook = typeof opts.webhook === "string"; + const webhookUrl = + typeof opts.webhook === "string" ? normalizeHttpWebhookUrl(opts.webhook) : null; + if (typeof opts.webhook === "string" && !webhookUrl) { + throw new Error("--webhook must be a valid http(s) URL"); + } + const hasWebhook = Boolean(webhookUrl); const deliveryFlagCount = [hasAnnounce, hasNoDeliver, hasWebhook].filter( Boolean, ).length; diff --git a/src/cli/cron-cli/register.cron-edit-options.ts b/src/cli/cron-cli/register.cron-edit-options.ts index 6de48c3db2bb..2bb8d7728ce8 100644 --- a/src/cli/cron-cli/register.cron-edit-options.ts +++ b/src/cli/cron-cli/register.cron-edit-options.ts @@ -24,6 +24,7 @@ const assignIf = ( export async function resolveCronEditPayloadDeliveryPatch( opts: Record, loadExistingJob: () => Promise, + webhookUrl: string | undefined, ): Promise> { const patch: Record = {}; const hasSystemEventPatch = typeof opts.systemEvent === "string"; @@ -33,12 +34,15 @@ export async function resolveCronEditPayloadDeliveryPatch( if (commandShell && commandArgv) { throw new Error("Pass command payload either with --command or --command-argv, not both."); } + // Raw flag presence owns the set/clear mutex even when normalization omits a blank value. + const hasModel = typeof opts.model === "string"; const model = normalizeOptionalString(opts.model); - if (model && opts.clearModel) { + if (hasModel && opts.clearModel) { throw new Error("Use --model or --clear-model, not both"); } + const hasThinking = typeof opts.thinking === "string"; const thinking = normalizeOptionalString(opts.thinking); - if (thinking && opts.clearThinking) { + if (hasThinking && opts.clearThinking) { throw new Error("Use --thinking or --clear-thinking, not both"); } const fallbacks = parseCronFallbacks(opts.fallbacks); @@ -86,7 +90,7 @@ export async function resolveCronEditPayloadDeliveryPatch( throw new Error("Invalid --script-tool-budget (must be a positive integer)."); } - const hasWebhookDelivery = typeof opts.webhook === "string"; + const hasWebhookDelivery = Boolean(webhookUrl); const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery; const threadId = parseCronThreadIdOption(opts.threadId); @@ -272,8 +276,7 @@ export async function resolveCronEditPayloadDeliveryPatch( delivery.channel = channel ? channel : undefined; } if (hasWebhookDelivery) { - const webhook = normalizeOptionalString(opts.webhook) ?? ""; - delivery.to = webhook ? webhook : undefined; + delivery.to = webhookUrl; } else if (opts.clearTo) { delivery.to = null; } else if (typeof opts.to === "string") { diff --git a/src/cli/cron-cli/register.cron-edit.test.ts b/src/cli/cron-cli/register.cron-edit.test.ts index 3791d9e217a6..b39e85b84741 100644 --- a/src/cli/cron-cli/register.cron-edit.test.ts +++ b/src/cli/cron-cli/register.cron-edit.test.ts @@ -698,6 +698,18 @@ describe("cron edit command", () => { ); }); + it.each([ + ["--model", "", "--clear-model"], + ["--model", " ", "--clear-model"], + ["--thinking", "", "--clear-thinking"], + ["--thinking", " ", "--clear-thinking"], + ])("rejects blank %s %j combined with %s", async (flag, value, clearFlag) => { + await expectCronEditRejection( + [flag, value, clearFlag], + `Use ${flag} or ${clearFlag}, not both`, + ); + }); + it("stores an explicit wildcard with --clear-tools", async () => { callGatewayFromCli.mockImplementation(async (method: string) => { if (method === "cron.get") { @@ -1040,6 +1052,10 @@ describe("cron edit command", () => { exitSpy.mockRestore(); }); + it.each(["", "not-a-url"])("rejects invalid --webhook %j before gateway RPC", async (value) => { + await expectCronEditRejection(["--webhook", value], "--webhook must be a valid http(s) URL"); + }); + it("documents the delivery clear flags alongside the sibling --clear-model", () => { const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); const help = editCommand?.helpInformation() ?? ""; diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 6a873c49b993..ce94efd9ef07 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -7,6 +7,7 @@ import { import type { Command } from "commander"; import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js"; import type { CronJob } from "../../cron/types.js"; +import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { danger } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; @@ -221,7 +222,14 @@ export function registerCronEditCommand(cron: Command) { "--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.", ); } - const hasWebhookDelivery = typeof opts.webhook === "string"; + const webhookUrl = + typeof opts.webhook === "string" + ? (normalizeHttpWebhookUrl(opts.webhook) ?? undefined) + : undefined; + if (typeof opts.webhook === "string" && !webhookUrl) { + throw new Error("--webhook must be a valid http(s) URL"); + } + const hasWebhookDelivery = Boolean(webhookUrl); const deliveryModeFlagCount = [ Boolean(opts.announce), typeof opts.deliver === "boolean", @@ -413,7 +421,7 @@ export function registerCronEditCommand(cron: Command) { Object.assign( patch, - await resolveCronEditPayloadDeliveryPatch(opts, readExistingCronJob), + await resolveCronEditPayloadDeliveryPatch(opts, readExistingCronJob, webhookUrl), ); const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string"; diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 7dddef9c9cea..e110437b6e89 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -811,6 +811,19 @@ describe("gatherDaemonStatus", () => { } }, 1_000); + it.each(["bogus", "0", "-1", "1.5"])( + "rejects invalid status timeout %s before reading service state", + async (timeout) => { + await expect(gatherStatus({ rpc: { timeout } })).rejects.toThrow( + `Invalid --timeout. Use a positive millisecond value, e.g. --timeout 30000. Received: "${timeout}".`, + ); + + expect(serviceReadCommand).not.toHaveBeenCalled(); + expect(serviceIsLoaded).not.toHaveBeenCalled(); + expect(serviceReadRuntime).not.toHaveBeenCalled(); + }, + ); + it("keeps gateway status read-only when service management is unsupported", async () => { serviceReadCommand.mockResolvedValueOnce(null); serviceIsLoaded.mockResolvedValueOnce(false); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index fe9a9bd0395c..5e276f7234aa 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -1,6 +1,5 @@ // Collects daemon status from service files, config snapshots, ports, probes, and plugin drift. import fs from "node:fs/promises"; -import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import JSON5 from "json5"; @@ -66,6 +65,7 @@ import { } from "../../plugins/plugin-version-drift.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { VERSION } from "../../version.js"; +import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; import type { GatewayRpcOpts } from "./types.js"; @@ -597,7 +597,9 @@ export async function gatherDaemonStatus( allowExecSecretRefs?: boolean; } & FindExtraGatewayServicesOptions, ): Promise { - const timeoutMs = parseStrictPositiveInteger(opts.rpc.timeout ?? undefined) ?? 10_000; + const timeoutMs = parseTimeoutMsWithFallback(opts.rpc.timeout, 10_000, { + invalidType: "error", + }); const service = resolveGatewayService(); const serviceState = await readGatewayServiceState(service, { env: process.env, diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index c64b6826fa7f..e3f94146ec48 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -876,11 +876,16 @@ describe("exec approvals CLI", () => { }); }); - it("defaults allowlist add to wildcard agent", async () => { + it.each([ + { label: "by default", agentArgs: [] as string[], agentKey: "*" }, + { label: "for the explicit wildcard", agentArgs: ["--agent", "*"], agentKey: "*" }, + { label: "for a configured agent", agentArgs: ["--agent", "main"], agentKey: "main" }, + ])("adds an allowlist entry $label", async ({ agentArgs, agentKey }) => { + readBestEffortConfig.mockResolvedValue({ agents: { list: [{ id: "main" }] } }); const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals); updateExecApprovals.mockClear(); - await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]); + await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname", ...agentArgs]); expect(callGatewayFromCli.mock.calls.some((call) => call[0] === "exec.approvals.set")).toBe( false, @@ -889,12 +894,56 @@ describe("exec approvals CLI", () => { expect(updateExecApprovals).toHaveBeenCalledWith( expect.objectContaining({ baseHash: "hash-local" }), ); - if (requireRecord(saved.agents, "saved agents")["*"] === undefined) { - throw new Error("Expected wildcard exec approval agent entry"); + if (requireRecord(saved.agents, "saved agents")[agentKey] === undefined) { + throw new Error(`Expected ${agentKey} exec approval agent entry`); } + expect(readBestEffortConfig).toHaveBeenCalledTimes(agentKey === "main" ? 1 : 0); expect(loggedOutput()).toContain("Writing local approvals."); }); + it.each(["add", "remove"])( + "rejects an unknown agent before allowlist %s persistence", + async (operation) => { + readBestEffortConfig.mockResolvedValue({ agents: { list: [{ id: "main" }] } }); + const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals); + updateExecApprovals.mockClear(); + + await expect( + runApprovalsCommand([ + "approvals", + "allowlist", + operation, + "/usr/bin/uname", + "--agent", + "nope-agent", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toStrictEqual([ + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ]); + expect(updateExecApprovals).not.toHaveBeenCalled(); + expect(localSnapshot.file.agents).toEqual({}); + expect(loggedOutput()).not.toContain("Writing local approvals."); + }, + ); + + it.each(["add", "remove"])( + "rejects a blank agent before allowlist %s persistence", + async (operation) => { + const updateExecApprovals = vi.mocked(execApprovals.updateExecApprovals); + updateExecApprovals.mockClear(); + + await expect( + runApprovalsCommand(["approvals", "allowlist", operation, "/usr/bin/uname", "--agent", ""]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toStrictEqual(["--agent must not be blank"]); + expect(updateExecApprovals).not.toHaveBeenCalled(); + expect(localSnapshot.file.agents).toEqual({}); + }, + ); + it.each([ { label: "an already-allowlisted add", diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index f7896bba401a..ba935028f74e 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -22,6 +22,7 @@ import { renderTerminalSafeTable, } from "../../packages/terminal-core/src/table.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, type OperatorScope } from "../gateway/method-scopes.js"; import { readFileDescriptorBounded } from "../infra/boundary-file-read.js"; @@ -1011,8 +1012,7 @@ async function saveSnapshot( } function resolveAgentKey(value?: string | null): string { - const trimmed = normalizeOptionalString(value) ?? ""; - return trimmed ? trimmed : "*"; + return value == null ? "*" : requireTrimmedNonEmpty(value, "--agent must not be blank"); } function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null { @@ -1048,6 +1048,15 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{ agent: ExecApprovalsAgent; allowlistEntries: NonNullable; }> { + const agentKey = resolveAgentKey(opts.agent); + if (agentKey !== "*") { + const source = !opts.gateway && !opts.node ? "local" : opts.gateway ? "gateway" : "node"; + const { config } = await loadConfigForApprovalsTarget({ opts, source }); + if (!config) { + exitWithError("Config unavailable; cannot validate --agent."); + } + resolveConfiguredAgentId(config, agentKey); + } const { snapshot, nodeId, source, targetLabel, baseHash, kind } = await loadWritableSnapshotTarget(opts); if (kind === "native" || !isFileApprovalsSnapshot(snapshot)) { @@ -1058,7 +1067,6 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{ const file = snapshot.file; file.version = 1; - const agentKey = resolveAgentKey(opts.agent); const agent = ensureAgent(file, agentKey); const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : []; diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index abc5c02b4f12..9f80140afc3f 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -41,7 +41,7 @@ export class ExpectedCliError extends Error { } } -function isGatewayCredentialsCliError( +export function isGatewayCredentialsCliError( error: unknown, ): error is Error & { method: string; configPath: string } { // Keep the root failure renderer lean; importing gateway/call would pull the diff --git a/src/cli/hooks-cli.toggle.test.ts b/src/cli/hooks-cli.toggle.test.ts index 6a506c5f2c83..1f5742875a35 100644 --- a/src/cli/hooks-cli.toggle.test.ts +++ b/src/cli/hooks-cli.toggle.test.ts @@ -424,6 +424,28 @@ describe("hooks CLI metadata config keys", () => { expect(explicitFleet).toEqual(initialConfig); }); + it("rejects a blank hook agent before resolving a workspace", async () => { + configureExplicitFleet(); + + await expect( + createHooksProgram().parseAsync(["hooks", "list", "--agent", "", "--json"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(capture.runtimeErrors.at(-1)).toContain("--agent must not be blank"); + expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("rejects a blank parent hook agent before dispatching a subcommand", async () => { + await expect( + createHooksProgram().parseAsync(["hooks", "--agent", "", "list"], { from: "user" }), + ).rejects.toThrow("--agent must not be blank"); + + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + it("keeps the explicit owner in the offline hooks fallback", async () => { const explicitFleet = configureExplicitFleet(); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 6f89257571bc..71949f441bb7 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -78,6 +78,9 @@ type HooksReportTarget = { function resolveHooksReportTarget(config: OpenClawConfig, rawAgentId?: string): HooksReportTarget { const requested = rawAgentId?.trim(); + if (rawAgentId !== undefined && !requested) { + throw new Error("--agent must not be blank"); + } const requestedAgentId = requested ? normalizeAgentId(requested) : undefined; if (requestedAgentId) { resolveConfiguredAgentId(config, requestedAgentId); @@ -617,6 +620,9 @@ export function registerHooksCli(program: Command): void { Boolean(opts?.json || hooks.opts<{ json?: boolean }>().json); hooks.hook("preAction", (_thisCommand, actionCommand) => { const parentAgent = hooks.opts<{ agent?: string }>().agent; + if (parentAgent !== undefined && !parentAgent.trim()) { + throw new Error("--agent must not be blank"); + } if ( parentAgent && actionCommand !== hooks && diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index c5da9651e1a2..b87860bdec31 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -321,7 +321,7 @@ describe("skills verify CLI", () => { ).rejects.toThrow("__exit__:1"); const message = - 'Skill "nonexistent-skill-xyz" not found. Run `openclaw skills list` to see available skills.'; + 'Skill "nonexistent-skill-xyz" not found on ClawHub. Run `openclaw skills search nonexistent-skill-xyz` to find the right skill reference.'; if (human) { expect(mocks.runtimeStdout).toStrictEqual([]); expect(mocks.runtimeErrors).toStrictEqual([message]); diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 351fe6283c40..7471e5f6847b 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -361,6 +361,15 @@ describe("agentCliCommand", () => { expect(zeroTimeoutGatewayRequestMs).toBe(2_147_000_000); }); + it("rejects a blank agent before selecting a local or Gateway target", async () => { + await expect(agentCliCommand({ message: "hi", agent: "" }, runtime)).rejects.toThrow( + "--agent must not be blank", + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).not.toHaveBeenCalled(); + }); + it("clamps oversized gateway timeout seconds at the command boundary", async () => { await withTempStore(async () => { mockGatewaySuccessReply(); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 013f15a21f4d..e8ebe589401c 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -1235,6 +1235,9 @@ export async function agentCliCommand( runtime: RuntimeEnv, deps?: AgentCliDeps, ) { + if (opts.agent !== undefined && !opts.agent.trim()) { + throw new Error("--agent must not be blank"); + } protectJsonStdout(opts); const messageOpts = await resolveAgentMessageOpts(opts); // `/compact` cannot run as a plain CLI agent turn: the slash-command handler diff --git a/src/commands/backup-git.test.ts b/src/commands/backup-git.test.ts new file mode 100644 index 000000000000..3619bbfd3d0a --- /dev/null +++ b/src/commands/backup-git.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestRuntime } from "./test-runtime-config-helpers.js"; + +const mocks = vi.hoisted(() => ({ + createGitBackup: vi.fn(), + getRuntimeConfig: vi.fn(), + listRegisteredAgentDatabases: vi.fn(), + recordBackupRunOutcome: vi.fn(), + restoreGitBackupRef: vi.fn(), + verifyGitBackupRef: vi.fn(), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getRuntimeConfig: mocks.getRuntimeConfig }; +}); + +vi.mock("../snapshot/git-backup.js", () => ({ + createGitBackup: mocks.createGitBackup, + initializeGitBackupRepository: vi.fn(), + readGitBackupLog: vi.fn(), + restoreGitBackupRef: mocks.restoreGitBackupRef, + verifyGitBackupRef: mocks.verifyGitBackupRef, +})); + +vi.mock("../state/backup-run-records.js", () => ({ + recordBackupRunOutcome: mocks.recordBackupRunOutcome, +})); + +vi.mock("../state/openclaw-agent-db.js", () => ({ + listOpenClawRegisteredAgentDatabases: mocks.listRegisteredAgentDatabases, +})); + +import { + backupGitCreateCommand, + backupGitRestoreCommand, + backupGitVerifyCommand, +} from "./backup-git.js"; + +describe("Git backup command agent selection", () => { + beforeEach(() => { + mocks.createGitBackup.mockReset().mockResolvedValue({ + commit: "backup-commit", + noChanges: false, + pushed: false, + repositoryPath: "/tmp/repository", + }); + mocks.getRuntimeConfig.mockReset().mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "ops-team" }] }, + }); + mocks.listRegisteredAgentDatabases.mockReset().mockReturnValue([]); + mocks.recordBackupRunOutcome.mockReset(); + mocks.restoreGitBackupRef.mockReset().mockResolvedValue({ + commit: "backup-commit", + excludedTables: [], + targetPath: "/tmp/restored.sqlite", + }); + mocks.verifyGitBackupRef.mockReset().mockResolvedValue({ + commit: "backup-commit", + tables: [], + }); + vi.spyOn(fs, "realpath").mockImplementation(async (value) => path.resolve(String(value))); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates a backup for a configured normalized agent", async () => { + await backupGitCreateCommand(createTestRuntime(), { + repository: "/tmp/repository", + agents: ["Ops Team"], + }); + + expect(mocks.createGitBackup).toHaveBeenCalledWith( + expect.objectContaining({ + databases: [expect.objectContaining({ identity: { role: "agent", agentId: "ops-team" } })], + }), + ); + }); + + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["empty", "", "--agent must not be blank"], + ["whitespace-only", " ", "--agent must not be blank"], + ])("rejects an %s Git create agent", async (_label, agent, message) => { + await expect( + backupGitCreateCommand(createTestRuntime(), { + repository: "/tmp/repository", + agents: [agent], + }), + ).rejects.toThrow(message); + + expect(mocks.createGitBackup).not.toHaveBeenCalled(); + }); + + it.each([ + { label: "all", scope: { all: true } }, + { label: "global", scope: { global: true } }, + ])("keeps the $label Git create scope independent of configured agents", async ({ scope }) => { + await backupGitCreateCommand(createTestRuntime(), { + repository: "/tmp/repository", + ...scope, + }); + + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); + expect(mocks.createGitBackup).toHaveBeenCalledOnce(); + }); + + it("keeps the --all plus explicit-scope conflict ahead of agent validation", async () => { + await expect( + backupGitCreateCommand(createTestRuntime(), { + repository: "/tmp/repository", + all: true, + agents: ["nope-agent"], + }), + ).rejects.toThrow("Use --all by itself, or select --global and --agent scopes explicitly."); + + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); + expect(mocks.createGitBackup).not.toHaveBeenCalled(); + }); + + it("keeps artifact verify and restore available for an unconfigured agent", async () => { + await backupGitVerifyCommand(createTestRuntime(), { + repository: "/tmp/repository", + agent: "retired-agent", + }); + await backupGitRestoreCommand(createTestRuntime(), { + repository: "/tmp/repository", + agent: "retired-agent", + target: "/tmp/restored.sqlite", + }); + + expect(mocks.verifyGitBackupRef).toHaveBeenCalledWith( + expect.objectContaining({ identity: { role: "agent", agentId: "retired-agent" } }), + ); + expect(mocks.restoreGitBackupRef).toHaveBeenCalledWith( + expect.objectContaining({ identity: { role: "agent", agentId: "retired-agent" } }), + ); + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/backup-git.ts b/src/commands/backup-git.ts index 3554783db982..62f79c38bca5 100644 --- a/src/commands/backup-git.ts +++ b/src/commands/backup-git.ts @@ -1,5 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { formatErrorMessage } from "../infra/errors.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -45,14 +47,29 @@ function resolveRequiredPath(value: string | undefined, label: string): string { } async function resolveCreateDatabases(runtime: RuntimeEnv, options: BackupGitCreateOptions) { - const agents = [...new Set((options.agents ?? []).map((agent) => normalizeAgentId(agent)))]; - const explicit = options.global === true || agents.length > 0; + const normalizedAgents = [ + ...new Set( + (options.agents ?? []).map((agent) => { + const trimmed = agent.trim(); + if (!trimmed) { + throw new Error("--agent must not be blank"); + } + return normalizeAgentId(trimmed); + }), + ), + ]; + const explicit = options.global === true || normalizedAgents.length > 0; if (options.all && explicit) { throw new Error("Use --all by itself, or select --global and --agent scopes explicitly."); } if (!options.all && !explicit) { throw new Error("Choose at least one Git backup scope: --all, --global, or --agent ."); } + let agents: string[] = []; + if (normalizedAgents.length > 0) { + const config = getRuntimeConfig({ skipPluginValidation: true }); + agents = normalizedAgents.map((agent) => resolveConfiguredAgentId(config, agent)); + } const databases: Array<{ path: string; identity: GitBackupIdentity; diff --git a/src/commands/backup-schedule.test.ts b/src/commands/backup-schedule.test.ts index dd48dcfb438a..47db78bd76a6 100644 --- a/src/commands/backup-schedule.test.ts +++ b/src/commands/backup-schedule.test.ts @@ -9,6 +9,9 @@ const gatewayRpc = vi.hoisted(() => ({ call: vi.fn(), isImplicitLocalTarget: vi.fn(async () => true), })); +const configMocks = vi.hoisted(() => ({ + getRuntimeConfig: vi.fn(), +})); vi.mock("../cli/gateway-rpc.js", async (importOriginal) => { const actual = await importOriginal(); @@ -19,6 +22,11 @@ vi.mock("../cli/gateway-rpc.js", async (importOriginal) => { }; }); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getRuntimeConfig: configMocks.getRuntimeConfig }; +}); + import { GIT_BACKUP_PUSH_CREDENTIAL_WARNING } from "./backup-git.js"; import { backupDisableCommand, backupEnableCommand } from "./backup-schedule.js"; @@ -41,6 +49,9 @@ describe("scheduled backups", () => { beforeEach(() => { gatewayRpc.call.mockReset(); gatewayRpc.isImplicitLocalTarget.mockReset().mockResolvedValue(true); + configMocks.getRuntimeConfig.mockReset().mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "ops-team" }] }, + }); }); afterEach(async () => { @@ -94,6 +105,41 @@ describe("scheduled backups", () => { expect(runtime.error).not.toHaveBeenCalled(); }); + it("schedules a configured agent using its normalized id", async () => { + gatewayRpc.call.mockResolvedValue({ created: true, job: { id: "backup-job" } }); + const runtime = createTestRuntime(); + + await backupEnableCommand(runtime, { + repository: "/tmp/openclaw-backups", + agent: "Ops Team", + }); + + const spec = gatewayRpc.call.mock.calls[0]?.[2] as { payload: { argv: string[] } }; + expect(spec.payload.argv).toContain("ops-team"); + expect(spec.payload.argv).not.toContain("--all"); + }); + + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["empty", "", "--agent must not be blank"], + ["whitespace-only", " ", "--agent must not be blank"], + ])("rejects an %s scheduled backup agent", async (_label, agent, message) => { + const runtime = createTestRuntime(); + + await expect( + backupEnableCommand(runtime, { + repository: "/tmp/openclaw-backups", + agent, + }), + ).rejects.toThrow(message); + + expect(gatewayRpc.call).not.toHaveBeenCalled(); + }); + it("atomically converges an existing declaration and removes it idempotently", async () => { gatewayRpc.call.mockResolvedValueOnce({ created: false, diff --git a/src/commands/backup-schedule.ts b/src/commands/backup-schedule.ts index 25c8f6e52c9a..20ff0ac0c047 100644 --- a/src/commands/backup-schedule.ts +++ b/src/commands/backup-schedule.ts @@ -1,10 +1,12 @@ import path from "node:path"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; import { callGatewayFromCli, isImplicitLocalGatewayTargetFromCli, type GatewayRpcOpts, } from "../cli/gateway-rpc.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { CronJob } from "../cron/types.js"; import { executeGitCommand } from "../infra/git-exec.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -56,9 +58,18 @@ function buildScheduledArgv( redactSecrets: boolean, ): string[] { const agent = options.agent?.trim(); + if (options.agent !== undefined && !agent) { + throw new Error("--agent must not be blank"); + } if (options.globalOnly && agent) { throw new Error("Use either --global-only or --agent , not both."); } + const agentId = agent + ? resolveConfiguredAgentId( + getRuntimeConfig({ skipPluginValidation: true }), + normalizeAgentId(agent), + ) + : undefined; return [ "openclaw", "backup", @@ -66,11 +77,7 @@ function buildScheduledArgv( "create", "--repository", repositoryPath, - ...(options.globalOnly - ? ["--global"] - : agent - ? ["--agent", normalizeAgentId(agent)] - : ["--all"]), + ...(options.globalOnly ? ["--global"] : agentId ? ["--agent", agentId] : ["--all"]), ...(options.push ? ["--push"] : []), ...(redactSecrets ? ["--exclude-secrets"] : []), ]; diff --git a/src/commands/backup-sqlite.test.ts b/src/commands/backup-sqlite.test.ts index dddd2518823b..4744d39038cc 100644 --- a/src/commands/backup-sqlite.test.ts +++ b/src/commands/backup-sqlite.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -18,11 +18,23 @@ import { backupSqliteVerifyCommand, } from "./backup-sqlite.js"; +const configMocks = vi.hoisted(() => ({ + getRuntimeConfig: vi.fn(), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getRuntimeConfig: configMocks.getRuntimeConfig }; +}); + const tempDirs = useAutoCleanupTempDirTracker(afterEach); let previousStateDir: string | undefined; beforeEach(() => { previousStateDir = process.env.OPENCLAW_STATE_DIR; + configMocks.getRuntimeConfig.mockReset().mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "ops-team" }] }, + }); }); afterEach(() => { @@ -257,6 +269,24 @@ describe("SQLite backup commands", () => { ).rejects.toThrow("Choose exactly one SQLite snapshot source"); }); + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["empty", "", "--agent must not be blank"], + ["whitespace-only", " ", "--agent must not be blank"], + ])("rejects an %s SQLite snapshot agent", async (_label, agent, message) => { + process.env.OPENCLAW_STATE_DIR = tempDirs.make("openclaw-backup-sqlite-agent-rejection-"); + await expect( + backupSqliteCreateCommand(createRuntimeCapture(), { + agent, + repository: "/tmp/snapshots", + }), + ).rejects.toThrow(message); + }); + it("does not claim completion when a corrupt database also rejects outcome recording", async () => { const tempDir = tempDirs.make("openclaw-backup-sqlite-corrupt-"); const stateDir = path.join(tempDir, "state"); diff --git a/src/commands/backup-sqlite.ts b/src/commands/backup-sqlite.ts index 09877c95a781..04a87e458777 100644 --- a/src/commands/backup-sqlite.ts +++ b/src/commands/backup-sqlite.ts @@ -1,5 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -168,6 +170,9 @@ async function resolveSnapshotDatabase( options: BackupSqliteCreateOptions, ): Promise { const rawAgentId = options.agent?.trim(); + if (options.agent !== undefined && !rawAgentId) { + throw new Error("--agent must not be blank"); + } if (options.global === true && rawAgentId) { throw new Error("Choose exactly one SQLite snapshot source: --global or --agent ."); } @@ -180,7 +185,10 @@ async function resolveSnapshotDatabase( identity: { role: "global" }, }; } - const agentId = normalizeAgentId(rawAgentId); + const agentId = resolveConfiguredAgentId( + getRuntimeConfig({ skipPluginValidation: true }), + normalizeAgentId(rawAgentId), + ); return { path: await fs.realpath(resolveOpenClawAgentSqlitePath({ agentId })), identity: { role: "agent", agentId }, diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 80fc1641f987..c7f875c085d6 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -203,6 +203,16 @@ function requireReadOnlyPluginListCall(): unknown[] { } describe("config-only channels status output", () => { + it("guides operators when no channels are configured", async () => { + activeChannelPlugins.splice(0); + + const output = await formatLocalStatusSummary({ channels: {} }); + + expect(output).toContain( + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", + ); + }); + it("sanitizes channel and account display names in terminal output", async () => { const control = "\u001B]0;channels-status-injection\u0007"; registerSingleTestPlugin( diff --git a/src/commands/channels.resolve.test.ts b/src/commands/channels.resolve.test.ts index e1629939d46f..dce34836dc9c 100644 --- a/src/commands/channels.resolve.test.ts +++ b/src/commands/channels.resolve.test.ts @@ -90,6 +90,10 @@ describe("channelsResolveCommand", () => { }); it("uses installed channel plugins for explicit target resolution without installing", async () => { + mocks.loadConfig.mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "ops" }] }, + channels: {}, + }); const resolveTargets = vi.fn().mockResolvedValue([ { input: "friends", @@ -141,6 +145,30 @@ describe("channelsResolveCommand", () => { expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)"); }); + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["empty", "", "--agent must not be blank"], + ["whitespace-only", " ", "--agent must not be blank"], + ])("rejects an %s explicit agent before channel resolution", async (_label, agent, message) => { + mocks.loadConfig.mockReturnValue({ + agents: { list: [{ id: "main" }] }, + channels: {}, + }); + + await expect( + channelsResolveCommand({ agent, channel: "telegram", entries: ["friends"] }, runtime), + ).rejects.toThrow(message); + + expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(mocks.resolveInstallableChannelPlugin).not.toHaveBeenCalled(); + expect(mocks.resolveMessageChannelSelection).not.toHaveBeenCalled(); + }); + it("tells users to add an explicit catalog channel before resolving", async () => { mocks.resolveInstallableChannelPlugin.mockResolvedValue({ cfg: { channels: {} }, diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index fff7adb3b319..afac9fae2c1a 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -1,6 +1,7 @@ // Channels status command-flow tests cover gateway calls, config fallback, and timeout validation. import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js"; +import { GatewayTransportError } from "../gateway/transport-error.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { channelsStatusCommand } from "./channels/status.js"; import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js"; @@ -214,6 +215,18 @@ function createTokenOnlyPlugin() { }; } +function createGatewayTransportError(message = "Gateway not reachable (ECONNREFUSED).") { + return new GatewayTransportError({ + kind: "closed", + message, + connectionDetails: { + url: "ws://127.0.0.1:18997", + urlSource: "local loopback", + message: "Gateway target: ws://127.0.0.1:18997", + }, + }); +} + describe("channelsStatusCommand SecretRef fallback flow", () => { beforeEach(() => { mocks.callGateway.mockReset(); @@ -270,7 +283,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { }); it("keeps read-only fallback output when SecretRefs are unresolved", async () => { - mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.callGateway.mockRejectedValue(createGatewayTransportError()); mocks.requireValidConfig.mockResolvedValue({ secretResolved: false, channels: {} }); mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ resolvedConfig: { secretResolved: false, channels: {} }, @@ -284,6 +297,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { await channelsStatusCommand({ probe: false }, runtime as never); expect(errors.join("\n")).toContain("Gateway not reachable"); + expect(errors.join("\n")).not.toContain("Gateway auth unavailable"); expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledOnce(); const configResolutionRequest = mocks.resolveCommandConfigWithSecrets.mock.calls[0]?.[0]; expect(configResolutionRequest?.commandName).toBe("channels status"); @@ -294,6 +308,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { ), ).toBe(true); const joined = logs.join("\n"); + expect(joined).toContain("Gateway not reachable; showing config-only status."); + expect(joined).not.toContain("Gateway auth unavailable; showing config-only status."); expect(joined).toContain("configured, secret unavailable in this command path"); expect(joined).toContain("token:config (unavailable)"); }); @@ -319,6 +335,10 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(joined).toContain("Gateway auth unavailable; showing config-only status."); expect(joined).not.toContain("Gateway not reachable; showing config-only status."); expect(joined).toContain("configured, secret unavailable in this command path"); + + const { runtime: jsonRuntime, logs: jsonLogs } = createCapturingTestRuntime(); + await channelsStatusCommand({ json: true, probe: false }, jsonRuntime as never); + expect(JSON.parse(jsonLogs.at(-1) ?? "{}").gatewayAuthUnavailable).toBe(true); }); it("renders missing gateway credentials canonically before config-only status", async () => { @@ -434,7 +454,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { it("keeps JSON fallback structured without rendering config-only text", async () => { mocks.callGateway.mockRejectedValue( - new Error( + createGatewayTransportError( [ "gateway timeout after 3000ms", "Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible", diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index a3deeb5fcd38..1a5f4b4375ce 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -37,6 +37,14 @@ describe("channels command", () => { setActivePluginRegistry(createTestRegistry([])); }); + it("guides operators when no channels are configured", () => { + const lines = formatGatewayChannelsStatusLines({ channelAccounts: {} }); + + expect(lines).toContain( + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", + ); + }); + it("surfaces Signal runtime errors in channels status output", () => { const lines = formatGatewayChannelsStatusLines({ channelLabels: { diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index fc363a9e9556..9014de61377a 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -19,7 +19,11 @@ import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-sna import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { listManifestInstalledChannelIds } from "../channel-setup/discovery.js"; import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js"; -import { formatChannelAccountLabel, requireValidChannelConfig } from "./shared.js"; +import { + formatChannelAccountLabel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, + requireValidChannelConfig, +} from "./shared.js"; export type ChannelsListOptions = { json?: boolean; @@ -341,11 +345,7 @@ export async function channelsListCommand( } if (accountLines.length === 0 && catalogOnlyLines.length === 0) { lines.push( - theme.muted( - showAll - ? "- no chat channels found" - : "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", - ), + theme.muted(showAll ? "- no chat channels found" : NO_CONFIGURED_CHAT_CHANNELS_LINE), ); } else { for (const line of accountLines) { diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 7183e1a3ecb2..ba77c5f19c16 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { resolveConfiguredAgentId } from "../../agents/agent-scope-config.js"; import type { ChannelResolveKind, ChannelResolveResult, @@ -118,17 +119,6 @@ function formatResolveResult(result: ResolveResult): string { /** Resolve user/group/channel labels into plugin-specific stable target ids. */ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) { - const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const loadedRaw = getRuntimeConfig(); - let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({ - config: loadedRaw, - commandName: "channels resolve", - targetIds: getChannelsCommandSecretTargetIds(), - agentId: opts.agent, - mode: "read_only_operational", - runtime, - autoEnable: true, - }); const entries = normalizeStringEntries(opts.entries); if (entries.length === 0) { throw new Error( @@ -136,12 +126,29 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti ); } + const loadedRaw = getRuntimeConfig(); + const requestedAgent = opts.agent?.trim(); + if (opts.agent !== undefined && !requestedAgent) { + throw new Error("--agent must not be blank"); + } + const agentId = requestedAgent ? resolveConfiguredAgentId(loadedRaw, requestedAgent) : undefined; + const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); + let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({ + config: loadedRaw, + commandName: "channels resolve", + targetIds: getChannelsCommandSecretTargetIds(), + agentId, + mode: "read_only_operational", + runtime, + autoEnable: true, + }); + const explicitChannel = opts.channel?.trim(); const resolvedExplicit = explicitChannel ? await resolveInstallableChannelPlugin({ cfg, runtime, - agentId: opts.agent, + agentId, rawChannel: explicitChannel, allowInstall: false, supports: (plugin) => Boolean(plugin.resolver?.resolveTargets), @@ -168,7 +175,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti : await resolveMessageChannelSelection({ cfg, channel: opts.channel ?? null, - agentId: opts.agent, + agentId, }); const plugin = selection.plugin; if (!plugin?.resolver?.resolveTargets) { diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 3f1d33bc965f..424390ee101c 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -12,6 +12,9 @@ import { requireValidConfig, requireValidConfigFileSnapshot } from "../config-va export type ChatChannel = ChannelId; +export const NO_CONFIGURED_CHAT_CHANNELS_LINE = + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)"; + export { requireValidConfigFileSnapshot }; /** Load valid channel command config with read-only secret resolution applied. */ diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index eb74a7dd9c44..0a4b2e9bd327 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -23,6 +23,7 @@ import { appendTokenSourceBits, buildChannelAccountLine, type ChatChannel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, } from "./shared.js"; type ChannelStatusPluginLabel = { @@ -74,6 +75,7 @@ export async function formatConfigChannelsStatusLines( includeSetupFallbackPlugins: true, }).filter((plugin) => !requestedChannel || plugin.id === requestedChannel); const visibleChannelIds = new Set(); + const statusLinesStart = lines.length; for (const plugin of plugins) { visibleChannelIds.add(plugin.id); const accountIds = plugin.config.listAccountIds(cfg); @@ -127,6 +129,9 @@ export async function formatConfigChannelsStatusLines( lines.push(`- ${hint.label}: ${hint.repairHint}`); } } + if (lines.length === statusLinesStart) { + lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE)); + } lines.push(""); lines.push( diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 22609e8d5b4e..9a23829c8d45 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -21,6 +21,7 @@ import { appendTokenSourceBits, buildChannelAccountLine, type ChatChannel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, } from "./shared.js"; import { formatConfigChannelsStatusLines } from "./status-config-format.js"; import type { ChannelsStatusOptions } from "./status.js"; @@ -193,12 +194,16 @@ export function formatGatewayChannelsStatusLines(payload: Record>; } } + const accountLinesStart = lines.length; for (const channelId of Object.keys(accountPayloads).toSorted()) { const accounts = accountPayloads[channelId]; if (accounts && accounts.length > 0) { lines.push(...accountLines(channelId, accounts)); } } + if (lines.length === accountLinesStart) { + lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE)); + } lines.push(""); const issues = collectChannelStatusIssues(payload); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index f8e8c8ecf3c7..919cf631fe11 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,7 +1,11 @@ // Implements `openclaw channels status` with gateway status and config-only fallback. import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import { formatCliFailureLines, isExpectedCliError } from "../../cli/failure-output.js"; +import { + formatCliFailureLines, + isExpectedCliError, + isGatewayCredentialsCliError, +} from "../../cli/failure-output.js"; import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js"; import { withProgress } from "../../cli/progress.js"; import { callGateway } from "../../gateway/call.js"; @@ -77,7 +81,8 @@ export async function channelsStatusCommand( } catch (err) { const safeError = formatChannelsStatusError(err); const expectedError = isExpectedCliError(err); - const gatewayAuthUnavailable = expectedError || isGatewaySecretRefUnavailableError(err); + const gatewayAuthUnavailable = + isGatewayCredentialsCliError(err) || isGatewaySecretRefUnavailableError(err); const expectedErrorOutput = expectedError ? formatCliFailureLines({ title: "", error: err }).join("\n") : undefined; diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 5075bf22434e..2b1e14215152 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1858,6 +1858,33 @@ describe("doctor config flow", () => { expect(result.cfg.agents).not.toHaveProperty("list"); }); + it("stamps explicit ownership when Doctor migrates a markerless multi-agent list", async () => { + const rawConfig = { + agents: { + list: [{ id: "ops" }, { id: "research", model: "openai/research" }], + }, + }; + const result = await runDoctorConfigWithInput({ + config: migratePersistedImplicitMainRoster(rawConfig).config as OpenClawConfig, + parsedConfig: rawConfig, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.explicitSetPaths).toEqual([ + ["agents", "entries"], + ["agents", "ownership"], + ]); + expect(result.cfg.agents).toEqual({ + ownership: "explicit", + entries: { + ops: {}, + research: { model: "openai/research" }, + }, + }); + }); + it("materializes ambient roles for a multi-agent configured default", async () => { const rawConfig = { agents: { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 48544f98b845..89ab2e8e55f8 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -266,8 +266,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { const migratedRoster = readAgentRosterProperty(migrated); const migratedEntries = migratedRoster?.kind === "entries" ? migratedRoster.value : undefined; const { list: _legacyList, ...candidateAgents } = migrated.agents ?? {}; - const stampsExplicitOwnership = - legacyDefaultAgentId !== undefined && Object.keys(migratedEntries ?? {}).length > 1; + const stampsExplicitOwnership = Object.keys(migratedEntries ?? {}).length > 1; const rosterRepair = { config: { ...migrated, diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index 9302c0f759b9..14efca39a842 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -18,9 +18,9 @@ import type { import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; -import { redactSecretDegradationReason } from "../secrets/runtime-degraded-state.js"; import type { StatusSummary } from "../status/types.js"; import { VERSION } from "../version.js"; +import { projectDoctorSecretRuntimeDegradations } from "./doctor-secret-runtime-degradation.js"; import { GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE, GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE, @@ -94,14 +94,11 @@ export async function checkGatewayHealth(params: { }); healthOk = true; noteCliGatewayVersionSkew(status); - if (status.degradedSecretOwners && status.degradedSecretOwners.length > 0) { + const secretDegradations = projectDoctorSecretRuntimeDegradations(status); + if (secretDegradations.length > 0) { note( - status.degradedSecretOwners - .map( - (owner) => - `- ${owner.degradationState ?? "cold"} ${owner.ownerKind}:${owner.ownerId} (${owner.paths.join(", ")}): ${redactSecretDegradationReason(owner.reason)}` + - "\n Retry: openclaw secrets reload", - ) + secretDegradations + .map((owner) => `- ${owner.message}\n Retry: ${owner.retryHint}`) .join("\n"), "Secret runtime degradation", ); diff --git a/src/commands/doctor-lint.test.ts b/src/commands/doctor-lint.test.ts index 64182bed9a9e..45578dde3a53 100644 --- a/src/commands/doctor-lint.test.ts +++ b/src/commands/doctor-lint.test.ts @@ -6,6 +6,8 @@ import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import * as bundledHealthChecks from "../flows/bundled-health-checks.js"; +import { CORE_HEALTH_CHECKS } from "../flows/doctor-core-checks.js"; import { clearHealthChecksForTest, registerHealthCheck } from "../flows/health-check-registry.js"; import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-record-cache.js"; import { writePersistedInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js"; @@ -17,6 +19,8 @@ const mocks = vi.hoisted(() => ({ actualOpenNodeSqliteDatabase: vi.fn(), actualPrepareSqliteReadOnlyLocationSync: vi.fn(), actualReadConfigFileSnapshot: vi.fn(), + buildGatewayProbeConnectionDetails: vi.fn(), + callGateway: vi.fn(), openNodeSqliteDatabase: vi.fn(), prepareSqliteReadOnlyLocationSync: vi.fn(), readConfigFileSnapshot: vi.fn(), @@ -66,7 +70,14 @@ vi.mock("../flows/doctor-health-contributions.js", async (importOriginal) => { mocks.resolveDoctorContributionHealthChecks(...args), }; }); - +vi.mock("../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails, + callGateway: mocks.callGateway, + }; +}); const runtime = { log: vi.fn(), error: vi.fn(), @@ -79,6 +90,10 @@ describe("runDoctorLintCli", () => { beforeEach(() => { vi.clearAllMocks(); mocks.readConfigFileSnapshot.mockReset(); + mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({ + url: "ws://127.0.0.1:18789", + }); + mocks.callGateway.mockReset().mockResolvedValue({ degradedSecretOwners: [] }); mocks.openNodeSqliteDatabase.mockImplementation((...args: unknown[]) => mocks.actualOpenNodeSqliteDatabase(...args), ); @@ -113,29 +128,115 @@ describe("runDoctorLintCli", () => { } }); - it("reports the visible finding count in human output", async () => { + it.each([ + { label: "--only JSON", selection: "only", json: true }, + { label: "--only human text", selection: "only", json: false }, + { label: "--all JSON", selection: "all", json: true }, + { label: "--all human text", selection: "all", json: false }, + { label: "default JSON", selection: "default", json: true }, + { label: "default human text", selection: "default", json: false }, + ] as const)("keeps Gateway-owned secret degradation observable through $label", async (entry) => { + const gatewayCheck = CORE_HEALTH_CHECKS.find( + (check) => check.id === "core/doctor/gateway-health", + ); + expect(gatewayCheck).toBeDefined(); + const previousResolveChecks = + mocks.resolveDoctorContributionHealthChecks.getMockImplementation(); + mocks.resolveDoctorContributionHealthChecks.mockResolvedValue([gatewayCheck]); + const registerChecks = vi + .spyOn(bundledHealthChecks, "registerBundledHealthChecks") + .mockImplementation(() => {}); + const resolveStateMode = vi + .spyOn(bundledHealthChecks, "resolveBundledHealthCheckPluginStateMode") + .mockReturnValue("direct"); mocks.readConfigFileSnapshot.mockResolvedValue({ exists: true, valid: true, - config: {}, + config: { + gateway: { + mode: "local", + auth: { mode: "token", token: "SYNTHETIC_GATEWAY_SECRET" }, + }, + }, path: "/tmp/openclaw.json", }); - + mocks.callGateway.mockResolvedValue({ + degradedSecretOwners: [ + { + ownerKind: "account", + ownerId: "discord:ops", + state: "unavailable", + paths: ["channels.discord.accounts.ops.token"], + reason: + "secret reference was not found (env:default:PRIVATE_REF_ID=SYNTHETIC_OWNER_SECRET)", + }, + ], + }); const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: !entry.json }); + try { const exitCode = await runDoctorLintCli(runtime, { - severityMin: "error", - onlyIds: ["core/doctor/final-config-validation"], + ...(entry.json ? { json: true } : {}), + ...(entry.selection === "only" + ? { onlyIds: ["core/doctor/gateway-health"] } + : entry.selection === "all" + ? { includeAllChecks: true } + : {}), }); + const output = stdout.mock.calls.map(([line]) => String(line)).join(""); - expect(exitCode).toBe(0); - expect(String(stdout.mock.calls[0]?.[0])).toContain("0 finding(s)"); - expect(String(stdout.mock.calls[1]?.[0])).toBe(" no findings\n"); + if (entry.selection === "default") { + expect(exitCode).toBe(0); + if (entry.json) { + expect(JSON.parse(output)).toMatchObject({ + checksRun: 0, + checksSkipped: 1, + findings: [], + }); + } else { + expect(output).toContain("0 finding(s)"); + expect(output).toContain(" no findings\n"); + } + expect(mocks.buildGatewayProbeConnectionDetails).not.toHaveBeenCalled(); + expect(mocks.callGateway).not.toHaveBeenCalled(); + return; + } + + expect(exitCode).toBe(1); + expect(output).toContain("core/doctor/gateway-health"); + expect(output).toContain("cold account:discord:ops"); + expect(output).toContain("channels.discord.accounts.ops.token"); + expect(output).toContain("openclaw secrets reload"); + expect(output).not.toContain("SYNTHETIC_GATEWAY_SECRET"); + expect(output).not.toContain("SYNTHETIC_OWNER_SECRET"); + expect(output).not.toContain("PRIVATE_REF_ID"); + expect(mocks.callGateway).toHaveBeenCalledOnce(); + if (entry.json) { + expect(JSON.parse(output)).toMatchObject({ + ok: false, + checksRun: 1, + findings: [ + { + checkId: "core/doctor/gateway-health", + severity: "warning", + path: "channels.discord.accounts.ops.token", + target: "account:discord:ops", + }, + ], + }); + } else { + expect(output).toContain("[warning] core/doctor/gateway-health"); + } } finally { Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsTTY }); stdout.mockRestore(); + registerChecks.mockRestore(); + resolveStateMode.mockRestore(); + if (previousResolveChecks) { + mocks.resolveDoctorContributionHealthChecks.mockImplementation(previousResolveChecks); + } } }); diff --git a/src/commands/doctor-secret-runtime-degradation.ts b/src/commands/doctor-secret-runtime-degradation.ts new file mode 100644 index 000000000000..acaea2268442 --- /dev/null +++ b/src/commands/doctor-secret-runtime-degradation.ts @@ -0,0 +1,41 @@ +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { + redactSecretDegradationReason, + SECRET_DEGRADATION_RETRY_HINT, +} from "../secrets/runtime-degraded-state.js"; +import type { StatusSummary } from "../status/types.js"; + +const DOCTOR_SECRET_OWNER_ID_MAX_CHARS = 96; +const DOCTOR_SECRET_OWNER_PATH_MAX_CHARS = 120; +const DOCTOR_SECRET_OWNER_VISIBLE_PATHS = 3; + +function safeDoctorSecretOwnerText(value: string, maxChars: number): string { + const safe = sanitizeTerminalText(redactSensitiveUrlLikeString(value)); + return safe.length <= maxChars ? safe : `${truncateUtf16Safe(safe, maxChars - 1)}…`; +} + +/** Projects Gateway-owned secret degradation into the shared bounded Doctor display shape. */ +export function projectDoctorSecretRuntimeDegradations( + status: Pick, +) { + return (status.degradedSecretOwners ?? []).map((owner) => { + const ownerId = safeDoctorSecretOwnerText(owner.ownerId, DOCTOR_SECRET_OWNER_ID_MAX_CHARS); + const target = `${owner.ownerKind}:${ownerId}`; + const visiblePaths = owner.paths + .slice(0, DOCTOR_SECRET_OWNER_VISIBLE_PATHS) + .map((configPath) => + safeDoctorSecretOwnerText(configPath, DOCTOR_SECRET_OWNER_PATH_MAX_CHARS), + ); + const omittedPaths = owner.paths.length - visiblePaths.length; + const paths = + visiblePaths.join(", ") + (omittedPaths > 0 ? ` (+${omittedPaths} paths omitted)` : ""); + return { + message: `${owner.degradationState ?? "cold"} ${target} (${paths || "no affected paths reported"}): ${redactSecretDegradationReason(owner.reason)}`, + path: visiblePaths[0] ?? "gateway", + target, + retryHint: SECRET_DEGRADATION_RETRY_HINT, + }; + }); +} diff --git a/src/commands/doctor.refuses-newer-database-schemas.e2e.test.ts b/src/commands/doctor.refuses-newer-database-schemas.e2e.test.ts index 0b61080f5ce5..d8b2f7e10d15 100644 --- a/src/commands/doctor.refuses-newer-database-schemas.e2e.test.ts +++ b/src/commands/doctor.refuses-newer-database-schemas.e2e.test.ts @@ -61,6 +61,34 @@ describe("doctor database schema preflight", () => { expect(autoMigrateLegacyStateDir).not.toHaveBeenCalled(); expect(readConfigFileSnapshot).not.toHaveBeenCalled(); }); + + it.each([ + ["plain doctor", { nonInteractive: true }], + ["doctor --fix", { nonInteractive: true, repair: true }], + ])("diagnoses an unreadable shared state database for %s", async (_label, options) => { + const statePath = resolveOpenClawStateSqlitePath(process.env); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, "not a sqlite database"); + mockDoctorConfigSnapshot(); + + const failure = await doctorCommand(createDoctorRuntime(), options).then( + () => null, + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(statePath); + expect((failure as Error).message).toMatch(/file is not a database/iu); + expect((failure as Error).message).toContain("left unchanged"); + expect((failure as Error).message).toContain("restore this file from a verified backup"); + expect((failure as Error).message).toContain("openclaw doctor --fix"); + expect((failure as Error).message).toContain( + "https://docs.openclaw.ai/reference/database-schemas", + ); + expect(fs.readFileSync(statePath, "utf8")).toBe("not a sqlite database"); + expect(autoMigrateLegacyStateDir).not.toHaveBeenCalled(); + expect(readConfigFileSnapshot).not.toHaveBeenCalled(); + }); }); function mockInteractiveGitUpdate(status: "ok" | "skipped"): void { diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 8ea229de5b0d..7669f8a59160 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -29,6 +29,7 @@ import { formatUnresolvedCommandPromptAdvisory, formatUnresolvedShellPromptAdvisory, } from "./repair-plan.js"; +import { rethrowSqliteSchemaVersionError } from "./schema-safety.js"; import { normalizeStoredCronJobs } from "./store-migration.js"; import { noteCronDeliveryTargetAdvisory, noteCronModelOverrides } from "./warnings.js"; @@ -158,6 +159,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { try { state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true }); } catch (err) { + rethrowSqliteSchemaVersionError(err); const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); return [ legacyCronStoreFinding({ @@ -202,6 +204,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { ); } } catch (err) { + rethrowSqliteSchemaVersionError(err); findings.push( legacyCronStoreFinding({ message: `Unable to read quarantined cron rows in SQLite at ${shortenHomePath(sqliteStorePath)}.`, @@ -356,6 +359,7 @@ export async function maybeRepairLegacyCronStore(params: { try { state = await loadLegacyCronRepairState({ cfg: params.cfg }); } catch (err) { + rethrowSqliteSchemaVersionError(err); const reason = err instanceof Error ? err.message : String(err); const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); note( @@ -395,6 +399,7 @@ export async function maybeRepairLegacyCronStore(params: { ); } } catch (err) { + rethrowSqliteSchemaVersionError(err); const reason = err instanceof Error ? err.message : String(err); note( [ diff --git a/src/commands/doctor/cron/legacy-repair.ts b/src/commands/doctor/cron/legacy-repair.ts index 803f432e2a7e..0331892bb337 100644 --- a/src/commands/doctor/cron/legacy-repair.ts +++ b/src/commands/doctor/cron/legacy-repair.ts @@ -49,6 +49,10 @@ import { needsSqliteProjectionBackfill, } from "./repair-plan.js"; import { planCronCodexRefRewriteAgainstPersistedConfig } from "./runtime-policy-migration.js"; +import { + assertCronStateSchemaSupported, + rethrowSqliteSchemaVersionError, +} from "./schema-safety.js"; import { collectStoredCronCodexRuntimePolicyTargets, cronCodexRuntimePolicyTargetKey, @@ -123,6 +127,7 @@ export async function loadLegacyCronRepairState(params: { const legacyStoreDetected = await legacyCronStoreFilesExist(storePath); const legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath); const legacyQuarantine = await loadLegacyCronQuarantineForMigration(storePath); + assertCronStateSchemaSupported(params.env); if ( params.onlyIfLegacyDetected && !legacyStoreDetected && @@ -220,6 +225,7 @@ export async function applyLegacyCronStoreRepair(params: { migrateCodexModelRefs?: boolean; blockedModelIdentities?: ReadonlySet; }): Promise { + assertCronStateSchemaSupported(); const { state } = params; const changes: string[] = []; const warnings: string[] = []; @@ -309,6 +315,7 @@ export async function applyLegacyCronStoreRepair(params: { saveCronQuarantinedJobs({ storePath: state.storePath, ...quarantine }); } } catch (err) { + rethrowSqliteSchemaVersionError(err); return { changes, warnings: [ @@ -337,6 +344,7 @@ export async function applyLegacyCronStoreRepair(params: { try { importedRunLogs = (await migrateLegacyCronRunLogsToSqlite(state.storePath)).importedFiles; } catch (err) { + rethrowSqliteSchemaVersionError(err); warnings.push( `Failed importing legacy cron run logs at ${shortenHomePath(state.storePath)}: ${errorMessage(err)}`, ); @@ -353,6 +361,7 @@ export async function applyLegacyCronStoreRepair(params: { try { markLegacyCronMigrationSourceRemoved(state.legacyMigrationSource); } catch (err) { + rethrowSqliteSchemaVersionError(err); warnings.push( `Cron store was archived, but its migration receipt could not be finalized: ${errorMessage(err)}`, ); @@ -411,6 +420,7 @@ export async function repairLegacyCronStoreWithoutPrompt(params: { onlyIfLegacyDetected: true, }); } catch (err) { + rethrowSqliteSchemaVersionError(err); return { changes: [], warnings: [ @@ -438,6 +448,7 @@ export async function collectCronCodexRuntimePolicyTargetsReadOnly(params: { warnings: [], }; } catch (err) { + rethrowSqliteSchemaVersionError(err); return { targets: [], warnings: [ @@ -466,6 +477,7 @@ export async function repairCronCodexModelRefsAfterConfigWrite(params: { }) : { changes: [], warnings: [] }; } catch (err) { + rethrowSqliteSchemaVersionError(err); return { changes: [], warnings: [ diff --git a/src/commands/doctor/cron/schema-safety.test.ts b/src/commands/doctor/cron/schema-safety.test.ts new file mode 100644 index 000000000000..1c663217d8be --- /dev/null +++ b/src/commands/doctor/cron/schema-safety.test.ts @@ -0,0 +1,197 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { isSqliteSchemaVersionError } from "../../../infra/sqlite-user-version.js"; +import { + closeOpenClawStateDatabaseForTest, + OPENCLAW_STATE_SCHEMA_VERSION, +} from "../../../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js"; +import { collectLegacyCronStoreHealthFindings, maybeRepairLegacyCronStore } from "./index.js"; +import { + applyLegacyCronStoreRepair, + collectCronCodexRuntimePolicyTargetsReadOnly, + loadLegacyCronRepairState, + repairCronCodexModelRefsAfterConfigWrite, + repairLegacyCronStoreWithoutPrompt, +} from "./legacy-repair.js"; + +type FutureSchemaFixture = { + cfg: OpenClawConfig; + databasePath: string; + storePath: string; +}; + +let tempRoot: string | undefined; +let fixtureDatabase: DatabaseSync | undefined; + +afterEach(async () => { + fixtureDatabase?.close(); + fixtureDatabase = undefined; + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } +}); + +async function writeFutureSchema(databasePath: string): Promise { + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + const database = new DatabaseSync(databasePath); + try { + database.exec(` + CREATE TABLE preserved_sentinel (value TEXT NOT NULL) STRICT; + INSERT INTO preserved_sentinel (value) VALUES ('keep-me'); + PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1}; + `); + } finally { + database.close(); + } +} + +async function createFixture(options: { futureSchema: boolean }): Promise { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-cron-schema-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempRoot); + const storePath = path.join(tempRoot, "cron", "jobs.json"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs: [] }), "utf8"); + + const databasePath = resolveOpenClawStateSqlitePath(); + if (options.futureSchema) { + await writeFutureSchema(databasePath); + } + + return { + cfg: { cron: { store: storePath } } as OpenClawConfig, + databasePath, + storePath, + }; +} + +async function snapshotFixture(databasePath: string) { + const database = new DatabaseSync(databasePath, { readOnly: true }); + try { + const versionRow = database.prepare("PRAGMA user_version").get() as { + user_version: number; + }; + const sentinelRow = database.prepare("SELECT value FROM preserved_sentinel").get() as { + value: string; + }; + const bytes = await fs.readFile(databasePath); + const artifactNames = (await fs.readdir(path.dirname(databasePath))).toSorted(); + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + schemaVersion: versionRow.user_version, + sentinel: sentinelRow.value, + artifacts: Object.fromEntries( + await Promise.all( + artifactNames.map(async (name) => [ + name, + createHash("sha256") + .update(await fs.readFile(path.join(path.dirname(databasePath), name))) + .digest("hex"), + ]), + ), + ), + }; + } finally { + database.close(); + } +} + +async function expectSchemaRefusalWithoutMutation( + fixture: FutureSchemaFixture, + operation: () => Promise, +): Promise { + const before = await snapshotFixture(fixture.databasePath); + let error: unknown; + try { + await operation(); + } catch (caught) { + error = caught; + } + expect(isSqliteSchemaVersionError(error)).toBe(true); + await expect(snapshotFixture(fixture.databasePath)).resolves.toEqual(before); +} + +describe("future shared-state schema safety", () => { + const entrypoints: Array<[string, (fixture: FutureSchemaFixture) => Promise]> = [ + [ + "legacy cron health collection", + async ({ cfg }) => await collectLegacyCronStoreHealthFindings({ cfg }), + ], + [ + "interactive legacy cron repair", + async ({ cfg }) => + await maybeRepairLegacyCronStore({ + cfg, + options: {}, + prompter: { confirm: vi.fn() }, + }), + ], + [ + "non-interactive legacy cron repair", + async ({ cfg }) => await repairLegacyCronStoreWithoutPrompt({ cfg }), + ], + [ + "Codex cron migration planning", + async ({ cfg }) => await collectCronCodexRuntimePolicyTargetsReadOnly({ cfg }), + ], + [ + "Codex cron migration commit", + async ({ cfg }) => await repairCronCodexModelRefsAfterConfigWrite({ cfg }), + ], + ]; + + it.each(entrypoints)("fails closed without mutation during %s", async (_name, operation) => { + const fixture = await createFixture({ futureSchema: true }); + await expectSchemaRefusalWithoutMutation(fixture, () => operation(fixture)); + }); + + it("fails closed in non-interactive repair when no legacy files remain", async () => { + const fixture = await createFixture({ futureSchema: true }); + await fs.rm(fixture.storePath); + + await expectSchemaRefusalWithoutMutation(fixture, async () => { + await repairLegacyCronStoreWithoutPrompt({ cfg: fixture.cfg }); + }); + }); + + it("preserves active WAL artifacts while rejecting the future schema", async () => { + const fixture = await createFixture({ futureSchema: false }); + await fs.mkdir(path.dirname(fixture.databasePath), { recursive: true }); + fixtureDatabase = new DatabaseSync(fixture.databasePath); + fixtureDatabase.exec(` + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE preserved_sentinel (value TEXT NOT NULL) STRICT; + INSERT INTO preserved_sentinel (value) VALUES ('keep-me'); + PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1}; + `); + + await expectSchemaRefusalWithoutMutation(fixture, async () => { + await maybeRepairLegacyCronStore({ + cfg: fixture.cfg, + options: {}, + prompter: { confirm: vi.fn() }, + }); + }); + }); + + it("fails closed if the schema advances between inspection and repair", async () => { + const fixture = await createFixture({ futureSchema: false }); + const state = await loadLegacyCronRepairState({ cfg: fixture.cfg }); + expect(state).not.toBeNull(); + closeOpenClawStateDatabaseForTest(); + await writeFutureSchema(fixture.databasePath); + + await expectSchemaRefusalWithoutMutation(fixture, async () => { + await applyLegacyCronStoreRepair({ cfg: fixture.cfg, state: state! }); + }); + }); +}); diff --git a/src/commands/doctor/cron/schema-safety.ts b/src/commands/doctor/cron/schema-safety.ts new file mode 100644 index 000000000000..b320cea19910 --- /dev/null +++ b/src/commands/doctor/cron/schema-safety.ts @@ -0,0 +1,12 @@ +import { isSqliteSchemaVersionError } from "../../../infra/sqlite-user-version.js"; +import { withExistingOpenClawStateDatabaseArtifactPreservingReadOnly } from "../../../state/openclaw-state-db-readonly.js"; + +export function assertCronStateSchemaSupported(env?: NodeJS.ProcessEnv): void { + withExistingOpenClawStateDatabaseArtifactPreservingReadOnly(() => undefined, { env }); +} + +export function rethrowSqliteSchemaVersionError(error: unknown): void { + if (isSqliteSchemaVersionError(error)) { + throw error; + } +} diff --git a/src/commands/export-trajectory.test.ts b/src/commands/export-trajectory.test.ts index 49eb22ba851a..14b8e8700437 100644 --- a/src/commands/export-trajectory.test.ts +++ b/src/commands/export-trajectory.test.ts @@ -146,6 +146,50 @@ describe("exportTrajectoryCommand", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["empty", "", "--agent must not be blank"], + ["whitespace-only", " ", "--agent must not be blank"], + ])("rejects an %s explicit agent before reading a session", async (_label, agent, message) => { + const runtime = createRuntime(); + mocks.getRuntimeConfig.mockReturnValue({ agents: { list: [{ id: "main" }] } }); + + await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime); + + expect(runtime.error).toHaveBeenCalledWith(message); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.resolveStorePath).not.toHaveBeenCalled(); + expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled(); + }); + + it("keeps a configured explicit agent as the session store owner", async () => { + const runtime = createRuntime(); + mocks.getRuntimeConfig.mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "work" }] }, + session: { store: "/tmp/openclaw/agents/{agentId}/sessions/sessions.json" }, + }); + mocks.resolveStorePath.mockReturnValue("/tmp/openclaw/agents/work/sessions/sessions.json"); + + await exportTrajectoryCommand( + { sessionKey: "agent:main:telegram:direct:123", agent: "work" }, + runtime, + ); + + expect(mocks.resolveStorePath).toHaveBeenCalledWith( + "/tmp/openclaw/agents/{agentId}/sessions/sessions.json", + { agentId: "work" }, + ); + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ + agentId: "work", + sessionKey: "agent:main:telegram:direct:123", + storePath: "/tmp/openclaw/agents/work/sessions/sessions.json", + }); + }); + it.each([ ["home-prefixed", "~/x/sessions.json", "/home/demo/x/sessions.json"], [ diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index d41a31f57824..4383333d6bf9 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -1,6 +1,7 @@ /** CLI command for exporting a session transcript as a trajectory artifact. */ import path from "node:path"; import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveSessionStorePathCore } from "../config/sessions/paths.js"; @@ -114,7 +115,22 @@ export async function exportTrajectoryCommand( runtime.exit(1); return; } - const targetAgentId = resolvedOpts.agent ?? resolveAgentIdFromSessionKey(sessionKey); + const requestedAgent = resolvedOpts.agent?.trim(); + if (resolvedOpts.agent !== undefined && !requestedAgent) { + runtime.error("--agent must not be blank"); + runtime.exit(1); + return; + } + let targetAgentId = resolveAgentIdFromSessionKey(sessionKey); + if (requestedAgent) { + try { + targetAgentId = resolveConfiguredAgentId(getRuntimeConfig(), requestedAgent); + } catch (error) { + runtime.error(formatErrorMessage(error)); + runtime.exit(1); + return; + } + } let storePath = resolvedOpts.store ? resolveSessionStorePathCore(resolvedOpts.store, { agentId: targetAgentId }) : resolveSessionStorePathCore(getRuntimeConfig().session?.store, { agentId: targetAgentId }); diff --git a/src/commands/migrate/context.test.ts b/src/commands/migrate/context.test.ts index 042dca852660..4b0ae632d24d 100644 --- a/src/commands/migrate/context.test.ts +++ b/src/commands/migrate/context.test.ts @@ -36,4 +36,8 @@ describe("migration context helpers", () => { it("keeps the configured default when no migration target is supplied", () => { expect(resolveMigrationTargetAgentId({}, undefined)).toBeUndefined(); }); + + it("rejects an explicitly blank migration target", () => { + expect(() => resolveMigrationTargetAgentId({}, "")).toThrow("--agent must not be blank"); + }); }); diff --git a/src/commands/migrate/context.ts b/src/commands/migrate/context.ts index 16579ef57a14..46b3b53533d8 100644 --- a/src/commands/migrate/context.ts +++ b/src/commands/migrate/context.ts @@ -40,6 +40,9 @@ export function resolveMigrationTargetAgentId( rawAgentId: string | undefined, ): string | undefined { const raw = rawAgentId?.trim(); + if (rawAgentId !== undefined && !raw) { + throw new Error("--agent must not be blank"); + } if (!raw) { return undefined; } diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 55645225a15b..5959bba7972c 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -23,6 +23,30 @@ vi.mock("../config/config.js", async () => { }); describe("sandbox explain command", () => { + it.each([ + [ + "unknown", + "nope-agent", + 'Unknown agent id "nope-agent". Run openclaw agents list to see configured agents.', + ], + ["blank", "", "--agent must not be blank"], + ])("rejects an explicit %s agent", async (_label, agent, message) => { + mockCfg = { + agents: { + defaults: { sandbox: { mode: "off" } }, + list: [{ id: "main" }], + }, + }; + + await expect( + sandboxExplainCommand({ json: true, agent }, { + log: () => {}, + error: () => {}, + exit: (_code: number) => {}, + } as unknown as Parameters[1]), + ).rejects.toThrow(message); + }); + it("honors an explicit agent in an ownerless multi-agent fleet", async () => { mockCfg = { agents: { @@ -462,6 +486,7 @@ describe("sandbox explain command", () => { defaults: { sandbox: { mode: "non-main" }, }, + list: [{ id: "main" }, { id: "builder" }], }, }; diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index d5863939e343..cf834dc6b783 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -13,6 +13,7 @@ import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { colorize, isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { resolveAgentConfig, + resolveConfiguredAgentId, resolveSessionAgentId, resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; @@ -144,7 +145,11 @@ export async function sandboxExplainCommand( const cfg = getRuntimeConfig(); const requestedSession = opts.session?.trim(); - const requestedAgentId = opts.agent?.trim() ? normalizeAgentId(opts.agent) : undefined; + const requestedAgent = opts.agent?.trim(); + if (opts.agent !== undefined && !requestedAgent) { + throw new Error("--agent must not be blank"); + } + const requestedAgentId = requestedAgent ? normalizeAgentId(requestedAgent) : undefined; const sessionAgentId = requestedSession && requestedSession !== "global" && requestedSession.includes(":") ? normalizeAgentId(resolveAgentIdFromSessionKey(requestedSession)) @@ -154,6 +159,9 @@ export async function sandboxExplainCommand( `Sandbox explain agent "${requestedAgentId}" does not match session agent "${sessionAgentId}".`, ); } + if (requestedAgentId) { + resolveConfiguredAgentId(cfg, requestedAgentId); + } const resolvedAgentId = resolveSessionAgentId({ sessionKey: requestedSession, config: cfg, diff --git a/src/commands/sessions-compact.test.ts b/src/commands/sessions-compact.test.ts index 3d7190367174..0055b0d0f1f8 100644 --- a/src/commands/sessions-compact.test.ts +++ b/src/commands/sessions-compact.test.ts @@ -27,6 +27,16 @@ beforeEach(() => { }); describe("sessionsCompactCommand", () => { + it("rejects a blank agent before calling the Gateway", async () => { + const runtime = createRuntime(); + + await expect( + sessionsCompactCommand({ key: "agent:main:main", agent: "" }, runtime), + ).rejects.toThrow("--agent must not be blank"); + + expect(callGatewayCli).not.toHaveBeenCalled(); + }); + it("prints the token delta and does not exit on a successful compaction", async () => { callGatewayCli.mockResolvedValue({ ok: true, diff --git a/src/commands/sessions-compact.ts b/src/commands/sessions-compact.ts index 1734df6c2f26..b87f2517f786 100644 --- a/src/commands/sessions-compact.ts +++ b/src/commands/sessions-compact.ts @@ -73,6 +73,10 @@ export async function sessionsCompactCommand( opts: SessionsCompactCliOptions, runtime: RuntimeEnv, ): Promise { + const agent = opts.agent?.trim(); + if (opts.agent !== undefined && !agent) { + throw new Error("--agent must not be blank"); + } const rpcOpts: SessionsCompactRpcOpts = { url: opts.url, token: opts.token, @@ -84,7 +88,7 @@ export async function sessionsCompactCommand( }; const params = { key: opts.key, - ...(opts.agent ? { agentId: opts.agent } : {}), + ...(agent ? { agentId: agent } : {}), ...(opts.maxLines !== undefined ? { maxLines: opts.maxLines } : {}), }; diff --git a/src/commands/sessions-lifecycle.test.ts b/src/commands/sessions-lifecycle.test.ts index 4eb577187584..e6848f35d48a 100644 --- a/src/commands/sessions-lifecycle.test.ts +++ b/src/commands/sessions-lifecycle.test.ts @@ -4,12 +4,17 @@ import { sessionsArchiveCommand, sessionsDeleteCommand } from "./sessions-lifecy const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), confirm: vi.fn(), + getRuntimeConfig: vi.fn(), })); vi.mock("../cli/gateway-rpc.js", () => ({ callGatewayFromCliWithTransport: mocks.callGateway, })); +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.getRuntimeConfig, +})); + vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: () => ({ confirm: mocks.confirm }), })); @@ -35,6 +40,58 @@ describe("sessions lifecycle commands", () => { beforeEach(() => { vi.clearAllMocks(); mocks.confirm.mockResolvedValue(true); + mocks.getRuntimeConfig.mockReturnValue({ + agents: { entries: { main: {}, work: {} } }, + }); + }); + + it.each([ + ["archive", sessionsArchiveCommand, {} as Record], + ["delete", sessionsDeleteCommand, { yes: true } as Record], + ])( + "%s rejects an unconfigured --agent before contacting the gateway", + async (_label, command, extra) => { + const runtime = createRuntime(); + await command( + { keys: ["agent:ghost:main"], agent: "ghost", json: true, ...extra } as never, + runtime as never, + ); + expect(mocks.callGateway).not.toHaveBeenCalled(); + expect(runtime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + results: [ + expect.objectContaining({ + error: expect.stringContaining('Unknown agent id "ghost"'), + }), + ], + }), + 2, + ); + }, + ); + + it.each([ + ["archive", sessionsArchiveCommand, {} as Record], + ["delete", sessionsDeleteCommand, { yes: true } as Record], + ])("%s rejects a blank --agent", async (_label, command, extra) => { + const runtime = createRuntime(); + await command( + { keys: ["agent:main:main"], agent: " ", json: true, ...extra } as never, + runtime as never, + ); + expect(mocks.callGateway).not.toHaveBeenCalled(); + expect(runtime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + results: [ + expect.objectContaining({ + error: expect.stringContaining("--agent must not be blank"), + }), + ], + }), + 2, + ); }); it("archives through sessions.patch and emits the stable JSON envelope", async () => { diff --git a/src/commands/sessions-lifecycle.ts b/src/commands/sessions-lifecycle.ts index 62febe9cc87c..aec0216febc2 100644 --- a/src/commands/sessions-lifecycle.ts +++ b/src/commands/sessions-lifecycle.ts @@ -4,9 +4,11 @@ import type { SessionsDeleteResult, WorktreePreservationReason, } from "../../packages/gateway-protocol/src/index.js"; +import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; import { formatCliJsonFailure, rethrowExpectedCliError } from "../cli/failure-output.js"; import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js"; +import { getRuntimeConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { SESSION_ARCHIVE_REQUEST_TIMEOUT_MS } from "../shared/session-archive-timeout.js"; @@ -68,6 +70,14 @@ type SessionsLifecycleRpcOptions = Parameters; + let agent: string | undefined; try { - sessions = await listRequestedSessions(keys.filter(Boolean), opts.agent, rpcOptions); + // The not-found hint points at `sessions list --agent `, which rejects an unconfigured id + // locally. Validating here keeps that suggestion runnable instead of handing back a dead end. + agent = resolveLifecycleAgentId(opts.agent); + sessions = await listRequestedSessions(keys.filter(Boolean), agent, rpcOptions); } catch (error) { rethrowExpectedCliError(error); const message = formatErrorMessage(error); @@ -226,7 +240,7 @@ async function runSessionsLifecycleCommand( } const results = keys.map((key): SessionsLifecycleResult | undefined => - key && sessions.has(key) ? undefined : notFoundResult(key, opts.agent), + key && sessions.has(key) ? undefined : notFoundResult(key, agent), ); const listedTargets = keys.flatMap((key, index) => { const session = sessions.get(key); @@ -296,7 +310,7 @@ async function runSessionsLifecycleCommand( rpcOptions, { key: session.key, - ...(opts.agent ? { agentId: opts.agent } : {}), + ...(agent ? { agentId: agent } : {}), ...(session.sessionId ? { expectedSessionId: session.sessionId } : {}), archived: true, }, @@ -312,7 +326,7 @@ async function runSessionsLifecycleCommand( rpcOptions, { key: session.key, - ...(opts.agent ? { agentId: opts.agent } : {}), + ...(agent ? { agentId: agent } : {}), ...(session.sessionId ? { expectedSessionId: session.sessionId } : {}), deleteTranscript: true, ...(session.archived === true ? { archivedOnly: true } : {}), @@ -320,7 +334,7 @@ async function runSessionsLifecycleCommand( { defaultTimeoutMs: 30_000 }, )) as SessionsDeleteResult; if (!response.deleted) { - results[index] = notFoundResult(session.key, opts.agent); + results[index] = notFoundResult(session.key, agent); continue; } results[index] = { diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts index f39155a6728c..95da0dcf74a6 100644 --- a/src/config/io.eacces.test.ts +++ b/src/config/io.eacces.test.ts @@ -31,6 +31,24 @@ function makeEaccesFs(configPath: string) { } describe("config io EACCES handling", () => { + it("logs config load failures without exposing a raw error stack", () => { + const configPath = "/data/.openclaw/openclaw.json"; + const errors: unknown[][] = []; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { + error: (...args: unknown[]) => errors.push(args), + warn: () => {}, + }, + }); + + expect(() => io.loadConfig()).toThrow(expect.objectContaining({ code: "EACCES" })); + expect(errors).toEqual([ + [`Failed to read config at ${configPath}: EACCES: permission denied, open '${configPath}'`], + ]); + }); + it("returns a helpful error message when config file is not readable (EACCES)", async () => { const configPath = "/data/.openclaw/openclaw.json"; const errors: string[] = []; diff --git a/src/config/io.load.ts b/src/config/io.load.ts index e4c8f4f9b952..1ed07f98bd35 100644 --- a/src/config/io.load.ts +++ b/src/config/io.load.ts @@ -1,3 +1,4 @@ +import { formatErrorMessage } from "../infra/errors.js"; import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, @@ -208,7 +209,7 @@ export function loadConfigFromContext( if ((error as { code?: string })?.code === "INVALID_CONFIG") { throw error; } - deps.logger.error(`Failed to read config at ${configPath}`, error); + deps.logger.error(`Failed to read config at ${configPath}: ${formatErrorMessage(error)}`); throw error; } } diff --git a/src/config/sessions/targets.selection.test.ts b/src/config/sessions/targets.selection.test.ts new file mode 100644 index 000000000000..ebaece30b1d9 --- /dev/null +++ b/src/config/sessions/targets.selection.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { resolveSessionStoreTargets } from "./targets.js"; + +describe("session store target selection", () => { + it("rejects a blank agent instead of selecting the default store", () => { + expect(() => resolveSessionStoreTargets({}, { agent: "" })).toThrow( + "--agent must not be blank", + ); + }); +}); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 36f144347211..b499c8e4ccf0 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -620,7 +620,11 @@ export function resolveSessionStoreTargets( params: { env?: NodeJS.ProcessEnv; diagnostics?: string[] } = {}, ): SessionStoreTarget[] { const env = params.env ?? process.env; - const hasAgent = Boolean(opts.agent?.trim()); + const requestedAgent = opts.agent?.trim(); + if (opts.agent !== undefined && !requestedAgent) { + throw new Error("--agent must not be blank"); + } + const hasAgent = requestedAgent !== undefined; const allAgents = opts.allAgents === true; if (hasAgent && allAgents) { throw new Error("--agent and --all-agents cannot be used together"); @@ -638,7 +642,7 @@ export function resolveSessionStoreTargets( if (persistedStoreOwner.kind === "retired") { throw new Error(`Session store owner is retired: ${persistedStoreOwner.agentId}`); } - const requestedAgentId = hasAgent ? normalizeAgentId(opts.agent ?? "") : undefined; + const requestedAgentId = requestedAgent ? normalizeAgentId(requestedAgent) : undefined; if ( requestedAgentId && persistedStoreOwner.kind === "configured" && @@ -687,7 +691,7 @@ export function resolveSessionStoreTargets( } if (hasAgent) { - const requested = normalizeAgentId(opts.agent ?? ""); + const requested = normalizeAgentId(requestedAgent); resolveConfiguredAgentId(cfg, requested); return [ { diff --git a/src/cron/service.stream-validation.test.ts b/src/cron/service.stream-validation.test.ts index c350f626bbd0..110483d387ba 100644 --- a/src/cron/service.stream-validation.test.ts +++ b/src/cron/service.stream-validation.test.ts @@ -21,12 +21,14 @@ function streamJob(overrides: Partial = {}): CronJobCreate { }; } -async function createCron(triggersEnabled: boolean) { +async function createCron(triggersEnabled: boolean | undefined, cronEnabled = true) { const { storePath } = await makeStorePath(); const cron = new CronService({ storePath, - cronEnabled: true, - cronConfig: { triggers: { enabled: triggersEnabled } }, + cronEnabled, + ...(triggersEnabled === undefined + ? {} + : { cronConfig: { triggers: { enabled: triggersEnabled } } }), log: logger, enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), @@ -37,6 +39,27 @@ async function createCron(triggersEnabled: boolean) { } describe("cron stream schedule validation", () => { + it.each([ + { cronEnabled: true, configured: undefined, triggersEnabled: true }, + { cronEnabled: true, configured: true, triggersEnabled: true }, + { cronEnabled: true, configured: false, triggersEnabled: false }, + { cronEnabled: false, configured: true, triggersEnabled: true }, + { cronEnabled: false, configured: false, triggersEnabled: false }, + ])( + "reports active trigger capability independently of scheduler enablement ($cronEnabled/$configured)", + async ({ cronEnabled, configured, triggersEnabled }) => { + const cron = await createCron(configured, cronEnabled); + try { + await expect(cron.status()).resolves.toMatchObject({ + enabled: cronEnabled, + triggersEnabled, + }); + } finally { + cron.stop(); + } + }, + ); + it("rejects creation while cron triggers are disabled", async () => { const cron = await createCron(false); try { diff --git a/src/cron/service/ops-read.ts b/src/cron/service/ops-read.ts index e8687b792b8f..fcb24fd99df0 100644 --- a/src/cron/service/ops-read.ts +++ b/src/cron/service/ops-read.ts @@ -42,6 +42,7 @@ export async function status(state: CronServiceState) { const sqlitePath = resolveOpenClawStateSqlitePath(); return { enabled: state.deps.cronEnabled, + triggersEnabled: state.deps.cronConfig?.triggers?.enabled !== false, storePath: sqlitePath, storage: "sqlite" as const, sqlitePath, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 11ad33b656d9..a52dfadf671d 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -372,6 +372,7 @@ export type CronWakeMode = "now" | "next-heartbeat"; /** Lightweight service status returned to gateway/control surfaces. */ export type CronStatusSummary = { enabled: boolean; + triggersEnabled: boolean; /** @deprecated Alias for `sqlitePath`. */ storePath: string; /** Storage backend identifier. */ diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index abea02a37dd6..d422b4ad11d8 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -231,7 +231,7 @@ describe("cron service timer seam coverage", () => { expect(task.startedAt).toBe(now); expect(task.lastEventAt).toBe(now); expect(task.endedAt).toBe(now); - expect(task.cleanupAfter).toBeUndefined(); + expect(task.cleanupAfter).toBe(now + 7 * 24 * 60 * 60_000); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 6ca04140a607..75f47ac55c17 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -1,7 +1,9 @@ // Doctor runtime check tests cover runtime-backed doctor checks. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { GATEWAY_HEALTH_RATE_LIMITED_MESSAGE } from "../commands/gateway-health-auth-diagnostic.js"; +import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js"; import { setPluginToolMeta } from "../plugins/tools.js"; const mocks = vi.hoisted(() => ({ @@ -11,7 +13,8 @@ const mocks = vi.hoisted(() => ({ loadModelCatalog: vi.fn(async (): Promise>> => []), normalizeProviderToolSchemasWithPlugin: vi.fn(), buildGatewayProbeConnectionDetails: vi.fn(), - probeGatewayStatus: vi.fn(), + callGateway: vi.fn(), + isGatewayCredentialsRequiredError: vi.fn(), readGatewayServiceState: vi.fn(), resolveGatewayService: vi.fn(() => ({ label: "openclaw-gateway" })), resolvePluginProvidersCore: vi.fn((): Array> => []), @@ -46,10 +49,8 @@ vi.mock("../agents/agent-tools.js", () => ({ vi.mock("../gateway/call.js", () => ({ buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails, -})); - -vi.mock("../cli/daemon-cli/probe.js", () => ({ - probeGatewayStatus: mocks.probeGatewayStatus, + callGateway: mocks.callGateway, + isGatewayCredentialsRequiredError: mocks.isGatewayCredentialsRequiredError, })); vi.mock("../daemon/service.js", () => ({ @@ -105,13 +106,6 @@ describe("doctor runtime tool schema checks", () => { mocks.normalizeProviderToolSchemasWithPlugin .mockReset() .mockImplementation(({ context }) => context.tools); - mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({ - url: "http://127.0.0.1:5829", - }); - mocks.probeGatewayStatus.mockReset().mockResolvedValue({ - ok: true, - server: { version: "2026.6.26" }, - }); mocks.readGatewayServiceState.mockReset().mockResolvedValue({ installed: true, loadState: { status: "loaded" }, @@ -567,10 +561,8 @@ describe("doctor gateway runtime checks", () => { mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({ url: "http://127.0.0.1:5829", }); - mocks.probeGatewayStatus.mockReset().mockResolvedValue({ - ok: true, - server: { version: "2026.6.26" }, - }); + mocks.callGateway.mockReset().mockResolvedValue({ degradedSecretOwners: [] }); + mocks.isGatewayCredentialsRequiredError.mockReset().mockReturnValue(false); mocks.readGatewayServiceState.mockReset().mockResolvedValue({ installed: true, loadState: { status: "loaded" }, @@ -582,52 +574,209 @@ describe("doctor gateway runtime checks", () => { mocks.resolveGatewayService.mockReset().mockReturnValue({ label: "openclaw-gateway" }); }); - it("reports unreachable gateway health probes", async () => { - mocks.probeGatewayStatus.mockResolvedValueOnce({ - ok: false, - error: "connect ECONNREFUSED 127.0.0.1:5829", + it("projects every degraded SecretRef owner from exactly one authenticated read-only status RPC", async () => { + const cfg = { gateway: { mode: "local" as const } }; + const privateToken = "SYNTHETIC_PRIVATE_URL_TOKEN"; + mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({ + url: "wss://127.0.0.1:5829", + tlsFingerprint: "sha256:test-doctor-fingerprint", + preauthHandshakeTimeoutMs: 1200, + }); + mocks.callGateway.mockResolvedValueOnce({ + degradedSecretOwners: [ + { + ownerKind: "account", + ownerId: "discord:ops", + state: "unavailable", + paths: ["channels.discord.accounts.ops.token"], + reason: "secret reference was not found (env:default:PRIVATE_REF_ID)", + }, + { + ownerKind: "capability", + ownerId: "tts", + state: "unavailable", + degradationState: "stale", + paths: ["tts.providers.elevenlabs.apiKey", "tts.providers.elevenlabs.voiceId"], + reason: "secret provider policy denied resolution", + }, + { + ownerKind: "provider", + ownerId: `vault\u001b]52;c;attack\u0007:https://user:${privateToken}@secret.test/${"a".repeat(500)}`, + state: "unavailable", + paths: Array.from( + { length: 12 }, + (_, index) => + `providers.example.${index}.https://secret.test/value?token=${privateToken}\n${"z".repeat(400)}`, + ), + reason: `secret provider failed: ${privateToken}\nref PRIVATE_REF_ID`, + }, + ], + degradedPlugins: [{ pluginId: "not-this-check" }], }); - await expect( - collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }), - ).resolves.toContainEqual({ - checkId: "core/doctor/gateway-health", - severity: "warning", - message: "Gateway is not reachable: connect ECONNREFUSED 127.0.0.1:5829", - path: "gateway.mode", - target: "http://127.0.0.1:5829", - fixHint: - "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.", + const findings = await collectGatewayHealthFindings({ + cfg, + configPath: "/tmp/selected-openclaw.json", }); + + expect(mocks.callGateway).toHaveBeenCalledExactlyOnceWith({ + method: "status", + params: { includeChannelSummary: false }, + timeoutMs: 3000, + sharedStateMode: "read-only", + config: cfg, + configPath: "/tmp/selected-openclaw.json", + tlsFingerprint: "sha256:test-doctor-fingerprint", + preauthHandshakeTimeoutMs: 1200, + }); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message: expect.stringContaining("cold account:discord:ops"), + path: "channels.discord.accounts.ops.token", + target: "account:discord:ops", + fixHint: expect.stringContaining("openclaw secrets reload"), + }), + expect.objectContaining({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message: expect.stringContaining("stale capability:tts"), + path: "tts.providers.elevenlabs.apiKey", + target: "capability:tts", + fixHint: expect.stringContaining("openclaw secrets reload"), + }), + expect.objectContaining({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message: expect.stringContaining("provider:vault"), + path: expect.stringContaining("providers.example.0"), + target: expect.stringContaining("provider:vault"), + }), + ]); + expect(findings[1]?.message).toContain("tts.providers.elevenlabs.voiceId"); + const finding = findings[2]; + const rendered = JSON.stringify(findings); + expect(finding?.message).toContain("omitted"); + expect(finding?.message).toContain("secret resolution failed"); + expect(finding?.message.length).toBeLessThanOrEqual(700); + expect(finding?.target?.length).toBeLessThanOrEqual(150); + expect(finding?.path?.length).toBeLessThanOrEqual(180); + expect(rendered).not.toContain(privateToken); + expect(rendered).not.toContain("PRIVATE_REF_ID"); + expect(rendered).not.toContain("not-this-check"); + expect(rendered).not.toContain("\u001b"); + expect(rendered).not.toContain("\u0007"); }); - it("reports temporary Gateway authentication lockouts with wait-and-retry guidance", async () => { - mocks.probeGatewayStatus.mockResolvedValueOnce({ - ok: false, - error: "connect failed", - connectFailure: { kind: "rate-limited", detailCode: "AUTH_RATE_LIMITED" }, - }); + it.each([ + { + label: "missing Gateway authentication", + error: new Error("auth token SYNTHETIC_PRIVATE_TOKEN\nref PRIVATE_REF_ID"), + credentialsRequired: true, + message: + "Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.", + }, + { + label: "an unavailable Gateway authentication SecretRef", + error: new GatewaySecretRefUnavailableError("gateway.auth.token"), + credentialsRequired: false, + message: + "Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.", + }, + { + label: "temporary Gateway authentication rate limiting", + error: new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "unauthorized: too many failed authentication attempts (retry later)", + details: { code: "AUTH_RATE_LIMITED", authReason: "rate_limited" }, + retryable: true, + }), + credentialsRequired: false, + message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE, + }, + { + label: "an unreachable Gateway with terminal control characters", + error: new Error("connect ECONNREFUSED 127.0.0.1:5829\u001b]52;c;attack\u0007\u009b"), + credentialsRequired: false, + message: "Gateway status could not be inspected: connect ECONNREFUSED 127.0.0.1:5829", + }, + ])("reports $label from exactly one sanitized status attempt", async (entry) => { + mocks.callGateway.mockRejectedValueOnce(entry.error); + mocks.isGatewayCredentialsRequiredError.mockReturnValueOnce(entry.credentialsRequired); + + const findings = await collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message: entry.message, + path: "gateway.mode", + target: "http://127.0.0.1:5829", + }), + ]); + expect(JSON.stringify(findings)).not.toContain("SYNTHETIC_PRIVATE_TOKEN"); + expect(JSON.stringify(findings)).not.toContain("PRIVATE_REF_ID"); + expect(mocks.callGateway).toHaveBeenCalledOnce(); + }); + + it("reports preparation failures without exposing URL credentials or control characters", async () => { + mocks.buildGatewayProbeConnectionDetails.mockRejectedValueOnce( + new Error( + `invalid wss://user:${"SYNTHETIC_PRIVATE_TOKEN".repeat(20)}@gateway.test/rpc\nmore`, + ), + ); + + const findings = await collectGatewayHealthFindings({ cfg: {} }); + + expect(findings).toEqual([ + expect.objectContaining({ + severity: "warning", + message: expect.stringContaining("Gateway health inspection could not be prepared"), + path: "gateway", + }), + ]); + expect(JSON.stringify(findings)).not.toContain("SYNTHETIC_PRIVATE_TOKEN"); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("prepares the target but skips the RPC for active exec credentials unless execution is allowed", async () => { + const cfg = { + gateway: { + mode: "local" as const, + auth: { + mode: "token" as const, + token: { source: "exec" as const, provider: "vault", id: "PRIVATE_REF_ID" }, + }, + }, + }; + + const findings = await collectGatewayHealthFindings({ cfg, env: {} }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message: expect.stringContaining("intentionally skipped"), + fixHint: expect.stringContaining("--allow-exec"), + }), + ]); + expect(JSON.stringify(findings)).not.toContain("PRIVATE_REF_ID"); + expect(mocks.buildGatewayProbeConnectionDetails).toHaveBeenCalledOnce(); + expect(mocks.callGateway).not.toHaveBeenCalled(); await expect( - collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }), - ).resolves.toContainEqual({ - checkId: "core/doctor/gateway-health", - severity: "warning", - message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE, - path: "gateway.mode", - target: "http://127.0.0.1:5829", - fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.", - }); + collectGatewayHealthFindings({ cfg, env: {}, allowExecSecretRefs: true }), + ).resolves.toEqual([]); + expect(mocks.callGateway).toHaveBeenCalledOnce(); }); it("redacts sensitive remote gateway URLs from health finding targets", async () => { mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({ url: "wss://user:pass@gateway.example.test/rpc?token=secret&safe=value", }); - mocks.probeGatewayStatus.mockResolvedValueOnce({ - ok: false, - error: "remote gateway did not answer", - }); + mocks.callGateway.mockRejectedValueOnce(new Error("remote gateway did not answer")); const findings = await collectGatewayHealthFindings({ cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example.test/rpc" } } }, @@ -636,7 +785,7 @@ describe("doctor gateway runtime checks", () => { expect(findings).toContainEqual({ checkId: "core/doctor/gateway-health", severity: "warning", - message: "Gateway is not reachable: remote gateway did not answer", + message: "Gateway status could not be inspected: remote gateway did not answer", path: "gateway.remote.url", target: "wss://***:***@gateway.example.test/rpc?token=***&safe=value", fixHint: "Verify the remote Gateway URL, network path, TLS settings, and credentials.", diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index a859cb9c48f9..d533fd5e7735 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -1,5 +1,6 @@ // Doctor runtime checks inspect tool names, browser residue, and runtime state. import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { TOOL_NAME_SEPARATOR } from "../agents/agent-bundle-mcp-names.js"; import { type McpToolCatalogDiagnostic, @@ -29,12 +30,11 @@ import { type RuntimeToolSchemaDiagnostic, } from "../agents/tool-schema-projection.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; -import { probeGatewayStatus } from "../cli/daemon-cli/probe.js"; +import { projectDoctorSecretRuntimeDegradations } from "../commands/doctor-secret-runtime-degradation.js"; import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js"; import { GATEWAY_HEALTH_RATE_LIMITED_MESSAGE, - gatewayProbeResultSawGateway, - gatewayProbeResultWasRateLimited, + gatewayConnectErrorWasRateLimited, } from "../commands/gateway-health-auth-diagnostic.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -42,7 +42,12 @@ import { type GatewayServiceRuntime, } from "../daemon/service-runtime.js"; import { resolveGatewayService, readGatewayServiceState } from "../daemon/service.js"; -import { buildGatewayProbeConnectionDetails } from "../gateway/call.js"; +import { + buildGatewayProbeConnectionDetails, + callGateway, + isGatewayCredentialsRequiredError, +} from "../gateway/call.js"; +import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js"; import { formatErrorMessage } from "../infra/errors.js"; import { formatLocalAudioSelection, @@ -54,14 +59,18 @@ import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js"; import type { ProviderCatalogOrder, ProviderPlugin } from "../plugins/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js"; +import type { StatusSummary } from "../status/types.js"; +import { scrubDoctorErrorMessage } from "./doctor-error-message.js"; +import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js"; import type { HealthCheckContext, HealthFinding } from "./health-checks.js"; type BundleMcpToolRuntime = Awaited>; const PROVIDER_CATALOG_ORDERS = ["simple", "profile", "paired", "late"] as const; const PROVIDER_CATALOG_ORDER_SET = new Set(PROVIDER_CATALOG_ORDERS); -function formatGatewayHealthTarget(url: string): string { - return redactSensitiveUrlLikeString(url); +function formatGatewayHealthDiagnostic(value: unknown): string { + const raw = value instanceof Error ? value.message : String(value); + return scrubDoctorErrorMessage(sanitizeTerminalText(redactSensitiveUrlLikeString(raw))); } export function detectUnavailableSkills(cfg: OpenClawConfig, workspaceDir: string) { @@ -105,64 +114,87 @@ export async function collectLocalAudioAccelerationFindings(): Promise, + ctx: Pick, ): Promise { - let probeDetails: Awaited>; + const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local"; + const gatewayPath = mode === "remote" ? "gateway.remote.url" : "gateway.mode"; + let probeDetails: Awaited> | undefined; + const warning = (message: string, fixHint: string): HealthFinding => ({ + checkId: "core/doctor/gateway-health", + severity: "warning", + message, + path: probeDetails || mode === "remote" ? gatewayPath : "gateway", + ...(probeDetails ? { target: formatGatewayHealthDiagnostic(probeDetails.url) } : {}), + fixHint, + }); try { probeDetails = await buildGatewayProbeConnectionDetails({ config: ctx.cfg, - ...(ctx.configPath ? { configPath: ctx.configPath } : {}), + configPath: ctx.configPath, }); - } catch (error) { - return [ - { - checkId: "core/doctor/gateway-health", - severity: "warning", - message: `Gateway health probe could not be prepared: ${formatErrorMessage(error)}`, - path: ctx.cfg.gateway?.mode === "remote" ? "gateway.remote.url" : "gateway", - fixHint: - "Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.", - }, - ]; - } - - const probe = await probeGatewayStatus({ - url: probeDetails.url, - timeoutMs: 3000, - tlsFingerprint: probeDetails.tlsFingerprint, - preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs, - config: ctx.cfg, - json: true, - }); - const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local"; - if (gatewayProbeResultWasRateLimited(probe)) { - return [ - { - checkId: "core/doctor/gateway-health", - severity: "warning", - message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE, - path: mode === "remote" ? "gateway.remote.url" : "gateway.mode", - target: formatGatewayHealthTarget(probeDetails.url), - fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.", - }, - ]; - } - if (gatewayProbeResultSawGateway(probe)) { - return []; - } - return [ - { + if ( + ctx.allowExecSecretRefs !== true && + (await hasActiveGatewayExecCredential({ + cfg: ctx.cfg, + env: ctx.env, + targetUrl: probeDetails.url, + })) + ) { + return [ + warning( + "Authenticated Gateway health inspection was intentionally skipped because an active credential uses an exec SecretRef.", + "Rerun `openclaw doctor --lint --only core/doctor/gateway-health --allow-exec` to permit configured secret execution.", + ), + ]; + } + const status = await callGateway({ + method: "status", + params: { includeChannelSummary: false }, + timeoutMs: 3000, + sharedStateMode: "read-only", + config: ctx.cfg, + configPath: ctx.configPath, + tlsFingerprint: probeDetails.tlsFingerprint, + preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs, + }); + return projectDoctorSecretRuntimeDegradations(status).map((owner) => ({ checkId: "core/doctor/gateway-health", severity: "warning", - message: `Gateway is not reachable: ${probe.error ?? "status probe failed"}`, - path: mode === "remote" ? "gateway.remote.url" : "gateway.mode", - target: formatGatewayHealthTarget(probeDetails.url), - fixHint: - mode === "remote" - ? "Verify the remote Gateway URL, network path, TLS settings, and credentials." - : "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.", - }, - ]; + message: `Secret runtime degradation: ${owner.message}`, + path: owner.path, + target: owner.target, + fixHint: `Retry: ${owner.retryHint}`, + })); + } catch (error) { + if (!probeDetails) { + return [ + warning( + `Gateway health inspection could not be prepared: ${formatGatewayHealthDiagnostic(error)}`, + "Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.", + ), + ]; + } + const diagnostic = gatewayConnectErrorWasRateLimited(error) + ? { + message: GATEWAY_HEALTH_RATE_LIMITED_MESSAGE, + fixHint: "Wait for the temporary authentication lockout to expire, then rerun doctor.", + } + : isGatewayCredentialsRequiredError(error) || isGatewaySecretRefUnavailableError(error) + ? { + message: + "Gateway status could not be inspected because this CLI has no usable token/password or paired device token for read-scope RPCs.", + fixHint: + "Configure the Gateway token/password or pair this device, then rerun the selected health check.", + } + : { + message: `Gateway status could not be inspected: ${formatGatewayHealthDiagnostic(error)}`, + fixHint: + mode === "remote" + ? "Verify the remote Gateway URL, network path, TLS settings, and credentials." + : "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.", + }; + return [warning(diagnostic.message, diagnostic.fixHint)]; + } } function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): string | undefined { diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 9ab377e5c4d8..10ef190f2e48 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -1063,7 +1063,7 @@ function createGatewayHealthCheck(deps: CoreHealthCheckDeps): SplitHealthCheckDe return { id: GATEWAY_HEALTH_CHECK_ID, kind: "core", - description: "Gateway reachability is represented as structured findings.", + description: "Authenticated Gateway health and degraded secret owners are structured findings.", source: "doctor", defaultEnabled: false, async detect(ctx) { diff --git a/src/flows/doctor-gateway-exec-credential.test.ts b/src/flows/doctor-gateway-exec-credential.test.ts new file mode 100644 index 000000000000..ddcb9972c2b4 --- /dev/null +++ b/src/flows/doctor-gateway-exec-credential.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js"; + +const execRef = { source: "exec", provider: "vault", id: "PRIVATE_EDGE_REF_ID" } as const; +type GatewayExecEdgeCredentialCase = { + label: string; + mode: "local" | "remote"; + targetUrl?: string; + edgeAuth: Record; + expected: boolean; +}; + +describe("hasActiveGatewayExecCredential", () => { + it.each([ + { + label: "the configured remote target uses an exec-backed edge header", + mode: "remote" as const, + edgeAuth: { "X-Edge-Auth": execRef }, + expected: true, + }, + { + label: "a matching target differs only by query parameters", + mode: "remote" as const, + targetUrl: "wss://gateway.example.test/rpc?profile=two", + edgeAuth: { "X-Edge-Auth": execRef }, + expected: true, + }, + { + label: "the effective target is a different gateway", + mode: "remote" as const, + targetUrl: "wss://other-gateway.example.test/rpc", + edgeAuth: { "X-Edge-Auth": execRef }, + expected: false, + }, + { + label: "a local gateway cannot use unrelated remote edge headers", + mode: "local" as const, + edgeAuth: { "X-Edge-Auth": execRef }, + expected: false, + }, + { + label: "matching literal and environment-backed headers do not execute", + mode: "remote" as const, + edgeAuth: { + "X-Literal": "literal-edge-value", + "X-Environment": { source: "env", provider: "default", id: "EDGE_TOKEN" } as const, + }, + expected: false, + }, + ])("detects only effective exec edge credentials when $label", async (entry) => { + const cfg: OpenClawConfig = { + gateway: { + mode: entry.mode, + remote: { url: "wss://gateway.example.test/rpc", edgeAuth: entry.edgeAuth }, + }, + }; + + await expect( + hasActiveGatewayExecCredential({ cfg, env: {}, targetUrl: entry.targetUrl }), + ).resolves.toBe(entry.expected); + }); +}); diff --git a/src/flows/doctor-gateway-exec-credential.ts b/src/flows/doctor-gateway-exec-credential.ts index 177f50ab621b..558a1ab8c84d 100644 --- a/src/flows/doctor-gateway-exec-credential.ts +++ b/src/flows/doctor-gateway-exec-credential.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; export async function hasActiveGatewayExecCredential(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; + targetUrl?: string; }): Promise { const [{ resolveSecretInputRef }, { gatewaySecretInputPathCanWin }, secretPaths] = await Promise.all([ @@ -11,7 +12,7 @@ export async function hasActiveGatewayExecCredential(params: { import("../gateway/secret-input-paths.js"), ]); const mode = params.cfg.gateway?.mode === "remote" ? "remote" : "local"; - return secretPaths.ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => { + const hasExecCredential = secretPaths.ALL_GATEWAY_SECRET_INPUT_PATHS.some((path) => { if ( !gatewaySecretInputPathCanWin({ config: params.cfg, @@ -28,4 +29,23 @@ export async function hasActiveGatewayExecCredential(params: { }).ref; return ref?.source === "exec"; }); + if (hasExecCredential || !params.cfg.gateway?.remote?.edgeAuth) { + return hasExecCredential; + } + + const [{ buildGatewayProbeConnectionDetails }, edgeAuth] = await Promise.all([ + import("../gateway/call.js"), + import("../gateway/edge-auth.js"), + ]); + const targetUrl = + params.targetUrl ?? (await buildGatewayProbeConnectionDetails({ config: params.cfg })).url; + const { gatewayEdgeAuthValueForTarget, normalizeEdgeAuthHeadersConfig } = edgeAuth; + const headers = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ config: params.cfg, targetUrl }), + ); + return Object.values(headers ?? {}).some( + (value) => + resolveSecretInputRef({ value, defaults: params.cfg.secrets?.defaults }).ref?.source === + "exec", + ); } diff --git a/src/flows/doctor-health.test.ts b/src/flows/doctor-health.test.ts index 96d43e73850d..c4cd4a6d96f7 100644 --- a/src/flows/doctor-health.test.ts +++ b/src/flows/doctor-health.test.ts @@ -17,7 +17,7 @@ vi.mock("../config/paths.js", () => ({ vi.mock("../state/openclaw-database-preflight.js", () => ({ OpenClawDatabaseSchemaPreflightError: class extends Error {}, - preflightOpenClawDatabaseSchemas: () => ({ incompatible: [] }), + preflightOpenClawDatabaseSchemas: () => ({ incompatible: [], indeterminate: [] }), })); vi.mock("../state/openclaw-agent-db.js", () => ({ diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index 2b77f5acda78..cfa305e8da50 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { intro as clackIntro, outro as clackOutro } from "@clack/prompts"; import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js"; +import { formatCliCommand } from "../cli/command-format.js"; import type { DoctorOptions } from "../commands/doctor-prompter.js"; import { resolveStateDir } from "../config/paths.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -32,6 +33,14 @@ async function assertDoctorDatabaseSchemasCompatible(): Promise { operation: "doctor", }); } + const unreadableStateDatabase = databaseSchemas.indeterminate.find( + (database) => database.kind === "state", + ); + if (unreadableStateDatabase) { + throw new Error( + `Doctor cannot continue because the shared state database is unreadable: ${unreadableStateDatabase.path}: ${unreadableStateDatabase.reason}. The database was left unchanged; doctor will not recreate it because that could discard persistent operator data. Stop the Gateway and other OpenClaw processes, then restore this file from a verified backup or repair it manually. After recovery, run ${formatCliCommand("openclaw doctor --fix")} again. See ${stateDatabase.OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.`, + ); + } } function stateDirectoryExistsAtDoctorStart(): boolean { diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 72694926dd44..9cb082f92cdb 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -2,6 +2,7 @@ import { parseBoolean } from "@openclaw/normalization-core/boolean-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { + type CronListParams, ErrorCodes, errorShape, GatewayErrorDetailCodes, @@ -488,21 +489,7 @@ export const cronHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateCronListParams, "cron.list", respond)) { return; } - const p = params as { - includeDisabled?: boolean; - limit?: number; - offset?: number; - query?: string; - enabled?: "all" | "enabled" | "disabled"; - scheduleKind?: "all" | "at" | "every" | "cron"; - lastRunStatus?: "all" | "ok" | "error" | "skipped" | "unknown"; - trigger?: "all" | "conditional" | "unconditional"; - sortBy?: "nextRunAtMs" | "updatedAtMs" | "name"; - sortDir?: "asc" | "desc"; - agentId?: string; - compact?: boolean; - includeDeliveryPreviews?: boolean; - }; + const p = params as CronListParams; const callerScope = readCronCallerScope(client); const requestedAgentId = p.agentId ? normalizeAgentId(p.agentId) : undefined; if (callerScope && requestedAgentId && requestedAgentId !== callerScope.agentId) { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index fbff3a6f0962..77a3b91dd58d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -701,6 +701,10 @@ describe("gateway server cron", () => { const cronState = await createDirectCronState(); try { + await expect(directCronReq(cronState, "cron.status", {})).resolves.toMatchObject({ + ok: true, + payload: { enabled: false, triggersEnabled: false }, + }); const response = await directCronReq(cronState, "cron.add", { name: "disabled watcher", enabled: true, diff --git a/src/plugins/contracts/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts index 48fb0dbf4169..74566ff1dc61 100644 --- a/src/plugins/contracts/scheduled-turns.contract.test.ts +++ b/src/plugins/contracts/scheduled-turns.contract.test.ts @@ -93,6 +93,7 @@ function createMockCronService(): CronServiceContract { stop: vi.fn(), status: vi.fn(async () => ({ enabled: true, + triggersEnabled: true, storePath: "/tmp/openclaw-test-cron.json", storage: "sqlite" as const, sqlitePath: "/tmp/openclaw-test-state/state/openclaw.sqlite", diff --git a/src/skills/lifecycle/clawhub-request-error.ts b/src/skills/lifecycle/clawhub-request-error.ts index 01c4c12d6184..3ca68733fd11 100644 --- a/src/skills/lifecycle/clawhub-request-error.ts +++ b/src/skills/lifecycle/clawhub-request-error.ts @@ -1,3 +1,4 @@ +import { formatCliCommand } from "../../cli/command-format.js"; import { ClawHubRequestError } from "../../infra/clawhub-client.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -15,7 +16,9 @@ export function formatClawHubSkillRequestError( error.requestPath.endsWith(`${skillPath}/install`) || error.requestPath.endsWith(`${skillPath}/verify`)) ) { - return `Skill "${params.slug}" not found. Run \`openclaw skills list\` to see available skills.`; + // ClawHub said this slug is not in the registry, so listing locally installed skills cannot + // resolve it; search is the only next step that can find the right slug. + return `Skill "${params.slug}" not found on ClawHub. Run \`${formatCliCommand(`openclaw skills search ${params.slug}`)}\` to find the right skill reference.`; } const action = params.operation === "install" ? "installing" : "verifying"; if (error.status === 401) { diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index e1866bbedca2..b27c34f219c1 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -364,7 +364,7 @@ describe("skills-clawhub", () => { path: "/api/v1/skills/missing-skill/install", body: "remote not-found detail", expected: - 'Skill "missing-skill" not found. Run `openclaw skills list` to see available skills.', + 'Skill "missing-skill" not found on ClawHub. Run `openclaw skills search missing-skill` to find the right skill reference.', }, { name: "maps missing versioned skills to the skills-info recovery message", @@ -373,7 +373,7 @@ describe("skills-clawhub", () => { path: "/custom-clawhub/api/v1/skills/missing-skill", body: "remote versioned not-found detail", expected: - 'Skill "missing-skill" not found. Run `openclaw skills list` to see available skills.', + 'Skill "missing-skill" not found on ClawHub. Run `openclaw skills search missing-skill` to find the right skill reference.', }, { name: "keeps server failures distinct from missing skills", @@ -1961,7 +1961,7 @@ describe("skills-clawhub", () => { { ok: false, error: - 'Skill "missing-skill" not found. Run `openclaw skills list` to see available skills.', + 'Skill "missing-skill" not found on ClawHub. Run `openclaw skills search missing-skill` to find the right skill reference.', }, ]); expect(results[0]?.ok ? "" : results[0]?.error).not.toContain(body); diff --git a/src/tasks/cron-history-retention.ts b/src/tasks/cron-history-retention.ts index 76923855717c..246467392c30 100644 --- a/src/tasks/cron-history-retention.ts +++ b/src/tasks/cron-history-retention.ts @@ -75,6 +75,5 @@ export function shouldPruneTerminalTask( if (cronHistoryOverflowTaskIds.has(task.taskId)) { return true; } - const cleanupAfter = resolveEffectiveTaskCleanupAfter(task); - return cleanupAfter !== undefined && now >= cleanupAfter; + return now >= resolveEffectiveTaskCleanupAfter(task); } diff --git a/src/tasks/task-registry-mutation.ts b/src/tasks/task-registry-mutation.ts index 2aa95e8ba312..88320e749687 100644 --- a/src/tasks/task-registry-mutation.ts +++ b/src/tasks/task-registry-mutation.ts @@ -168,8 +168,7 @@ export function updateTask(taskId: string, patch: Partial): TaskReco } if (isTerminalTaskStatus(next.status) && typeof next.cleanupAfter !== "number") { const createdAt = next.createdAt ?? Date.now(); - const cleanupAfter = resolveTaskCleanupAfter({ ...next, createdAt }); - Object.assign(next, cleanupAfter === undefined ? {} : { cleanupAfter }); + next.cleanupAfter = resolveTaskCleanupAfter({ ...next, createdAt }); } const sessionIndexChanged = normalizeOptionalString(current.ownerKey) !== normalizeOptionalString(next.ownerKey) || diff --git a/src/tasks/task-registry-record-api.ts b/src/tasks/task-registry-record-api.ts index 06aa57f42ef1..4a242a01984c 100644 --- a/src/tasks/task-registry-record-api.ts +++ b/src/tasks/task-registry-record-api.ts @@ -263,8 +263,7 @@ export function createTaskRecord(params: { ...(params.detail !== undefined ? { detail: structuredClone(params.detail) } : {}), }); if (isTerminalTaskStatus(record.status) && typeof record.cleanupAfter !== "number") { - const cleanupAfter = resolveTaskCleanupAfter(record); - Object.assign(record, cleanupAfter === undefined ? {} : { cleanupAfter }); + record.cleanupAfter = resolveTaskCleanupAfter(record); } const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); const deliveryState = requesterOrigin diff --git a/src/tasks/task-registry.audit.test.ts b/src/tasks/task-registry.audit.test.ts index 1b99f623d5db..810aa2fff4a5 100644 --- a/src/tasks/task-registry.audit.test.ts +++ b/src/tasks/task-registry.audit.test.ts @@ -200,7 +200,7 @@ describe("task-registry audit", () => { expect(findings.map((finding) => finding.code)).toEqual(["lost"]); }); - it("does not flag count-retained cron history as missing cleanup", () => { + it("flags terminal cron history that is missing cleanup", () => { const findings = listTaskAuditFindings({ tasks: [ createTask({ @@ -213,6 +213,6 @@ describe("task-registry audit", () => { ], }); - expect(findings).toEqual([]); + expect(findings.map((finding) => finding.code)).toEqual(["missing_cleanup"]); }); }); diff --git a/src/tasks/task-registry.audit.ts b/src/tasks/task-registry.audit.ts index 22528c1fa103..57a55b449f10 100644 --- a/src/tasks/task-registry.audit.ts +++ b/src/tasks/task-registry.audit.ts @@ -8,7 +8,7 @@ import { type TaskAuditSummary, } from "./task-registry.audit.shared.js"; import type { TaskRecord } from "./task-registry.types.js"; -import { resolveEffectiveTaskCleanupAfter, resolveTaskCleanupAfter } from "./task-retention.js"; +import { resolveEffectiveTaskCleanupAfter } from "./task-retention.js"; type TaskAuditOptions = { now?: number; @@ -134,9 +134,7 @@ export function listTaskAuditFindings(options: TaskAuditOptions = {}): TaskAudit if (task.status === "lost") { const effectiveCleanupAfter = resolveEffectiveTaskCleanupAfter(task); const retainedUntilCleanup = - typeof task.cleanupAfter === "number" && - effectiveCleanupAfter !== undefined && - effectiveCleanupAfter > now; + typeof task.cleanupAfter === "number" && effectiveCleanupAfter > now; findings.push( createFinding({ severity: retainedUntilCleanup ? "warn" : "error", @@ -167,8 +165,7 @@ export function listTaskAuditFindings(options: TaskAuditOptions = {}): TaskAudit task.status !== "lost" && task.status !== "queued" && task.status !== "running" && - typeof task.cleanupAfter !== "number" && - resolveTaskCleanupAfter(task) !== undefined + typeof task.cleanupAfter !== "number" ) { findings.push( createFinding({ @@ -196,7 +193,6 @@ function isRetainedLostTaskAuditFinding(finding: TaskAuditFinding, now = Date.no finding.code === "lost" && finding.task.status === "lost" && typeof finding.task.cleanupAfter === "number" && - typeof cleanupAfter === "number" && cleanupAfter > now ); } @@ -238,10 +234,7 @@ export function summarizeRetainedLostTaskAuditFindings( } count += 1; const cleanupAfter = resolveEffectiveTaskCleanupAfter(finding.task); - if ( - typeof cleanupAfter === "number" && - (nextCleanupAfter === undefined || cleanupAfter < nextCleanupAfter) - ) { + if (nextCleanupAfter === undefined || cleanupAfter < nextCleanupAfter) { nextCleanupAfter = cleanupAfter; } } diff --git a/src/tasks/task-registry.maintenance.issue-60299.test.ts b/src/tasks/task-registry.maintenance.issue-60299.test.ts index 49bf47481b25..c72df4542ac6 100644 --- a/src/tasks/task-registry.maintenance.issue-60299.test.ts +++ b/src/tasks/task-registry.maintenance.issue-60299.test.ts @@ -22,6 +22,7 @@ import { } from "./task-runtime.test-helpers.js"; const GRACE_EXPIRED_MS = 10 * 60_000; +const DEFAULT_TASK_RETENTION_MS = 7 * 24 * 60 * 60_000; function makeStaleTask(overrides: Partial): TaskRecord { const now = Date.now(); @@ -899,7 +900,7 @@ describe("task-registry maintenance issue #60299", () => { status: "succeeded", endedAt: now + index + 1, lastEventAt: now + index + 1, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { kind: "cron-run", status: "ok", storeKey: "store:history" }, }), ); @@ -935,7 +936,7 @@ describe("task-registry maintenance issue #60299", () => { status: "succeeded", endedAt: now, lastEventAt: now, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { kind: "cron-run", status: "ok", storeKey }, }); const quietRuns = Array.from({ length: CRON_HISTORY_KEEP_PER_JOB + 1 }, (_, index) => @@ -946,7 +947,7 @@ describe("task-registry maintenance issue #60299", () => { status: "succeeded", endedAt: now + index + 1, lastEventAt: now + index + 1, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { storeKey }, }), ); @@ -976,7 +977,7 @@ describe("task-registry maintenance issue #60299", () => { startedAt: now + index, endedAt: now + CRON_HISTORY_KEEP_PER_JOB + 1, lastEventAt: now + CRON_HISTORY_KEEP_PER_JOB + 1, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { kind: "cron-run", status: "ok", storeKey: "store:same-ms" }, }), ); @@ -1000,7 +1001,7 @@ describe("task-registry maintenance issue #60299", () => { status: "succeeded", endedAt: now + index + 2, lastEventAt: now + index + 2, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { kind: "cron-run", status: "ok", storeKey: "store:a" }, }), ); @@ -1011,7 +1012,7 @@ describe("task-registry maintenance issue #60299", () => { status: "succeeded", endedAt: now + 1, lastEventAt: now + 1, - cleanupAfter: 0, + cleanupAfter: now + DEFAULT_TASK_RETENTION_MS, detail: { kind: "cron-run", status: "ok", storeKey: "store:b" }, }); const { currentTasks } = createTaskRegistryMaintenanceHarness({ @@ -1025,10 +1026,9 @@ describe("task-registry maintenance issue #60299", () => { expect(currentTasks.has(storeBTask.taskId)).toBe(true); }); - it("still stamps non-cron terminal rows with default retention", async () => { + it("stamps recent terminal cron rows with default retention", async () => { const endedAt = Date.now(); const task = makeStaleTask({ - runtime: "subagent", status: "succeeded", endedAt, lastEventAt: endedAt, @@ -1036,14 +1036,32 @@ describe("task-registry maintenance issue #60299", () => { }); const { currentTasks } = createTaskRegistryMaintenanceHarness({ tasks: [task] }); + expect(previewTaskRegistryMaintenance().cleanupStamped).toBe(1); const result = await runTaskRegistryMaintenance(); expect(result.cleanupStamped).toBe(1); expect(requireTaskRecord(currentTasks, task.taskId).cleanupAfter).toBe( - endedAt + 7 * 24 * 60 * 60_000, + endedAt + DEFAULT_TASK_RETENTION_MS, ); }); + it("prunes terminal cron rows after default retention", async () => { + const endedAt = Date.now() - DEFAULT_TASK_RETENTION_MS - 1; + const task = makeStaleTask({ + status: "succeeded", + endedAt, + lastEventAt: endedAt, + cleanupAfter: undefined, + }); + const { currentTasks } = createTaskRegistryMaintenanceHarness({ tasks: [task] }); + + expect(previewTaskRegistryMaintenance().pruned).toBe(1); + const result = await runTaskRegistryMaintenance(); + + expect(result.pruned).toBe(1); + expect(currentTasks.has(task.taskId)).toBe(false); + }); + it("still prunes lost cron rows after 24 hours", async () => { const endedAt = Date.now() - 25 * 60 * 60_000; const task = makeStaleTask({ diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index f95783077079..4b6c7f6ea56a 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -490,15 +490,7 @@ function hasDetachedTaskRecoveryHook(): boolean { } function shouldStampCleanupAfter(task: TaskRecord): boolean { - return ( - isTerminalTask(task) && - typeof task.cleanupAfter !== "number" && - resolveTaskCleanupAfter(task) !== undefined - ); -} - -function resolveCleanupAfter(task: TaskRecord): number | undefined { - return resolveTaskCleanupAfter(task); + return isTerminalTask(task) && typeof task.cleanupAfter !== "number"; } function taskReferenceAt(task: TaskRecord): number { @@ -698,7 +690,7 @@ function markTaskLost( ...task, status: "lost", endedAt: lostAt, - })!; + }); const updated = taskRegistryMaintenanceRuntime.markTaskLostById({ taskId: task.taskId, @@ -747,7 +739,7 @@ function projectTaskRecovered(task: TaskRecord, recovery: CronTerminalRecovery): ...projected, ...(typeof projected.cleanupAfter === "number" ? {} - : { cleanupAfter: resolveCleanupAfter(projected) }), + : { cleanupAfter: resolveTaskCleanupAfter(projected) }), }; } @@ -767,7 +759,7 @@ function projectTaskLost( ...projected, ...(typeof projected.cleanupAfter === "number" ? {} - : { cleanupAfter: resolveCleanupAfter(projected) }), + : { cleanupAfter: resolveTaskCleanupAfter(projected) }), }; } @@ -1114,12 +1106,10 @@ export async function runTaskRegistryMaintenance(): Promise { it("keeps lost tasks on a shorter retention window", () => { expect( resolveTaskCleanupAfter({ - runtime: "subagent", status: "lost", createdAt: 10, }), ).toBe(10 + LOST_TASK_RETENTION_MS); expect( resolveTaskCleanupAfter({ - runtime: "subagent", status: "failed", createdAt: 10, }), @@ -26,7 +24,6 @@ describe("task retention", () => { it("stamps cleanupAfter from terminal task timing", () => { expect( resolveTaskCleanupAfter({ - runtime: "subagent", status: "lost", createdAt: 1, lastEventAt: 2, @@ -38,7 +35,6 @@ describe("task retention", () => { it("clamps old lost cleanupAfter values to the shorter retention window", () => { expect( resolveEffectiveTaskCleanupAfter({ - runtime: "subagent", status: "lost", createdAt: 1, endedAt: 10, @@ -50,7 +46,6 @@ describe("task retention", () => { it("preserves explicit cleanupAfter for non-lost terminal tasks", () => { expect( resolveEffectiveTaskCleanupAfter({ - runtime: "subagent", status: "failed", createdAt: 1, endedAt: 10, @@ -58,27 +53,4 @@ describe("task retention", () => { }), ).toBe(99); }); - - it("does not stamp or honor cleanupAfter for terminal cron history", () => { - const task = { - runtime: "cron" as const, - status: "failed" as const, - createdAt: 1, - endedAt: 10, - cleanupAfter: 99, - }; - expect(resolveTaskCleanupAfter(task)).toBeUndefined(); - expect(resolveEffectiveTaskCleanupAfter(task)).toBeUndefined(); - }); - - it("keeps lost cron tasks on the 24-hour window", () => { - expect( - resolveTaskCleanupAfter({ - runtime: "cron", - status: "lost", - createdAt: 1, - endedAt: 10, - }), - ).toBe(10 + LOST_TASK_RETENTION_MS); - }); }); diff --git a/src/tasks/task-retention.ts b/src/tasks/task-retention.ts index df93ec7fbbfe..f84dd89333a0 100644 --- a/src/tasks/task-retention.ts +++ b/src/tasks/task-retention.ts @@ -10,25 +10,16 @@ function resolveTaskRetentionMs(status: TaskStatus): number { } export function resolveTaskCleanupAfter( - task: Pick, -): number | undefined { - if (task.runtime === "cron" && task.status !== "lost") { - return undefined; - } + task: Pick, +): number { const terminalAt = task.endedAt ?? task.lastEventAt ?? task.createdAt; return terminalAt + resolveTaskRetentionMs(task.status); } export function resolveEffectiveTaskCleanupAfter( - task: Pick< - TaskRecord, - "runtime" | "status" | "endedAt" | "lastEventAt" | "createdAt" | "cleanupAfter" - >, -): number | undefined { + task: Pick, +): number { const statusCleanupAfter = resolveTaskCleanupAfter(task); - if (statusCleanupAfter === undefined) { - return undefined; - } if (typeof task.cleanupAfter !== "number") { return statusCleanupAfter; } diff --git a/test/release-check.test.ts b/test/release-check.test.ts index af6c9e26447f..3e3804269629 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -913,7 +913,7 @@ describe("collectPackUnpackedSizeErrors", () => { expect( collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]), ).toEqual([ - "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 211812352 bytes (202.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 213909504 bytes (204.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", ]); }); diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index 3b661e59f3e0..17a6e7ed9c63 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -11,6 +11,7 @@ import { hasPromptSnapshotAffectingChange, hasQaSmokeAffectingChange, hasSqliteSessionLifecycleAffectingChange, + resolveChangedDockerSeedLanes, } from "../../scripts/lib/ci-changed-node-test-plan.mts"; import { listExtensionTestFilesForRoots, @@ -54,6 +55,30 @@ function expectAllExtensionConfigs( expect(configs).toContain("test/vitest/vitest.extension-codex.config.ts"); } +const allDockerSeedLanes = ["mcp-channels", "cron-mcp-cleanup", "mcp-code-mode-gateway"]; +it.each([ + [["scripts/e2e/mcp-channels-seed.ts"], ["mcp-channels"]], + [["scripts/e2e/cron-mcp-cleanup-seed.ts"], ["cron-mcp-cleanup"]], + [["scripts/e2e/mcp-code-mode-gateway-seed.ts"], ["mcp-code-mode-gateway"]], + [["scripts/e2e/lib/mcp-code-mode-probe-server.ts"], ["mcp-code-mode-gateway"]], + [["scripts/e2e/docker-openai-seed.ts"], allDockerSeedLanes], + [ + [ + "scripts/e2e/mcp-code-mode-gateway-seed.ts", + "scripts/e2e/mcp-channels-seed.ts", + "scripts/e2e/lib/mcp-code-mode-probe-server.ts", + "scripts/e2e/cron-mcp-cleanup-seed.ts", + ], + allDockerSeedLanes, + ], + [[".github/workflows/ci.yml"], allDockerSeedLanes], + [["scripts/lib/ci-changed-node-test-plan.mts"], allDockerSeedLanes], + [["scripts\\e2e\\lib\\mcp-code-mode-probe-server.ts"], ["mcp-code-mode-gateway"]], + [["scripts/e2e/install-e2e.ts", "docs/ci.md"], []], +])("resolves Docker seed lanes for %j", (changedPaths, expected) => { + expect(resolveChangedDockerSeedLanes(changedPaths)).toEqual(expected); +}); + describe("CI changed Node test plan", () => { it("routes Control UI style changes through source-scanning policy tests", () => { const shards = createChangedNodeTestShards(["ui/src/styles/chat/layout.css"]); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 13279177a59a..a264b7ffe8d9 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -322,6 +322,7 @@ function runCiManifestFixture(options: { export const hasSqliteSessionLifecycleAffectingChange = (changedPaths) => changedPaths.includes("src/sqlite-session-owner.ts") || changedPaths.includes("test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts"); + export const resolveChangedDockerSeedLanes = (changedPaths) => changedPaths.includes("scripts/e2e/docker-openai-seed.ts") ? ["mcp-channels", "cron-mcp-cleanup"] : []; `, "utf8", ); @@ -372,6 +373,7 @@ function runCiManifestFixture(options: { ...((options.openClawKitTests ?? options.bundledPlanner) ? ["openclawkit-tests-contract-v1"] : []), + ...(options.bundledPlanner ? ["docker-seed-e2e-contract-v1"] : []), ].join("\n"), ); const outputPath = path.join(root, "manifest.out"); @@ -2740,6 +2742,39 @@ NODE expect(workflow.jobs.android.strategy["max-parallel"]).toBe(2); }); + it("runs changed Docker seed owners in one gated scheduler job", () => { + const source = readFileSync(".github/workflows/ci.yml", "utf8"); + const jobs = readCiWorkflow().jobs; + const job = jobs["docker-seed-e2e"]; + expect(source).toContain("docker-seed-e2e-contract-v1"); + expect(source).toContain( + 'typeof changedNodeTestPlan.resolveChangedDockerSeedLanes === "function"', + ); + expect(jobs.preflight.outputs).toMatchObject({ + docker_seed_lanes: "${{ steps.manifest.outputs.docker_seed_lanes }}", + run_docker_seed_e2e: "${{ steps.manifest.outputs.run_docker_seed_e2e }}", + }); + expect(job.if).toBe("needs.preflight.outputs.run_docker_seed_e2e == 'true'"); + expect(job.needs).toEqual(["preflight"]); + expect(job["timeout-minutes"]).toBe(60); + expect(job.permissions).toEqual({ contents: "read" }); + expect(job.strategy).toBeUndefined(); + expect(job.steps[0]).toEqual(jobs["pnpm-store-warmup"].steps[0]); + expect(job.steps[1].uses).toBe("./.github/actions/setup-node-env"); + const run = job.steps[2] as WorkflowStep; + const parallelism = run.env?.OPENCLAW_DOCKER_ALL_PARALLELISM; + expect(run).toMatchObject({ + run: "pnpm test:docker:all", + env: { + OPENCLAW_DOCKER_ALL_LANES: "${{ needs.preflight.outputs.docker_seed_lanes }}", + OPENCLAW_DOCKER_ALL_LIVE_MODE: "skip", + OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1", + OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: parallelism, + }, + }); + expect(parallelism).toContain("&& 3 || 1"); + }); + it("splits Windows tests two ways on every runner backend", () => { const workflow = readCiWorkflow(); const runStep = workflow.jobs["checks-windows"].steps.find( @@ -3063,6 +3098,7 @@ NODE "checks-ui-e2e": "ubuntu-24.04", "checks-ui-e2e-real-gateway": "ubuntu-24.04", "control-ui-i18n": "ubuntu-24.04", + "docker-seed-e2e": "ubuntu-24.04", "ios-build": "macos-26", "macos-node": "macos-15", "macos-swift": "macos-26", @@ -3084,6 +3120,7 @@ NODE // Same serial Chromium workload as checks-ui-e2e: hosted attempt 1 made it // the run's slowest job (205s mean vs a 150-190s plateau). "checks-ui-e2e-real-gateway": "blacksmith-16vcpu-ubuntu-2404", + "docker-seed-e2e": "blacksmith-16vcpu-ubuntu-2404", "qa-smoke-ci-profile": "blacksmith-16vcpu-ubuntu-2404", "sqlite-session-lifecycle": "blacksmith-8vcpu-ubuntu-2404", "macos-node": "blacksmith-6vcpu-macos-15", @@ -3093,6 +3130,10 @@ NODE "checks-ui": "blacksmith-8vcpu-ubuntu-2404", "checks-windows": "blacksmith-8vcpu-windows-2025", } as const; + const expectedHybridForkRunners = { + ...expectedHybridFirstAttemptRunners, + "docker-seed-e2e": "ubuntu-24.04", + } as const; const configurableJobs = Object.entries(jobs) .filter(([, job]) => String(job["runs-on"]).startsWith("${{")) .map(([jobName]) => jobName) @@ -3160,7 +3201,7 @@ NODE runnerBackend: "hybrid", }), `${jobName}: returning-contributor fork`, - ).toBe(expectedHybridFirstAttemptRunners[jobName as keyof typeof expectedHostedRunners]); + ).toBe(expectedHybridForkRunners[jobName as keyof typeof expectedHostedRunners]); } const widenedHybridMatrixRows = [ @@ -3524,6 +3565,7 @@ NODE "checks-ui-e2e", "checks-ui-e2e-real-gateway", "control-ui-i18n", + "docker-seed-e2e", "native-i18n", "qa-smoke-ci-profile", "sqlite-session-lifecycle", @@ -5904,6 +5946,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(legacy.outputs.run_native_i18n).toBe("false"); expect(legacy.outputs.run_openclawkit_tests).toBe("false"); expect(legacy.outputs.run_qa_smoke_ci).toBe("false"); + expect(legacy.outputs.run_docker_seed_e2e).toBe("false"); + expect(legacy.outputs.docker_seed_lanes).toBe(""); expect(legacy.outputs.run_channel_contracts_shards).toBe("false"); expect(legacy.outputs.run_protocol_event_coverage).toBe("false"); expect( @@ -5935,6 +5979,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(current.outputs.run_native_i18n).toBe("true"); expect(current.outputs.run_openclawkit_tests).toBe("true"); expect(current.outputs.run_qa_smoke_ci).toBe("true"); + expect(current.outputs.run_docker_seed_e2e).toBe("false"); + expect(current.outputs.docker_seed_lanes).toBe(""); expect(current.outputs.run_sqlite_session_lifecycle).toBe("true"); expect(current.outputs.run_channel_contracts_shards).toBe("true"); expect(current.outputs.run_protocol_event_coverage).toBe("true"); @@ -6018,9 +6064,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); } + const dockerSeedPath = "scripts/e2e/docker-openai-seed.ts"; const changedPullRequest = runCiManifestFixture({ bundledPlanner: true, - changedPaths: ["src/focused.ts", "extensions/codex/src/focused.ts"], + changedPaths: ["src/focused.ts", "extensions/codex/src/focused.ts", dockerSeedPath], eventName: "pull_request", }); expect(changedPullRequest.status, changedPullRequest.output).toBe(0); @@ -6050,6 +6097,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); expect(changedPullRequest.outputs.run_checks_node_core_dist).toBe("true"); expect(changedPullRequest.outputs.run_sqlite_session_lifecycle).toBe("false"); + expect(changedPullRequest.outputs.run_docker_seed_e2e).toBe("true"); + expect(changedPullRequest.outputs.docker_seed_lanes).toBe("mcp-channels cron-mcp-cleanup"); const mixedFallbackPullRequest = runCiManifestFixture({ bundledPlanner: true, @@ -7119,6 +7168,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" "macos-swift", "ios-build", "android", + "docker-seed-e2e", ]; expect(workflow.on.pull_request).not.toHaveProperty("paths-ignore"); diff --git a/test/scripts/docker-e2e-seeds.test.ts b/test/scripts/docker-e2e-seeds.test.ts deleted file mode 100644 index 2638f91af87e..000000000000 --- a/test/scripts/docker-e2e-seeds.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Docker E2E seed tests cover generated config and fixture-server contracts. -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -function readScript(pathname: string): string { - return readFileSync(pathname, "utf8"); -} - -describe("Docker E2E seed scripts", () => { - it("keeps the shared OpenAI seed helper aligned with packaged provider onboarding", () => { - const source = readScript("scripts/e2e/docker-openai-seed.ts"); - - expect(source).toContain("../../dist/plugin-sdk/provider-onboard.js"); - expect(source).toContain('const DOCKER_OPENAI_MODEL_REF = "openai/gpt-5.6-luna"'); - expect(source).toContain('api: "openai-responses"'); - expect(source).toContain('aliases: [{ modelRef: DOCKER_OPENAI_MODEL_REF, alias: "GPT" }]'); - expect(source).toContain("primaryModelRef: DOCKER_OPENAI_MODEL_REF"); - expect(source).toContain("openAiProvider.apiKey = apiKey"); - }); - - it("keeps MCP channels config wired to seeded transcript artifacts", () => { - const source = readScript("scripts/e2e/mcp-channels-seed.ts"); - - expect(source).toContain( - 'const sessionsDir = path.join(stateDir, "agents", "main", "sessions")', - ); - expect(source).toContain('const sessionFile = path.join(sessionsDir, "sess-main.jsonl")'); - expect(source).toContain('const storePath = path.join(sessionsDir, "sessions.json")'); - expect(source).toContain('channel: "imessage"'); - expect(source).toContain('accountId: "imessage-default"'); - expect(source).toContain('"hello from seeded transcript"'); - expect(source).toContain('content: "seeded image attachment"'); - expect(source).toContain("__openclaw: {"); - expect(source).toContain("media: ["); - expect(source).toContain('url: "media://inbound/seeded-image.png"'); - expect(source).toContain('contentType: "image/png"'); - }); - - it("keeps cron MCP cleanup config wired to its probe server artifacts", () => { - const source = readScript("scripts/e2e/cron-mcp-cleanup-seed.ts"); - - expect(source).toContain('process.title = "openclaw-cron-mcp-cleanup-probe"'); - expect(source).toContain('const probeDir = path.join(stateDir, "cron-mcp-cleanup")'); - expect(source).toContain('const serverPath = path.join(probeDir, "probe-server.mjs")'); - expect(source).toContain("await fs.rm(pidsPath, { force: true })"); - expect(source).toContain("cronCleanupProbe: {"); - expect(source).toContain('command: "node"'); - expect(source).toContain("args: [serverPath]"); - expect(source).toContain("cwd: probeDir"); - expect(source).toContain("subagents: {\n runTimeoutSeconds: 8,"); - }); - - it("keeps MCP code-mode gateway config wired to its fixture server artifacts", () => { - const seed = readScript("scripts/e2e/mcp-code-mode-gateway-seed.ts"); - const source = seed + readScript("scripts/e2e/lib/mcp-code-mode-probe-server.ts"); - - expect(source).toContain('const serverPath = path.join(stateDir, "mcp-code-mode-fixture"'); - expect(source).toContain('["alpha", "fixture-note-alpha"]'); - expect(source).toContain("responses: {\n enabled: true,"); - expect(source).toContain("codeMode: {\n enabled: true,"); - expect(source).toContain("fixture: {"); - expect(source).toContain('command: "node"'); - expect(source).toContain("args: [serverPath]"); - expect(source).toContain("cwd: path.dirname(serverPath)"); - expect(source).toContain("connectionTimeoutMs: 30_000"); - expect(seed).not.toContain("sync:"); - }); -}); diff --git a/test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts b/test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts index 7ef93389a256..d77a0fb3d2c6 100644 --- a/test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts +++ b/test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts @@ -20,7 +20,11 @@ afterEach(() => { function makeLane( name: "baseline" | "candidate", sha: string, - options: { diagnosticOnly?: boolean; status?: "pass" | "fail"; withGif?: boolean } = {}, + options: { + diagnosticOnly?: boolean; + status?: "blocked" | "fail" | "pass"; + withGif?: boolean; + } = {}, ) { const repo = mkdtempSync(path.join(tmpdir(), `mantis-telegram-${name}-repo-`)); tempDirs.push(repo); @@ -188,6 +192,7 @@ describe("scripts/mantis/build-telegram-desktop-proof-evidence", () => { ]); expect(manifest.comparison.pass).toBe(false); + expect(manifest.comparison.outcome).toBe("fail"); expect(manifest.artifacts).toContainEqual( expect.objectContaining({ lane: "candidate", @@ -200,6 +205,43 @@ describe("scripts/mantis/build-telegram-desktop-proof-evidence", () => { ).toBe("candidate png"); }); + it("preserves a blocked lane as a distinct non-failure outcome", () => { + const baselineSha = "a".repeat(40); + const candidateSha = "b".repeat(40); + const baseline = makeLane("baseline", baselineSha, { status: "blocked", withGif: false }); + const candidate = makeLane("candidate", candidateSha, { status: "blocked", withGif: false }); + const outputDir = mkdtempSync(path.join(tmpdir(), "mantis-telegram-blocked-proof-")); + tempDirs.push(outputDir); + + const { manifest } = writeTelegramDesktopProofEvidence([ + "--output-dir", + outputDir, + "--baseline-repo-root", + baseline.repo, + "--baseline-output-dir", + baseline.outputDir, + "--baseline-sha", + baselineSha, + "--baseline-status", + "blocked", + "--candidate-repo-root", + candidate.repo, + "--candidate-output-dir", + candidate.outputDir, + "--candidate-sha", + candidateSha, + "--candidate-status", + "blocked", + ]); + + expect(manifest.comparison).toMatchObject({ + baseline: { status: "blocked" }, + candidate: { status: "blocked" }, + outcome: "blocked", + pass: false, + }); + }); + it("preserves an unattested diagnostic-only startup failure", () => { const baselineSha = "a".repeat(40); const candidateSha = "b".repeat(40); diff --git a/test/scripts/mantis-publish-pr-evidence.test.ts b/test/scripts/mantis-publish-pr-evidence.test.ts index dfbe81f2fb6d..8e10ead19a03 100644 --- a/test/scripts/mantis-publish-pr-evidence.test.ts +++ b/test/scripts/mantis-publish-pr-evidence.test.ts @@ -490,6 +490,43 @@ describe("scripts/mantis/publish-pr-evidence", () => { expect(shouldPublishPrComment(manifest, { requestSource: "pull_request_target" })).toBe(false); }); + it("publishes a visible blocked stop-report without proof media", () => { + const dir = mkdtempSync(path.join(tmpdir(), "mantis-evidence-test-")); + tempDirs.push(dir); + const manifestPath = path.join(dir, "mantis-evidence.json"); + writeFileSync( + manifestPath, + JSON.stringify({ + artifacts: [], + comparison: { + baseline: { expected: "typed reasoning chunks", status: "blocked" }, + candidate: { expected: "typed reasoning chunks", status: "blocked" }, + outcome: "blocked", + pass: false, + }, + id: "telegram-desktop-proof", + scenario: "telegram-desktop-proof", + schemaVersion: 1, + summary: + "Mantis could not prove this change because the harness cannot emit typed reasoning chunks.", + title: "Mantis Telegram Desktop Proof", + }), + ); + + const manifest = loadEvidenceManifest(manifestPath); + const body = renderEvidenceComment({ + manifest, + marker: "", + rawBase: "https://artifacts.openclaw.ai/mantis/telegram-desktop/pr-1/run-1", + requestSource: "pull_request_target", + }); + + expect(body).toContain("- Overall: `blocked`"); + expect(body).toContain("harness cannot emit typed reasoning chunks"); + expect(shouldPublishPrComment(manifest, { requestSource: "issue_comment" })).toBe(true); + expect(shouldPublishPrComment(manifest, { requestSource: "pull_request_target" })).toBe(true); + }); + it("rejects artifact paths that escape the manifest directory", () => { const dir = mkdtempSync(path.join(tmpdir(), "mantis-evidence-test-")); tempDirs.push(dir); diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index e3967c9d2c5d..07fd48e9119f 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -146,14 +146,29 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(workflow.permissions?.actions).toBe("read"); expect(leaseRun).toContain("lease-restore"); expect(leaseRun).toContain("until node --import tsx"); - expect(leaseRun).toContain("deadline=$(( SECONDS + 15 * 60 ))"); - expect(leaseRun).toContain("still leased by another run after 15 minutes"); - expect(leaseRun).toContain("sleep 60"); + expect(leaseRun).toContain("lease_deadline=$(( SECONDS + 4 * 60 * 60 ))"); + expect(leaseRun).toContain("remained busy for four hours"); + expect(leaseRun).not.toContain("15 * 60"); + expect(leaseRun).toContain("sleep 15"); expect(leaseRun.indexOf('echo "lease_file=$credential_dir/lease.json"')).toBeLessThan( leaseRun.indexOf("until node --import tsx"), ); }); + it("reports an honest blocked proof without failing the workflow", () => { + const trusted = workflowStep("Restore and validate trusted lane evidence").run ?? ""; + const inspect = workflowStep("Inspect Mantis evidence manifest").run ?? ""; + const fail = workflowStep("Fail when Mantis Telegram desktop proof failed"); + + expect(trusted).toContain('lane_status="blocked"'); + expect(trusted).toContain('|| "$baseline_status" == "blocked"'); + expect(trusted).toContain('[[ "$lane_status" == "pass" || "$lane_status" == "fail" ]]'); + expect(trusted).toContain('.comparison.outcome == "blocked"'); + expect(trusted).not.toContain("comparisonPass"); + expect(inspect).toContain(".comparison.outcome"); + expect(fail.if).toContain("steps.inspect.outputs.comparison_status != 'blocked'"); + }); + it("releases the runner Telegram QA lease after the agent", () => { const workflow = parse(readFileSync(WORKFLOW, "utf8")) as Workflow; const steps = workflow.jobs?.run_telegram_desktop_proof?.steps ?? []; @@ -1021,7 +1036,9 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(laneScript).toContain("/proc/self/fd/${descriptor}"); expect(laneScript).not.toContain("readRecorderSession"); expect(laneScript).toContain('"artifacts"'); - expect(laneScript).toContain('status: status === "complete" ? "pass" : "fail"'); + expect(laneScript).toContain( + 'status: status === "complete" ? "pass" : status === "blocked" ? "blocked" : "fail"', + ); expect(workflow).toContain("if .sutAttestation == null then"); expect(workflow).toContain('.status == "infra-error" and .artifacts == {} and .sendCount == 0'); expect(workflow).toContain( diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 66a3f8e9c0a6..6e897201a1ce 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1258,18 +1258,14 @@ describe("scripts/test-projects changed-target routing", () => { "test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", "test/e2e/qa-lab/runtime/mcp-channels.fixture.ts", "test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts", - "scripts/e2e/mcp-channels-seed.ts", - "scripts/e2e/docker-openai-seed.ts", "scripts/e2e/mcp-code-mode-gateway-docker.sh", "scripts/e2e/mcp-code-mode-gateway-live-docker.sh", - "scripts/e2e/mcp-code-mode-gateway-seed.ts", "scripts/e2e/agent-bundle-mcp-tools-docker.sh", "test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts", "scripts/mcp-code-mode-gateway-e2e.ts", "scripts/e2e/cron-cli-docker.sh", "scripts/e2e/cron-mcp-cleanup-docker.sh", "scripts/e2e/cron-mcp-cleanup-docker-client.ts", - "scripts/e2e/cron-mcp-cleanup-seed.ts", ]; expect(findUnmatchedExplicitTestTargets(targets)).toEqual([]); @@ -1280,7 +1276,6 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/plugin-prerelease-test-plan.test.ts", "test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", "test/scripts/cron-mcp-cleanup-docker-client.test.ts", - "test/scripts/docker-e2e-seeds.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts", "test/scripts/session-log-mentions.test.ts", "src/agents/agent-bundle-mcp-runtime.test.ts", diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 5714752c8473..464d5cc32089 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -555,6 +555,7 @@ export type { export type CronRunStatus = NonNullable; export type CronDeliveryStatus = NonNullable; export type CronJobsEnabledFilter = NonNullable; +export type CronJobsScheduleKindFilter = NonNullable; export type CronJobsTriggerFilter = NonNullable; export type CronJobsSortBy = NonNullable; export type CronRunScope = NonNullable; @@ -565,6 +566,7 @@ export type CronPayload = ProtocolCronJob["payload"]; export type CronStatus = { enabled: boolean; + triggersEnabled: boolean; jobs: number; nextWakeAtMs?: number | null; }; diff --git a/ui/src/e2e/cron-trigger-authoring.e2e.test.ts b/ui/src/e2e/cron-trigger-authoring.e2e.test.ts index 9f867c75cc0b..972b4573c878 100644 --- a/ui/src/e2e/cron-trigger-authoring.e2e.test.ts +++ b/ui/src/e2e/cron-trigger-authoring.e2e.test.ts @@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import type { Page } from "playwright"; import { expect, it } from "vitest"; +import type { ApplicationContext } from "../app/context.ts"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -14,6 +15,7 @@ const suite = createControlUiE2eSuite({ const proofDirectory = process.env.OPENCLAW_TRIGGER_UI_PROOF_DIR; const proofStage = process.env.OPENCLAW_TRIGGER_UI_PROOF_STAGE ?? "after"; +type CronTriggerTestApp = HTMLElement & { runtime?: { context: ApplicationContext } }; const scriptJob = { id: "existing-script-automation", @@ -52,6 +54,14 @@ async function captureProof(page: Page, name: string) { }); } +async function captureTriggerCapabilityProof(page: Page, name: string) { + await page + .locator(".settings-row__title") + .filter({ hasText: "Condition trigger" }) + .evaluate((element) => element.scrollIntoView({ block: "center" })); + await captureProof(page, name); +} + async function selectSeconds(page: Page) { const unit = page.locator("wa-select").filter({ has: page.locator('[slot="label"]', { hasText: "Unit" }), @@ -82,7 +92,7 @@ suite.define(() => { hasMore: false, nextOffset: null, }, - "cron.status": { enabled: true, jobs: 1, nextWakeAtMs: null }, + "cron.status": { enabled: true, triggersEnabled: true, jobs: 1, nextWakeAtMs: null }, }, }); @@ -162,4 +172,117 @@ suite.define(() => { }, ); }); + + it("keeps saved and unsaved trigger drafts separate from reconnect-refreshed scheduler capability", async () => { + await suite.withPage( + { locale: "en-US", serviceWorkers: "block", viewport: { height: 1_050, width: 1_440 } }, + async ({ page }) => { + const initialConfig = { cron: { triggers: { enabled: true } } }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + appliedConfigHash: "trigger-config-1", + config: initialConfig, + configRevisionHash: "trigger-config-1", + hash: "trigger-config-1", + issues: [], + raw: JSON.stringify(initialConfig), + valid: true, + }, + "cron.list": listResponse([]), + "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, + "cron.status": { enabled: true, triggersEnabled: true, jobs: 0, nextWakeAtMs: null }, + }, + }); + + await page.goto(`${suite.server.baseUrl}cron`); + await page.locator('[data-test-id="cron-new-task"]').click(); + await page.locator("details.cron-advanced > summary").click(); + const triggerToggle = page + .locator(".settings-row--toggle") + .filter({ hasText: "Condition trigger" }); + await expect.poll(() => triggerToggle.count()).toBe(1); + + const unsaved = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + await config.ensureLoaded(); + config.setWritesSuspended(true); + config.patchForm(["cron", "triggers", "enabled"], false); + return { dirty: config.state.configFormDirty, needsApply: config.state.configNeedsApply }; + }); + expect(unsaved).toEqual({ dirty: true, needsApply: false }); + expect(await gateway.getRequests("config.set")).toHaveLength(0); + await expect.poll(() => triggerToggle.count()).toBe(1); + await captureTriggerCapabilityProof(page, "05-unsaved-disable-keeps-active-trigger"); + + const saveResult = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + config.setWritesSuspended(false); + const saved = await config.save(); + return { + dirty: config.state.configFormDirty, + needsApply: config.state.configNeedsApply, + saved, + }; + }); + expect(saveResult).toEqual({ dirty: false, needsApply: true, saved: true }); + const savedRequest = await gateway.waitForRequest("config.set"); + expect(JSON.parse(String((savedRequest.params as { raw?: string }).raw))).toEqual({ + cron: { triggers: { enabled: false } }, + }); + expect(await gateway.getRequests("config.apply")).toHaveLength(0); + await expect.poll(() => triggerToggle.count()).toBe(1); + await captureTriggerCapabilityProof(page, "06-saved-unapplied-keeps-active-trigger"); + + const previousStatuses = (await gateway.getRequests("cron.status")).length; + await gateway.setMethodResponse("cron.status", { + enabled: true, + triggersEnabled: false, + jobs: 0, + nextWakeAtMs: null, + }); + await gateway.closeLatest(1012, "refresh effective trigger capability"); + await expect + .poll(async () => (await gateway.getRequests("cron.status")).length) + .toBeGreaterThan(previousStatuses); + await page.locator('[data-test-id="cron-new-task"]').click(); + await page.locator("details.cron-advanced > summary").click(); + await expect.poll(() => triggerToggle.count()).toBe(0); + await page.getByText("Condition triggers are disabled by cron.triggers.enabled.").waitFor(); + await captureTriggerCapabilityProof(page, "07-reconnect-refreshes-disabled-trigger"); + + const oppositeDraft = await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + if (!config) { + throw new Error("Runtime config capability is unavailable"); + } + await config.ensureLoaded(); + config.setWritesSuspended(true); + config.patchForm(["cron", "triggers", "enabled"], true); + return config.state.configFormDirty; + }); + expect(oppositeDraft).toBe(true); + await expect.poll(() => triggerToggle.count()).toBe(0); + await captureTriggerCapabilityProof( + page, + "08-unsaved-enable-cannot-author-disabled-trigger", + ); + await page.evaluate(async () => { + const config = (document.querySelector("openclaw-app") as CronTriggerTestApp).runtime + ?.context.runtimeConfig; + await config?.discardDraft(); + config?.setWritesSuspended(false); + }); + }, + ); + }); }); diff --git a/ui/src/e2e/cron-trigger-filter.e2e.test.ts b/ui/src/e2e/cron-trigger-filter.e2e.test.ts index c83167ce33b0..9ee9651a2551 100644 --- a/ui/src/e2e/cron-trigger-filter.e2e.test.ts +++ b/ui/src/e2e/cron-trigger-filter.e2e.test.ts @@ -61,7 +61,7 @@ suite.define(() => { ], }, "cron.runs": { entries: [], total: 0, offset: 0, limit: 50, hasMore: false }, - "cron.status": { enabled: true, jobs: 2, nextWakeAtMs: null }, + "cron.status": { enabled: true, triggersEnabled: false, jobs: 2, nextWakeAtMs: null }, }, }); diff --git a/ui/src/lib/cron/index.ts b/ui/src/lib/cron/index.ts index 0b8c9f5f1476..a508b1578846 100644 --- a/ui/src/lib/cron/index.ts +++ b/ui/src/lib/cron/index.ts @@ -7,6 +7,7 @@ import type { CronJob, CronDeliveryStatus, CronJobsEnabledFilter, + CronJobsScheduleKindFilter, CronJobsTriggerFilter, CronJobsListResult, CronJobsSortBy, @@ -191,7 +192,6 @@ export type CronFieldKey = export type CronFieldErrors = Partial>; -export type CronJobsScheduleKindFilter = "all" | "at" | "every" | "cron" | "on-exit" | "stream"; export type CronJobsLastStatusFilter = "all" | CronRunStatus | "unknown"; type CronRunsLoadStatus = "ok" | "error" | "skipped"; diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 85cfc845f269..ce5831d3fcbd 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -145,8 +145,8 @@ describe("renderAgents", () => { const job = createCronJob("implicit-default-job", { name: "Implicit default-agent reminder", }); - const globalNextWakeAtMs = Date.now() + 60_000; - const scopedNextWakeAtMs = globalNextWakeAtMs + 3_600_000; + const nextWakeAtMs = Date.now() + 60_000; + const scopedNextWakeAtMs = nextWakeAtMs + 3_600_000; const container = document.createElement("div"); render( renderAgents( @@ -154,7 +154,7 @@ describe("renderAgents", () => { activePanel: "cron", selectedAgentId: "alpha", cron: { - status: { enabled: true, jobs: 51, nextWakeAtMs: globalNextWakeAtMs }, + status: { enabled: true, triggersEnabled: true, jobs: 51, nextWakeAtMs }, jobs: [job], jobsTotal: 1, jobsHasMore: false, @@ -188,7 +188,7 @@ describe("renderAgents", () => { expect(nextWakeRow?.querySelector(".settings-row__control")?.textContent?.trim()).toBe( formatNextRun(scopedNextWakeAtMs), ); - expect(nextWakeRow?.textContent).not.toContain(formatNextRun(globalNextWakeAtMs)); + expect(nextWakeRow?.textContent).not.toContain(formatNextRun(nextWakeAtMs)); }); it("loads and renders the selected agent's 51st cron job when Load more is clicked", async () => { @@ -227,7 +227,7 @@ describe("renderAgents", () => { activePanel: "cron", selectedAgentId: "alpha", cron: { - status: { enabled: true, jobs: 80, nextWakeAtMs: null }, + status: { enabled: true, triggersEnabled: true, jobs: 80, nextWakeAtMs: null }, jobs: cronState.cronJobs, jobsTotal: cronState.cronJobsTotal, jobsHasMore: cronState.cronJobsHasMore, diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index 9fea134c88f1..7b2c48e2ed0d 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -148,8 +148,17 @@ function cronListResponse(jobs: CronJob[]): CronJobsListResult { }; } -function createRequest() { +function createRequest( + cronStatus: { enabled: boolean; jobs: number; triggersEnabled: boolean } = { + enabled: true, + jobs: 0, + triggersEnabled: true, + }, +) { return vi.fn(async (method: string) => { + if (method === "cron.status") { + return { ...cronStatus }; + } if (method === "cron.list") { return cronListResponse([]); } @@ -169,6 +178,39 @@ afterEach(() => { }); describe("CronPage editor state sync", () => { + it.each([ + { scenario: "an unsaved enable edit", active: false, edited: true, saved: false }, + { scenario: "an unsaved disable edit", active: true, edited: false, saved: false }, + { scenario: "a saved-but-unapplied enable edit", active: false, edited: true, saved: true }, + { scenario: "a saved-but-unapplied disable edit", active: true, edited: false, saved: true }, + ])("keeps trigger authoring owned by cron.status during $scenario", async (scenario) => { + const request = createRequest({ enabled: true, jobs: 0, triggersEnabled: scenario.active }); + const gateway = createGateway({ request } as unknown as GatewayBrowserClient, true); + const context = createContext(gateway); + const editedConfig = { cron: { triggers: { enabled: scenario.edited } } }; + Object.assign(context.runtimeConfig.state, { + configForm: editedConfig, + configFormDirty: !scenario.saved, + configNeedsApply: scenario.saved, + configSnapshot: scenario.saved ? { config: editedConfig, sourceConfig: editedConfig } : null, + }); + const page = createPage(context, { render: true }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: scenario.active }), + ); + (page.querySelector('[data-test-id="cron-new-task"]') as HTMLButtonElement).click(); + await waitForCronPage(() => expect(page.querySelector("fieldset.cron-editor")).not.toBeNull()); + + const triggerToggle = Array.from(page.querySelectorAll("wa-switch.settings-toggle")).find( + (toggle) => toggle.textContent?.includes("Condition trigger"), + ); + expect(Boolean(triggerToggle)).toBe(scenario.active); + if (!scenario.active) { + expect(page.textContent).toContain("disabled by cron.triggers.enabled"); + } + }); + it("keeps conflict detail attached to the authoritative job outside active filters", async () => { const staleJob: CronJob = { id: "filtered-conflict-job", @@ -663,7 +705,7 @@ describe("CronPage lifecycle", () => { const connectedState = page.cron; page.cron = { ...connectedState, - cronStatus: { enabled: true, jobs: 1 }, + cronStatus: { enabled: true, triggersEnabled: true, jobs: 1 }, cronJobs: [{ id: "old" } as never], cronCreateOpen: true, }; @@ -682,6 +724,40 @@ describe("CronPage lifecycle", () => { expect(page.cron).not.toBe(disconnectedState); }); + it("refreshes trigger authoring from scheduler status after reconnect", async () => { + const schedulerStatus = { enabled: true, jobs: 0, triggersEnabled: true }; + const request = createRequest(schedulerStatus); + const client = { request } as unknown as GatewayBrowserClient; + const gateway = createGateway(client, true); + const context = createContext(gateway); + Object.assign(context.runtimeConfig.state, { + configForm: { cron: { triggers: { enabled: true } } }, + configNeedsApply: true, + }); + const page = createPage(context, { render: true }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: true }), + ); + schedulerStatus.triggersEnabled = false; + gateway.emitSnapshot({ phase: "stopped" }); + expect(page.cron.cronStatus).toBeNull(); + gateway.emitSnapshot({ phase: "connected" }); + + await waitForCronPage(() => + expect(page.cron.cronStatus).toMatchObject({ triggersEnabled: false }), + ); + expect(request.mock.calls.filter(([method]) => method === "cron.status")).toHaveLength(2); + (page.querySelector('[data-test-id="cron-new-task"]') as HTMLButtonElement).click(); + await waitForCronPage(() => expect(page.querySelector("fieldset.cron-editor")).not.toBeNull()); + + const triggerToggle = Array.from(page.querySelectorAll("wa-switch.settings-toggle")).find( + (toggle) => toggle.textContent?.includes("Condition trigger"), + ); + expect(triggerToggle).toBeUndefined(); + expect(page.textContent).toContain("disabled by cron.triggers.enabled"); + }); + it("rejects model suggestions from an earlier connection epoch", async () => { const staleModels = createDeferred<{ models: Array<{ id: string }> }>(); let modelRequestCount = 0; diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts index 665cb0aeb41a..4296f73fca33 100644 --- a/ui/src/pages/cron/cron-page.ts +++ b/ui/src/pages/cron/cron-page.ts @@ -1,5 +1,4 @@ import { consume } from "@lit/context"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html } from "lit"; import { state } from "lit/decorators.js"; import type { AgentsListResult, CronJob } from "../../api/types.ts"; @@ -11,7 +10,6 @@ import { showConfirmDialog } from "../../components/confirm-dialog.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; import { watchAgentScope } from "../../lib/agents/index.ts"; -import { currentConfigObject } from "../../lib/config/config-state-model.ts"; import { addCronJob, cancelCronEdit, @@ -48,13 +46,6 @@ import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { buildCronSuggestions, THINKING_SUGGESTIONS } from "./form-suggestions.ts"; import { renderCron, type CronDetailTab, type CronListTab } from "./view.ts"; -function resolveCronTriggersEnabled(context: ApplicationContext): boolean { - const config = currentConfigObject(context.runtimeConfig.state) ?? {}; - const cron = isRecord(config.cron) ? config.cron : undefined; - const triggers = cron && isRecord(cron.triggers) ? cron.triggers : undefined; - return triggers?.enabled !== false; -} - class CronPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -408,7 +399,6 @@ class CronPage extends OpenClawLightDomElement { agentId: fallbackAgentId, loading: this.cron.cronLoading, canManage, - triggersEnabled: resolveCronTriggersEnabled(this.context), status: this.cron.cronStatus, failingCount: this.cron.cronFailingCount, agentScoped: this.cron.cronAgentId !== null, diff --git a/ui/src/pages/cron/view-run-history.test.ts b/ui/src/pages/cron/view-run-history.test.ts index 2e7d5530bd69..4669fa97359f 100644 --- a/ui/src/pages/cron/view-run-history.test.ts +++ b/ui/src/pages/cron/view-run-history.test.ts @@ -24,7 +24,7 @@ describe("cron view run history", () => { { ts: 1_000, jobId: "job-1", action: "finished", status: "ok", summary: "older run" }, { ts: 2_000, jobId: "job-2", action: "finished", status: "ok", summary: "newer run" }, ], - status: { enabled: true, jobs: 2 }, + status: { enabled: true, triggersEnabled: true, jobs: 2 }, }); const titles = Array.from(container.querySelectorAll(".cron-run-entry__title")).map((el) => diff --git a/ui/src/pages/cron/view.editor.test.ts b/ui/src/pages/cron/view.editor.test.ts new file mode 100644 index 000000000000..890aebe280dd --- /dev/null +++ b/ui/src/pages/cron/view.editor.test.ts @@ -0,0 +1,721 @@ +// Control UI tests cover the Automations (cron) editor pane behavior. +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_CRON_FORM } from "../../test-helpers/cron.ts"; +import { + createCronViewJob as createJob, + findToggleByLabel, + getButtonByText, + getElement, + renderCronView as renderView, + selectSegmented, +} from "./view.test-support.ts"; + +describe("cron view editor", () => { + it("renders the create view with prompt, general, and schedule cards", () => { + const onSubmit = vi.fn(); + const onClosePanel = vi.fn(); + const container = renderView({ createOpen: true, onSubmit, onClosePanel }); + + expect(container.querySelector(".cron-page--detail")?.textContent).toContain("New automation"); + expect(container.querySelector("#cron-payload-text")).toBeInstanceOf(HTMLTextAreaElement); + expect(container.querySelector("#cron-name")).toBeInstanceOf(HTMLInputElement); + expect(container.querySelector('[data-test-id="cron-schedule-kind-every"]')).toBeInstanceOf( + HTMLElement, + ); + // Create mode has no run-history tab and no enabled switch. + expect(container.querySelector('[data-test-id="cron-detail-tab-history"]')).toBeNull(); + expect(container.querySelector('[data-test-id="cron-toggle-enabled"]')).toBeNull(); + + // Generated controls take their accessible name from the row title label. + const nameLabel = container.querySelector('label[for="cron-name"]'); + expect(nameLabel?.textContent).toContain("Name"); + expect(nameLabel?.textContent).toContain("required"); + expect(container.querySelector("#cron-name")?.getAttribute("aria-required")).toBe("true"); + const promptLabel = container.querySelector('label[for="cron-payload-text"]'); + expect(promptLabel?.textContent).toContain("required"); + // The payload-kind help renders as the prompt row's description. + expect(promptLabel?.closest(".settings-row")?.textContent).toContain( + "Starts an agent run in its own session using your prompt.", + ); + + getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement).click(); + expect(onSubmit).toHaveBeenCalledTimes(1); + + getElement(container, '[data-test-id="cron-back"]', HTMLButtonElement).click(); + expect(onClosePanel).toHaveBeenCalledTimes(1); + }); + + it("wires shared text and select controls without changing their field ownership", () => { + const onFormChange = vi.fn(); + const container = renderView({ + createOpen: true, + channels: ["telegram"], + channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }], + channelLabels: { telegram: "Telegram fallback" }, + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + deliveryChannel: "telegram", + failureAlertMode: "custom", + failureAlertChannel: "retired-channel", + }, + onFormChange, + }); + + const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement); + prompt.value = "do the thing"; + prompt.dispatchEvent(new Event("input", { bubbles: true })); + expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" }); + + for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) { + const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + const input = getElement(container, `#${id}`, HTMLInputElement); + if (field === "sessionKey" || field === "deliveryAccountId") { + expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default"); + } + input.value = field; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field }); + } + + const channel = getElement( + container, + "#cron-failure-alert-channel", + HTMLElement, + ) as HTMLElement & { + value: string; + }; + const optionValues = Array.from(channel.querySelectorAll("wa-option"), (option) => + option.getAttribute("value"), + ); + expect(optionValues).toContain("retired-channel"); + expect(channel.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); + const telegramOption = channel.querySelector( + 'wa-option[value="telegram"]', + ); + expect(telegramOption?.label).toBe("Telegram fallback"); + Object.defineProperty(channel, "value", { configurable: true, value: "telegram" }); + channel.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(channel, "value"); + expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" }); + }); + + it("switches schedule inputs by segmented kind and wires kind changes", () => { + const onFormChange = vi.fn(); + const everyContainer = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every" }, + onFormChange, + }); + expect(everyContainer.querySelector("#cron-every-amount")).not.toBeNull(); + expect(everyContainer.querySelector("#cron-cron-expr")).toBeNull(); + const activeEvery = getElement( + everyContainer, + '[data-test-id="cron-schedule-kind-every"]', + HTMLElement, + ) as HTMLElement & { checked: boolean }; + expect(activeEvery.checked).toBe(true); + selectSegmented( + getElement(everyContainer, '[data-test-id="cron-schedule-kind-cron"]', HTMLElement), + ); + expect(onFormChange).toHaveBeenCalledWith({ + scheduleKind: "cron", + deleteAfterRun: false, + }); + + selectSegmented( + getElement(everyContainer, '[data-test-id="cron-schedule-kind-at"]', HTMLElement), + ); + expect(onFormChange).toHaveBeenCalledWith({ + scheduleKind: "at", + deleteAfterRun: true, + }); + + const atContainer = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "at" }, + }); + expect(atContainer.querySelector("#cron-schedule-at")).not.toBeNull(); + + const cronContainer = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", deleteAfterRun: true }, + onFormChange, + }); + expect(cronContainer.querySelector("#cron-cron-expr")).not.toBeNull(); + expect(findToggleByLabel(cronContainer, "Delete after run")).toBeNull(); + selectSegmented( + getElement(cronContainer, '[data-test-id="cron-schedule-kind-every"]', HTMLElement), + ); + expect(onFormChange).toHaveBeenCalledWith({ + scheduleKind: "every", + deleteAfterRun: false, + }); + + // on-exit jobs keep a pill so they can convert to an editable schedule; + // the on-exit pill only exists while it is the current value. + const onExitContainer = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "on-exit" }, + }); + const onExitKind = onExitContainer.querySelector('[data-test-id="cron-schedule-kind-on-exit"]'); + expect(onExitKind).not.toBeNull(); + expect(findToggleByLabel(onExitContainer, "Delete after run")).not.toBeNull(); + expect(everyContainer.querySelector('[data-test-id="cron-schedule-kind-on-exit"]')).toBeNull(); + const onExitFormChange = vi.fn(); + const keptOnExitContainer = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "on-exit", deleteAfterRun: false }, + onFormChange: onExitFormChange, + }); + selectSegmented( + getElement(keptOnExitContainer, '[data-test-id="cron-schedule-kind-at"]', HTMLElement), + ); + expect(onExitFormChange).toHaveBeenCalledWith({ scheduleKind: "at" }); + }); + + it("shows a live schedule summary when inputs are valid", () => { + const plural = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "30" }, + }); + expect(plural.querySelector(".cron-schedule-summary")?.textContent).toContain( + "Runs every 30 minutes", + ); + + const singular = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "1", everyUnit: "hours" }, + }); + expect(singular.querySelector(".cron-schedule-summary")?.textContent).toContain( + "Runs every hour", + ); + + const invalid = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "" }, + }); + expect(invalid.querySelector(".cron-schedule-summary")).toBeNull(); + + // One-shot summaries render the parsed date/time, not a duration. + const once = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "at", scheduleAt: "2026-07-14T09:00" }, + }); + const onceText = once.querySelector(".cron-schedule-summary")?.textContent ?? ""; + expect(onceText).toContain("Runs once at"); + expect(onceText).toContain("2026"); + }); + + it("offers a Seconds interval unit so sub-minute cadences stay editable", () => { + const container = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + everyAmount: "30", + everyUnit: "seconds", + }, + }); + const unitSelect = Array.from(container.querySelectorAll("wa-select")).find( + (select) => select.querySelector('[slot="label"]')?.textContent === "Unit", + ); + expect(unitSelect).toBeInstanceOf(HTMLElement); + if (!unitSelect) { + throw new Error("Expected the interval unit picker"); + } + const values = Array.from(unitSelect.querySelectorAll("wa-option"), (option) => + option.getAttribute("value"), + ); + expect(values).toEqual(["seconds", "minutes", "hours", "days"]); + }); + + it("summarizes seconds intervals, including singular and decimal amounts", () => { + const singular = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "1", everyUnit: "seconds" }, + }); + expect(singular.querySelector(".cron-schedule-summary")?.textContent).toContain( + "Runs every second", + ); + + const plural = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + everyAmount: "30", + everyUnit: "seconds", + }, + }); + expect(plural.querySelector(".cron-schedule-summary")?.textContent).toContain( + "Runs every 30 seconds", + ); + + const decimal = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + everyAmount: "0.45", + everyUnit: "seconds", + }, + }); + expect(decimal.querySelector(".cron-schedule-summary")?.textContent).toContain( + "Runs every 0.45 seconds", + ); + }); + + it("hides the schedule summary for recurring amounts that cannot produce safe milliseconds", () => { + for (const everyAmount of ["0x10", "1e3", "+1", String(Number.MAX_SAFE_INTEGER), "0.000001"]) { + const container = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount }, + }); + expect(container.querySelector(".cron-schedule-summary")).toBeNull(); + } + }); + + it("renders supported delivery options and normalizes stale announce selection", () => { + // systemEvent + main session cannot announce; a stale announce selection + // must render as none and the announce option must disappear. + const container = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + sessionTarget: "main", + payloadKind: "systemEvent", + deliveryMode: "announce", + }, + }); + const delivery = getElement(container, "#cron-delivery-mode", HTMLElement); + const values = Array.from(delivery.querySelectorAll("wa-option"), (option) => + option.getAttribute("value"), + ); + expect(values).toEqual(["webhook", "none"]); + expect(container.querySelector("#cron-delivery-channel")).toBeNull(); + }); + + it("shows announce channel/to rows and webhook URL row per delivery mode", () => { + const announce = renderView({ + createOpen: true, + channels: ["telegram"], + form: { ...DEFAULT_CRON_FORM, deliveryMode: "announce" }, + }); + expect(announce.querySelector("#cron-delivery-channel")).not.toBeNull(); + expect(announce.querySelector("#cron-delivery-to")).not.toBeNull(); + + const webhook = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, deliveryMode: "webhook" }, + fieldErrors: { deliveryTo: "cron.errors.webhookUrlRequired" }, + canSubmit: false, + }); + const urlInput = getElement(webhook, "#cron-delivery-to", HTMLInputElement); + expect(urlInput.getAttribute("aria-invalid")).toBe("true"); + expect(urlInput.getAttribute("aria-describedby")).toBe("cron-error-deliveryTo"); + expect(webhook.querySelector("#cron-error-deliveryTo")?.textContent).toContain( + "Webhook URL is required.", + ); + }); + + it("shows model and reasoning rows only for agent-turn payloads", () => { + const agentTurn = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, payloadKind: "agentTurn" }, + }); + expect(agentTurn.querySelector("#cron-payload-model")).not.toBeNull(); + expect(agentTurn.querySelector("#cron-payload-thinking")).not.toBeNull(); + + const systemEvent = renderView({ + createOpen: true, + form: { ...DEFAULT_CRON_FORM, payloadKind: "systemEvent", sessionTarget: "main" }, + }); + expect(systemEvent.querySelector("#cron-payload-model")).toBeNull(); + + const conditional = renderView({ + createOpen: true, + form: { + ...DEFAULT_CRON_FORM, + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + }); + expect(conditional.querySelector("#cron-trigger-script")).toBeInstanceOf(HTMLTextAreaElement); + expect(conditional.querySelector(".cron-trigger-summary")?.textContent).toContain( + "Trigger configured", + ); + }); + + it("waits for scheduler status before presenting trigger capability", () => { + const pending = renderView({ createOpen: true, status: null }); + + expect(findToggleByLabel(pending, "Condition trigger")).toBeNull(); + expect(pending.textContent).not.toContain("disabled by cron.triggers.enabled"); + }); + + it("hides trigger authoring when the operator disabled triggers but keeps clear available", () => { + const onFormChange = vi.fn(); + const status = { enabled: true, triggersEnabled: false, jobs: 0 }; + const disabled = renderView({ createOpen: true, status, onFormChange }); + expect(disabled.querySelector("#cron-trigger-script")).toBeNull(); + expect(disabled.textContent).toContain("disabled by cron.triggers.enabled"); + + const configured = renderView({ + createOpen: true, + status, + onFormChange, + form: { + ...DEFAULT_CRON_FORM, + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + }); + getButtonByText(configured, "Clear trigger").click(); + expect(onFormChange).toHaveBeenCalledWith({ triggerEnabled: false }); + }); + + it("renders script payloads as highlighted read-only code without exposing script authoring", () => { + const script = "const result = await agent('check status')"; + const job = createJob("job-script", { + name: "Status script", + payload: { kind: "script", script }, + }); + const container = renderView({ + jobs: [job], + editingJob: job, + form: { + ...DEFAULT_CRON_FORM, + name: job.name, + payloadKind: "script", + payloadLocked: true, + payloadText: script, + }, + }); + + const payload = getElement(container, "#cron-payload-text", HTMLPreElement); + expect(payload.textContent).toBe(script); + expect(payload.querySelector(".hljs-keyword")?.textContent).toBe("const"); + expect(payload.querySelector(".hljs-string")?.textContent).toBe("'check status'"); + expect(container.querySelector("textarea#cron-payload-text")).toBeNull(); + expect(container.querySelector("#cron-payload-kind")?.getAttribute("value")).toBeNull(); + expect((container.querySelector("#cron-payload-kind") as HTMLInputElement).value).toBe( + "Script", + ); + expect(container.textContent).toContain("contents stay read-only"); + expect(container.querySelector('option[value="script"]')).toBeNull(); + expect(findToggleByLabel(container, "Condition trigger")).toBeNull(); + expect(container.textContent).toContain("Script payloads cannot use condition triggers"); + }); + + it("keeps an incompatible existing script condition trigger visible and explicitly clearable", () => { + const onFormChange = vi.fn(); + const job = createJob("job-script-trigger", { + payload: { kind: "script", script: "json({ state: {} })" }, + trigger: { script: "json({ fire: true })" }, + }); + const container = renderView({ + jobs: [job], + editingJob: job, + onFormChange, + form: { + ...DEFAULT_CRON_FORM, + name: job.name, + payloadKind: "script", + payloadLocked: true, + payloadText: "json({ state: {} })", + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + fieldErrors: { triggerScript: "cron.errors.triggerScriptPayloadUnsupported" }, + canSubmit: false, + }); + + expect(findToggleByLabel(container, "Condition trigger")).toBeNull(); + expect(container.querySelector("#cron-trigger-script")).toBeNull(); + expect(container.textContent).toContain("Script payloads cannot use condition triggers"); + getButtonByText(container, "Clear trigger").click(); + expect(onFormChange).toHaveBeenCalledWith({ triggerEnabled: false }); + }); + + it("attaches the triggered minimum-interval error to the visible recurring interval", () => { + const container = renderView({ + createOpen: true, + canSubmit: false, + form: { + ...DEFAULT_CRON_FORM, + everyAmount: "5", + everyUnit: "seconds", + triggerEnabled: true, + triggerScript: "json({ fire: true })", + }, + fieldErrors: { everyAmount: "cron.errors.triggerIntervalTooShort" }, + }); + + const interval = getElement(container, "#cron-every-amount", HTMLInputElement); + expect(interval.getAttribute("aria-invalid")).toBe("true"); + expect(interval.getAttribute("aria-describedby")).toBe("cron-error-everyAmount"); + expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain( + "at least every 30 seconds", + ); + }); + + it("highlights locked command payloads as shell and keeps heartbeat payloads plain", () => { + const job = createJob("job-command", { + name: "Backup", + payload: { kind: "script", script: "" }, + }); + const command = renderView({ + jobs: [job], + editingJob: job, + form: { + ...DEFAULT_CRON_FORM, + name: job.name, + payloadKind: "command", + payloadLocked: true, + payloadText: "echo $HOME", + }, + }); + const payload = getElement(command, "#cron-payload-text", HTMLPreElement); + expect(payload.textContent).toBe("echo $HOME"); + expect(payload.querySelector(".hljs-built_in")?.textContent).toBe("echo"); + expect(findToggleByLabel(command, "Condition trigger")).not.toBeNull(); + + const heartbeat = renderView({ + jobs: [job], + editingJob: job, + form: { + ...DEFAULT_CRON_FORM, + name: job.name, + payloadKind: "heartbeat", + payloadLocked: true, + payloadText: "", + }, + }); + expect(heartbeat.querySelector("#cron-payload-text")).toBeInstanceOf(HTMLTextAreaElement); + }); + + it("disables submit and lists blocking fields when validation fails", () => { + const container = renderView({ + createOpen: true, + canSubmit: false, + form: { ...DEFAULT_CRON_FORM, name: "" }, + fieldErrors: { name: "cron.errors.nameRequired" }, + }); + const submit = getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement); + expect(submit.disabled).toBe(true); + const statusLinks = Array.from(container.querySelectorAll(".cron-form-status__link")); + expect(statusLinks.some((link) => link.textContent?.includes("Name"))).toBe(true); + expect(container.textContent).toContain("Fix 1 field to continue."); + }); + + it("renders job detail authority independently from the filtered table", () => { + const onRun = vi.fn(); + const onToggle = vi.fn(); + const onClone = vi.fn(); + const onRemove = vi.fn(); + const onDetailTabChange = vi.fn(); + const job = createJob("job-1", { name: "Nightly digest" }); + const container = renderView({ + jobs: [], + jobsTotal: 0, + editingJob: job, + onRun, + onToggle, + onClone, + onRemove, + onDetailTabChange, + }); + + expect(getElement(container, ".cron-detail-title", HTMLDivElement).textContent).toContain( + "Nightly digest", + ); + expect(getButtonByText(container, "Save changes")).toBeInstanceOf(HTMLButtonElement); + + getElement(container, '[data-test-id="cron-run-now"]', HTMLButtonElement).click(); + expect(onRun).toHaveBeenCalledWith(job, "force"); + + const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement); + const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & { + checked: boolean; + }; + expect(toggleInput.checked).toBe(true); + expect(toggle.textContent).toContain("Active"); + toggleInput.checked = false; + toggleInput.dispatchEvent(new Event("change", { bubbles: true })); + expect(onToggle).toHaveBeenCalledWith(job, false); + + const jobMenu = container.querySelector("wa-dropdown.cron-job-menu"); + const runIfDue = jobMenu?.querySelector('wa-dropdown-item[value="run-if-due"]'); + if (runIfDue) { + jobMenu?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: runIfDue }, bubbles: true }), + ); + } + expect(onRun).toHaveBeenCalledWith(job, "due"); + const clone = jobMenu?.querySelector('wa-dropdown-item[value="clone"]'); + if (clone) { + jobMenu?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: clone }, bubbles: true }), + ); + } + expect(onClone).toHaveBeenCalledWith(job); + const remove = jobMenu?.querySelector('wa-dropdown-item[value="remove"]'); + if (remove) { + jobMenu?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: remove }, bubbles: true }), + ); + } + expect(onRemove).toHaveBeenCalledWith(job); + + const settingsTab = container.querySelector('[data-test-id="cron-detail-tab-settings"]'); + expect(settingsTab?.getAttribute("aria-selected")).toBe("true"); + container + .querySelector('[data-test-id="cron-detail-tab-history"]') + ?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true })); + expect(onDetailTabChange).toHaveBeenCalledWith("history"); + }); + + it.each([true, false])("preserves task browsing for canManage=%s", (canManage) => { + const job = createJob("permission-job"); + const onSelectJob = vi.fn(); + const container = renderView({ canManage, jobs: [job], onSelectJob }); + + expect(Boolean(container.querySelector('[data-test-id="cron-new-task"]'))).toBe(canManage); + expect(Boolean(container.querySelector('[data-test-id="cron-row-run-permission-job"]'))).toBe( + canManage, + ); + expect(Boolean(container.querySelector("wa-dropdown.cron-job-menu"))).toBe(canManage); + getElement(container, '[data-test-id="cron-row-permission-job"]', HTMLDivElement).click(); + expect(onSelectJob).toHaveBeenCalledWith(job); + }); + + it("keeps read-only operators on browse surfaces without mutation controls", () => { + const onSelectJob = vi.fn(); + const job = createJob("job-1", { + name: "Nightly digest", + description: "Read-only operators can inspect this task", + }); + const list = renderView({ canManage: false, jobs: [job], jobsTotal: 1, onSelectJob }); + + expect(list.textContent).toContain("Browsing only"); + expect(list.querySelector('[data-test-id="cron-new-task"]')).toBeNull(); + expect(list.querySelector('[data-test-id="cron-row-run-job-1"]')).toBeNull(); + expect(list.querySelector('[data-test-id="cron-row-toggle-job-1"]')).toBeNull(); + expect(list.querySelector("wa-dropdown.cron-job-menu")).toBeNull(); + expect(list.querySelector("[data-suggestion]")).toBeNull(); + expect(list.querySelector(".cron-table__description")?.textContent).toContain(job.description); + + getElement(list, '[data-test-id="cron-row-job-1"]', HTMLDivElement).click(); + expect(onSelectJob).toHaveBeenCalledWith(job); + + const detail = renderView({ + canManage: false, + jobs: [], + editingJob: job, + }); + expect(detail.textContent).toContain("Browsing only"); + expect(detail.querySelector('[data-test-id="cron-run-now"]')).toBeNull(); + expect(detail.querySelector('[data-test-id="cron-toggle-enabled"]')).toBeNull(); + expect(detail.querySelector("wa-dropdown.cron-job-menu")).toBeNull(); + expect(detail.querySelector('[data-test-id="cron-submit"]')).toBeNull(); + expect(detail.querySelector(".cron-editor-actions")).toBeNull(); + expect(getElement(detail, ".cron-editor", HTMLFieldSetElement).disabled).toBe(true); + expect(detail.querySelector('[data-test-id="cron-detail-tab-history"]')).not.toBeNull(); + expect(detail.querySelector('[data-test-id="cron-detail-description"]')?.textContent).toContain( + job.description, + ); + }); + + it("locks the editor and back navigation while a save is pending", () => { + const job = createJob("job-1", { name: "Nightly digest" }); + const container = renderView({ jobs: [job], editingJob: job, busy: true }); + + const editor = getElement(container, ".cron-editor", HTMLFieldSetElement); + const name = getElement(container, "#cron-name", HTMLInputElement); + const back = getElement(container, '[data-test-id="cron-back"]', HTMLButtonElement); + const submit = getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement); + + expect(editor.disabled).toBe(true); + expect(editor.getAttribute("aria-busy")).toBe("true"); + expect(name.matches(":disabled")).toBe(true); + expect(back.disabled).toBe(true); + expect(submit.disabled).toBe(true); + expect(submit.textContent).toContain("Saving"); + }); + + it("shows run history instead of the editor on the history tab", () => { + const job = createJob("job-1", { + name: "Nightly digest", + description: "Saved description stays visible in history", + }); + const container = renderView({ + jobs: [job], + editingJob: job, + detailTab: "history", + runs: [ + { + ts: 5, + jobId: "job-1", + action: "finished", + jobName: "Nightly digest", + status: "ok", + summary: "ran", + }, + ], + }); + expect(container.querySelector(".cron-run-entry")).not.toBeNull(); + expect(container.querySelector(".cron-editor")).toBeNull(); + const description = container.querySelector('[data-test-id="cron-detail-description"]'); + expect(description?.textContent).toContain(job.description); + }); + + it("shows the paused switch state for disabled jobs", () => { + const onToggle = vi.fn(); + const job = createJob("job-1", { enabled: false }); + const container = renderView({ + jobs: [], + editingJob: job, + onToggle, + }); + const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement); + const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & { + checked: boolean; + }; + expect(toggleInput.checked).toBe(false); + expect(toggle.textContent).toContain("Paused"); + toggleInput.checked = true; + toggleInput.dispatchEvent(new Event("change", { bubbles: true })); + expect(onToggle).toHaveBeenCalledWith(job, true); + }); + + it("renders model-picker suggestions with the remaining text datalists", () => { + const container = renderView({ + createOpen: true, + agentSuggestions: ["main"], + modelSuggestions: ["openai/gpt-5.2"], + thinkingSuggestions: ["low"], + timezoneSuggestions: ["UTC"], + deliveryToSuggestions: ["+15551234"], + accountSuggestions: ["default"], + }); + for (const id of [ + "cron-agent-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]) { + expect(container.querySelector(`datalist#${id}`)).not.toBeNull(); + } + const model = getElement(container, "#cron-payload-model-picker", HTMLElement); + expect(model.querySelector('wa-option[value="openai/gpt-5.2"]')).not.toBeNull(); + expect(model.querySelector('[data-provider-icon="codex"]')).not.toBeNull(); + expect(container.querySelector("#cron-payload-model")?.hidden).toBe(true); + // The inherit option must resolve to a real catalog string — a missing key + // renders the raw "common.default" literal to every locale. + const inheritText = model.querySelector('wa-option[value=""]')?.textContent ?? ""; + expect(inheritText).toContain("Default"); + expect(inheritText).not.toContain("common.default"); + }); +}); diff --git a/ui/src/pages/cron/view.test-support.ts b/ui/src/pages/cron/view.test-support.ts index c3f0111f3e9e..e8049d73f6fe 100644 --- a/ui/src/pages/cron/view.test-support.ts +++ b/ui/src/pages/cron/view.test-support.ts @@ -1,4 +1,5 @@ import { render } from "lit"; +import { expect } from "vitest"; import type { CronJob } from "../../api/types.ts"; import { DEFAULT_CRON_FORM } from "../../test-helpers/cron.ts"; import { renderCron } from "./view.ts"; @@ -26,9 +27,12 @@ function createCronViewProps(overrides: Partial = {}): CronProps { agentId: "main", loading: false, canManage: true, - triggersEnabled: true, jobsLoadingMore: false, - status: null, + status: { + enabled: true, + triggersEnabled: true, + jobs: Math.max(overrides.jobsTotal ?? 0, overrides.jobs?.length ?? 0), + }, failingCount: null, agentScoped: false, scopedTotal: null, @@ -95,3 +99,45 @@ export function renderCronView(overrides: Partial = {}) { render(renderCron(createCronViewProps(overrides)), container); return container; } + +export function getButtonByText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.replace(/\s+/g, " ").trim() === text, + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; +} + +export function getElement( + container: Element, + selector: string, + constructor: new () => T, +): T { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; +} + +export function selectSegmented(control: HTMLElement) { + const group = control.closest("wa-radio-group"); + expect(group).not.toBeNull(); + if (!group) { + return; + } + group.value = control.getAttribute("value") ?? ""; + group.dispatchEvent(new Event("change", { bubbles: true })); +} + +export function findToggleByLabel(container: Element, label: string) { + return ( + Array.from(container.querySelectorAll("wa-switch.settings-toggle")).find((toggle) => + toggle.textContent?.includes(label), + ) ?? null + ); +} diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 22486a8ad64a..e7998169046c 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -1,60 +1,19 @@ -// Control UI tests cover the Automations (cron) view behavior. +// Control UI tests cover the Automations (cron) list pane and select controls. import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_CRON_FORM } from "../../test-helpers/cron.ts"; import { createCronViewJob as createJob, + getElement, renderCronView as renderView, + selectSegmented, } from "./view.test-support.ts"; -function getButtonByText(container: Element, text: string): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.replace(/\s+/g, " ").trim() === text, - ); - expect(button).toBeInstanceOf(HTMLButtonElement); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Expected button with text "${text}"`); - } - return button; -} - -function getElement( - container: Element, - selector: string, - constructor: new () => T, -): T { - const element = container.querySelector(selector); - expect(element).toBeInstanceOf(constructor); - if (!(element instanceof constructor)) { - throw new Error(`Expected ${selector} to match ${constructor.name}`); - } - return element; -} - -function selectSegmented(control: HTMLElement) { - const group = control.closest("wa-radio-group"); - expect(group).not.toBeNull(); - if (!group) { - return; - } - group.value = control.getAttribute("value") ?? ""; - group.dispatchEvent(new Event("change", { bubbles: true })); -} - -function findToggleByLabel(container: Element, label: string) { - return ( - Array.from(container.querySelectorAll("wa-switch.settings-toggle")).find((toggle) => - toggle.textContent?.includes(label), - ) ?? null - ); -} - describe("cron view list pane", () => { it("uses agent-scoped summary values", () => { const container = renderView({ agentScoped: true, scopedTotal: 3, scopedNextWakeAtMs: Date.now() + 60_000, - status: { enabled: true, jobs: 99, nextWakeAtMs: null }, + status: { enabled: true, triggersEnabled: true, jobs: 99, nextWakeAtMs: null }, }); const values = [...container.querySelectorAll(".cron-stat__value")].map((entry) => entry.textContent?.trim(), @@ -68,7 +27,7 @@ describe("cron view list pane", () => { const container = renderView({ agentScoped: true, scopedNextWakeAtMs: Date.now() + 60_000, - status: { enabled: false, jobs: 3, nextWakeAtMs: null }, + status: { enabled: false, triggersEnabled: true, jobs: 3, nextWakeAtMs: null }, }); const values = [...container.querySelectorAll(".cron-stat__value")].map((entry) => entry.textContent?.trim(), @@ -122,9 +81,21 @@ describe("cron view list pane", () => { '[data-test-id="cron-jobs-schedule-filter"]', HTMLSelectElement, ); - scheduleFilter.value = "cron"; - scheduleFilter.dispatchEvent(new Event("change", { bubbles: true })); - expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsScheduleKindFilter: "cron" }); + expect(Array.from(scheduleFilter.options, (option) => option.value)).toEqual([ + "all", + "at", + "every", + "cron", + "on-exit", + "stream", + ]); + for (const scheduleKind of ["on-exit", "stream"] as const) { + scheduleFilter.value = scheduleKind; + scheduleFilter.dispatchEvent(new Event("change", { bubbles: true })); + expect(onJobsFiltersChange).toHaveBeenCalledWith({ + cronJobsScheduleKindFilter: scheduleKind, + }); + } const lastStatusFilter = getElement( container, @@ -291,7 +262,7 @@ describe("cron view list pane", () => { it("shows a scheduler banner only while the scheduler is off", () => { const off = renderView({ - status: { enabled: false, jobs: 2 }, + status: { enabled: false, triggersEnabled: true, jobs: 2 }, jobs: [createJob("job-1")], jobsTotal: 2, }); @@ -301,7 +272,7 @@ describe("cron view list pane", () => { const footer = getElement(off, ".cron-table__footer", HTMLDivElement); expect(footer.textContent).toContain("1 of 2"); - const on = renderView({ status: { enabled: true, jobs: 2 } }); + const on = renderView({ status: { enabled: true, triggersEnabled: true, jobs: 2 } }); expect(on.querySelector('[data-test-id="cron-scheduler-banner"]')).toBeNull(); }); @@ -355,708 +326,6 @@ describe("cron view list pane", () => { }); }); -describe("cron view editor", () => { - it("renders the create view with prompt, general, and schedule cards", () => { - const onSubmit = vi.fn(); - const onClosePanel = vi.fn(); - const container = renderView({ createOpen: true, onSubmit, onClosePanel }); - - expect(container.querySelector(".cron-page--detail")?.textContent).toContain("New automation"); - expect(container.querySelector("#cron-payload-text")).toBeInstanceOf(HTMLTextAreaElement); - expect(container.querySelector("#cron-name")).toBeInstanceOf(HTMLInputElement); - expect(container.querySelector('[data-test-id="cron-schedule-kind-every"]')).toBeInstanceOf( - HTMLElement, - ); - // Create mode has no run-history tab and no enabled switch. - expect(container.querySelector('[data-test-id="cron-detail-tab-history"]')).toBeNull(); - expect(container.querySelector('[data-test-id="cron-toggle-enabled"]')).toBeNull(); - - // Generated controls take their accessible name from the row title label. - const nameLabel = container.querySelector('label[for="cron-name"]'); - expect(nameLabel?.textContent).toContain("Name"); - expect(nameLabel?.textContent).toContain("required"); - expect(container.querySelector("#cron-name")?.getAttribute("aria-required")).toBe("true"); - const promptLabel = container.querySelector('label[for="cron-payload-text"]'); - expect(promptLabel?.textContent).toContain("required"); - // The payload-kind help renders as the prompt row's description. - expect(promptLabel?.closest(".settings-row")?.textContent).toContain( - "Starts an agent run in its own session using your prompt.", - ); - - getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement).click(); - expect(onSubmit).toHaveBeenCalledTimes(1); - - getElement(container, '[data-test-id="cron-back"]', HTMLButtonElement).click(); - expect(onClosePanel).toHaveBeenCalledTimes(1); - }); - - it("wires shared text and select controls without changing their field ownership", () => { - const onFormChange = vi.fn(); - const container = renderView({ - createOpen: true, - channels: ["telegram"], - channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }], - channelLabels: { telegram: "Telegram fallback" }, - form: { - ...DEFAULT_CRON_FORM, - scheduleKind: "cron", - deliveryChannel: "telegram", - failureAlertMode: "custom", - failureAlertChannel: "retired-channel", - }, - onFormChange, - }); - - const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement); - prompt.value = "do the thing"; - prompt.dispatchEvent(new Event("input", { bubbles: true })); - expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" }); - - for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) { - const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; - const input = getElement(container, `#${id}`, HTMLInputElement); - if (field === "sessionKey" || field === "deliveryAccountId") { - expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default"); - } - input.value = field; - input.dispatchEvent(new Event("input", { bubbles: true })); - expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field }); - } - - const channel = getElement( - container, - "#cron-failure-alert-channel", - HTMLElement, - ) as HTMLElement & { - value: string; - }; - const optionValues = Array.from(channel.querySelectorAll("wa-option"), (option) => - option.getAttribute("value"), - ); - expect(optionValues).toContain("retired-channel"); - expect(channel.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); - const telegramOption = channel.querySelector( - 'wa-option[value="telegram"]', - ); - expect(telegramOption?.label).toBe("Telegram fallback"); - Object.defineProperty(channel, "value", { configurable: true, value: "telegram" }); - channel.dispatchEvent(new Event("change", { bubbles: true })); - Reflect.deleteProperty(channel, "value"); - expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" }); - }); - - it("switches schedule inputs by segmented kind and wires kind changes", () => { - const onFormChange = vi.fn(); - const everyContainer = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every" }, - onFormChange, - }); - expect(everyContainer.querySelector("#cron-every-amount")).not.toBeNull(); - expect(everyContainer.querySelector("#cron-cron-expr")).toBeNull(); - const activeEvery = getElement( - everyContainer, - '[data-test-id="cron-schedule-kind-every"]', - HTMLElement, - ) as HTMLElement & { checked: boolean }; - expect(activeEvery.checked).toBe(true); - selectSegmented( - getElement(everyContainer, '[data-test-id="cron-schedule-kind-cron"]', HTMLElement), - ); - expect(onFormChange).toHaveBeenCalledWith({ - scheduleKind: "cron", - deleteAfterRun: false, - }); - - selectSegmented( - getElement(everyContainer, '[data-test-id="cron-schedule-kind-at"]', HTMLElement), - ); - expect(onFormChange).toHaveBeenCalledWith({ - scheduleKind: "at", - deleteAfterRun: true, - }); - - const atContainer = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "at" }, - }); - expect(atContainer.querySelector("#cron-schedule-at")).not.toBeNull(); - - const cronContainer = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", deleteAfterRun: true }, - onFormChange, - }); - expect(cronContainer.querySelector("#cron-cron-expr")).not.toBeNull(); - expect(findToggleByLabel(cronContainer, "Delete after run")).toBeNull(); - selectSegmented( - getElement(cronContainer, '[data-test-id="cron-schedule-kind-every"]', HTMLElement), - ); - expect(onFormChange).toHaveBeenCalledWith({ - scheduleKind: "every", - deleteAfterRun: false, - }); - - // on-exit jobs keep a pill so they can convert to an editable schedule; - // the on-exit pill only exists while it is the current value. - const onExitContainer = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "on-exit" }, - }); - const onExitKind = onExitContainer.querySelector('[data-test-id="cron-schedule-kind-on-exit"]'); - expect(onExitKind).not.toBeNull(); - expect(findToggleByLabel(onExitContainer, "Delete after run")).not.toBeNull(); - expect(everyContainer.querySelector('[data-test-id="cron-schedule-kind-on-exit"]')).toBeNull(); - const onExitFormChange = vi.fn(); - const keptOnExitContainer = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "on-exit", deleteAfterRun: false }, - onFormChange: onExitFormChange, - }); - selectSegmented( - getElement(keptOnExitContainer, '[data-test-id="cron-schedule-kind-at"]', HTMLElement), - ); - expect(onExitFormChange).toHaveBeenCalledWith({ scheduleKind: "at" }); - }); - - it("shows a live schedule summary when inputs are valid", () => { - const plural = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "30" }, - }); - expect(plural.querySelector(".cron-schedule-summary")?.textContent).toContain( - "Runs every 30 minutes", - ); - - const singular = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "1", everyUnit: "hours" }, - }); - expect(singular.querySelector(".cron-schedule-summary")?.textContent).toContain( - "Runs every hour", - ); - - const invalid = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "" }, - }); - expect(invalid.querySelector(".cron-schedule-summary")).toBeNull(); - - // One-shot summaries render the parsed date/time, not a duration. - const once = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "at", scheduleAt: "2026-07-14T09:00" }, - }); - const onceText = once.querySelector(".cron-schedule-summary")?.textContent ?? ""; - expect(onceText).toContain("Runs once at"); - expect(onceText).toContain("2026"); - }); - - it("offers a Seconds interval unit so sub-minute cadences stay editable", () => { - const container = renderView({ - createOpen: true, - form: { - ...DEFAULT_CRON_FORM, - scheduleKind: "every", - everyAmount: "30", - everyUnit: "seconds", - }, - }); - const unitSelect = Array.from(container.querySelectorAll("wa-select")).find( - (select) => select.querySelector('[slot="label"]')?.textContent === "Unit", - ); - expect(unitSelect).toBeInstanceOf(HTMLElement); - if (!unitSelect) { - throw new Error("Expected the interval unit picker"); - } - const values = Array.from(unitSelect.querySelectorAll("wa-option"), (option) => - option.getAttribute("value"), - ); - expect(values).toEqual(["seconds", "minutes", "hours", "days"]); - }); - - it("summarizes seconds intervals, including singular and decimal amounts", () => { - const singular = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount: "1", everyUnit: "seconds" }, - }); - expect(singular.querySelector(".cron-schedule-summary")?.textContent).toContain( - "Runs every second", - ); - - const plural = renderView({ - createOpen: true, - form: { - ...DEFAULT_CRON_FORM, - scheduleKind: "every", - everyAmount: "30", - everyUnit: "seconds", - }, - }); - expect(plural.querySelector(".cron-schedule-summary")?.textContent).toContain( - "Runs every 30 seconds", - ); - - const decimal = renderView({ - createOpen: true, - form: { - ...DEFAULT_CRON_FORM, - scheduleKind: "every", - everyAmount: "0.45", - everyUnit: "seconds", - }, - }); - expect(decimal.querySelector(".cron-schedule-summary")?.textContent).toContain( - "Runs every 0.45 seconds", - ); - }); - - it("hides the schedule summary for recurring amounts that cannot produce safe milliseconds", () => { - for (const everyAmount of ["0x10", "1e3", "+1", String(Number.MAX_SAFE_INTEGER), "0.000001"]) { - const container = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "every", everyAmount }, - }); - expect(container.querySelector(".cron-schedule-summary")).toBeNull(); - } - }); - - it("renders supported delivery options and normalizes stale announce selection", () => { - // systemEvent + main session cannot announce; a stale announce selection - // must render as none and the announce option must disappear. - const container = renderView({ - createOpen: true, - form: { - ...DEFAULT_CRON_FORM, - sessionTarget: "main", - payloadKind: "systemEvent", - deliveryMode: "announce", - }, - }); - const delivery = getElement(container, "#cron-delivery-mode", HTMLElement); - const values = Array.from(delivery.querySelectorAll("wa-option"), (option) => - option.getAttribute("value"), - ); - expect(values).toEqual(["webhook", "none"]); - expect(container.querySelector("#cron-delivery-channel")).toBeNull(); - }); - - it("shows announce channel/to rows and webhook URL row per delivery mode", () => { - const announce = renderView({ - createOpen: true, - channels: ["telegram"], - form: { ...DEFAULT_CRON_FORM, deliveryMode: "announce" }, - }); - expect(announce.querySelector("#cron-delivery-channel")).not.toBeNull(); - expect(announce.querySelector("#cron-delivery-to")).not.toBeNull(); - - const webhook = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, deliveryMode: "webhook" }, - fieldErrors: { deliveryTo: "cron.errors.webhookUrlRequired" }, - canSubmit: false, - }); - const urlInput = getElement(webhook, "#cron-delivery-to", HTMLInputElement); - expect(urlInput.getAttribute("aria-invalid")).toBe("true"); - expect(urlInput.getAttribute("aria-describedby")).toBe("cron-error-deliveryTo"); - expect(webhook.querySelector("#cron-error-deliveryTo")?.textContent).toContain( - "Webhook URL is required.", - ); - }); - - it("shows model and reasoning rows only for agent-turn payloads", () => { - const agentTurn = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, payloadKind: "agentTurn" }, - }); - expect(agentTurn.querySelector("#cron-payload-model")).not.toBeNull(); - expect(agentTurn.querySelector("#cron-payload-thinking")).not.toBeNull(); - - const systemEvent = renderView({ - createOpen: true, - form: { ...DEFAULT_CRON_FORM, payloadKind: "systemEvent", sessionTarget: "main" }, - }); - expect(systemEvent.querySelector("#cron-payload-model")).toBeNull(); - - const conditional = renderView({ - createOpen: true, - form: { - ...DEFAULT_CRON_FORM, - triggerEnabled: true, - triggerScript: "json({ fire: true })", - }, - }); - expect(conditional.querySelector("#cron-trigger-script")).toBeInstanceOf(HTMLTextAreaElement); - expect(conditional.querySelector(".cron-trigger-summary")?.textContent).toContain( - "Trigger configured", - ); - }); - - it("hides trigger authoring when the operator disabled triggers but keeps clear available", () => { - const onFormChange = vi.fn(); - const disabled = renderView({ createOpen: true, triggersEnabled: false, onFormChange }); - expect(disabled.querySelector("#cron-trigger-script")).toBeNull(); - expect(disabled.textContent).toContain("disabled by cron.triggers.enabled"); - - const configured = renderView({ - createOpen: true, - triggersEnabled: false, - onFormChange, - form: { - ...DEFAULT_CRON_FORM, - triggerEnabled: true, - triggerScript: "json({ fire: true })", - }, - }); - getButtonByText(configured, "Clear trigger").click(); - expect(onFormChange).toHaveBeenCalledWith({ triggerEnabled: false }); - }); - - it("renders script payloads as highlighted read-only code without exposing script authoring", () => { - const script = "const result = await agent('check status')"; - const job = createJob("job-script", { - name: "Status script", - payload: { kind: "script", script }, - }); - const container = renderView({ - jobs: [job], - editingJob: job, - form: { - ...DEFAULT_CRON_FORM, - name: job.name, - payloadKind: "script", - payloadLocked: true, - payloadText: script, - }, - }); - - const payload = getElement(container, "#cron-payload-text", HTMLPreElement); - expect(payload.textContent).toBe(script); - expect(payload.querySelector(".hljs-keyword")?.textContent).toBe("const"); - expect(payload.querySelector(".hljs-string")?.textContent).toBe("'check status'"); - expect(container.querySelector("textarea#cron-payload-text")).toBeNull(); - expect(container.querySelector("#cron-payload-kind")?.getAttribute("value")).toBeNull(); - expect((container.querySelector("#cron-payload-kind") as HTMLInputElement).value).toBe( - "Script", - ); - expect(container.textContent).toContain("contents stay read-only"); - expect(container.querySelector('option[value="script"]')).toBeNull(); - expect(findToggleByLabel(container, "Condition trigger")).toBeNull(); - expect(container.textContent).toContain("Script payloads cannot use condition triggers"); - }); - - it("keeps an incompatible existing script condition trigger visible and explicitly clearable", () => { - const onFormChange = vi.fn(); - const job = createJob("job-script-trigger", { - payload: { kind: "script", script: "json({ state: {} })" }, - trigger: { script: "json({ fire: true })" }, - }); - const container = renderView({ - jobs: [job], - editingJob: job, - onFormChange, - form: { - ...DEFAULT_CRON_FORM, - name: job.name, - payloadKind: "script", - payloadLocked: true, - payloadText: "json({ state: {} })", - triggerEnabled: true, - triggerScript: "json({ fire: true })", - }, - fieldErrors: { triggerScript: "cron.errors.triggerScriptPayloadUnsupported" }, - canSubmit: false, - }); - - expect(findToggleByLabel(container, "Condition trigger")).toBeNull(); - expect(container.querySelector("#cron-trigger-script")).toBeNull(); - expect(container.textContent).toContain("Script payloads cannot use condition triggers"); - getButtonByText(container, "Clear trigger").click(); - expect(onFormChange).toHaveBeenCalledWith({ triggerEnabled: false }); - }); - - it("attaches the triggered minimum-interval error to the visible recurring interval", () => { - const container = renderView({ - createOpen: true, - canSubmit: false, - form: { - ...DEFAULT_CRON_FORM, - everyAmount: "5", - everyUnit: "seconds", - triggerEnabled: true, - triggerScript: "json({ fire: true })", - }, - fieldErrors: { everyAmount: "cron.errors.triggerIntervalTooShort" }, - }); - - const interval = getElement(container, "#cron-every-amount", HTMLInputElement); - expect(interval.getAttribute("aria-invalid")).toBe("true"); - expect(interval.getAttribute("aria-describedby")).toBe("cron-error-everyAmount"); - expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain( - "at least every 30 seconds", - ); - }); - - it("highlights locked command payloads as shell and keeps heartbeat payloads plain", () => { - const job = createJob("job-command", { - name: "Backup", - payload: { kind: "script", script: "" }, - }); - const command = renderView({ - jobs: [job], - editingJob: job, - form: { - ...DEFAULT_CRON_FORM, - name: job.name, - payloadKind: "command", - payloadLocked: true, - payloadText: "echo $HOME", - }, - }); - const payload = getElement(command, "#cron-payload-text", HTMLPreElement); - expect(payload.textContent).toBe("echo $HOME"); - expect(payload.querySelector(".hljs-built_in")?.textContent).toBe("echo"); - expect(findToggleByLabel(command, "Condition trigger")).not.toBeNull(); - - const heartbeat = renderView({ - jobs: [job], - editingJob: job, - form: { - ...DEFAULT_CRON_FORM, - name: job.name, - payloadKind: "heartbeat", - payloadLocked: true, - payloadText: "", - }, - }); - expect(heartbeat.querySelector("#cron-payload-text")).toBeInstanceOf(HTMLTextAreaElement); - }); - - it("disables submit and lists blocking fields when validation fails", () => { - const container = renderView({ - createOpen: true, - canSubmit: false, - form: { ...DEFAULT_CRON_FORM, name: "" }, - fieldErrors: { name: "cron.errors.nameRequired" }, - }); - const submit = getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement); - expect(submit.disabled).toBe(true); - const statusLinks = Array.from(container.querySelectorAll(".cron-form-status__link")); - expect(statusLinks.some((link) => link.textContent?.includes("Name"))).toBe(true); - expect(container.textContent).toContain("Fix 1 field to continue."); - }); - - it("renders job detail authority independently from the filtered table", () => { - const onRun = vi.fn(); - const onToggle = vi.fn(); - const onClone = vi.fn(); - const onRemove = vi.fn(); - const onDetailTabChange = vi.fn(); - const job = createJob("job-1", { name: "Nightly digest" }); - const container = renderView({ - jobs: [], - jobsTotal: 0, - editingJob: job, - onRun, - onToggle, - onClone, - onRemove, - onDetailTabChange, - }); - - expect(getElement(container, ".cron-detail-title", HTMLDivElement).textContent).toContain( - "Nightly digest", - ); - expect(getButtonByText(container, "Save changes")).toBeInstanceOf(HTMLButtonElement); - - getElement(container, '[data-test-id="cron-run-now"]', HTMLButtonElement).click(); - expect(onRun).toHaveBeenCalledWith(job, "force"); - - const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement); - const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & { - checked: boolean; - }; - expect(toggleInput.checked).toBe(true); - expect(toggle.textContent).toContain("Active"); - toggleInput.checked = false; - toggleInput.dispatchEvent(new Event("change", { bubbles: true })); - expect(onToggle).toHaveBeenCalledWith(job, false); - - const jobMenu = container.querySelector("wa-dropdown.cron-job-menu"); - const runIfDue = jobMenu?.querySelector('wa-dropdown-item[value="run-if-due"]'); - if (runIfDue) { - jobMenu?.dispatchEvent( - new CustomEvent("wa-select", { detail: { item: runIfDue }, bubbles: true }), - ); - } - expect(onRun).toHaveBeenCalledWith(job, "due"); - const clone = jobMenu?.querySelector('wa-dropdown-item[value="clone"]'); - if (clone) { - jobMenu?.dispatchEvent( - new CustomEvent("wa-select", { detail: { item: clone }, bubbles: true }), - ); - } - expect(onClone).toHaveBeenCalledWith(job); - const remove = jobMenu?.querySelector('wa-dropdown-item[value="remove"]'); - if (remove) { - jobMenu?.dispatchEvent( - new CustomEvent("wa-select", { detail: { item: remove }, bubbles: true }), - ); - } - expect(onRemove).toHaveBeenCalledWith(job); - - const settingsTab = container.querySelector('[data-test-id="cron-detail-tab-settings"]'); - expect(settingsTab?.getAttribute("aria-selected")).toBe("true"); - container - .querySelector('[data-test-id="cron-detail-tab-history"]') - ?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true })); - expect(onDetailTabChange).toHaveBeenCalledWith("history"); - }); - - it.each([true, false])("preserves task browsing for canManage=%s", (canManage) => { - const job = createJob("permission-job"); - const onSelectJob = vi.fn(); - const container = renderView({ canManage, jobs: [job], onSelectJob }); - - expect(Boolean(container.querySelector('[data-test-id="cron-new-task"]'))).toBe(canManage); - expect(Boolean(container.querySelector('[data-test-id="cron-row-run-permission-job"]'))).toBe( - canManage, - ); - expect(Boolean(container.querySelector("wa-dropdown.cron-job-menu"))).toBe(canManage); - getElement(container, '[data-test-id="cron-row-permission-job"]', HTMLDivElement).click(); - expect(onSelectJob).toHaveBeenCalledWith(job); - }); - - it("keeps read-only operators on browse surfaces without mutation controls", () => { - const onSelectJob = vi.fn(); - const job = createJob("job-1", { - name: "Nightly digest", - description: "Read-only operators can inspect this task", - }); - const list = renderView({ canManage: false, jobs: [job], jobsTotal: 1, onSelectJob }); - - expect(list.textContent).toContain("Browsing only"); - expect(list.querySelector('[data-test-id="cron-new-task"]')).toBeNull(); - expect(list.querySelector('[data-test-id="cron-row-run-job-1"]')).toBeNull(); - expect(list.querySelector('[data-test-id="cron-row-toggle-job-1"]')).toBeNull(); - expect(list.querySelector("wa-dropdown.cron-job-menu")).toBeNull(); - expect(list.querySelector("[data-suggestion]")).toBeNull(); - expect(list.querySelector(".cron-table__description")?.textContent).toContain(job.description); - - getElement(list, '[data-test-id="cron-row-job-1"]', HTMLDivElement).click(); - expect(onSelectJob).toHaveBeenCalledWith(job); - - const detail = renderView({ - canManage: false, - jobs: [], - editingJob: job, - }); - expect(detail.textContent).toContain("Browsing only"); - expect(detail.querySelector('[data-test-id="cron-run-now"]')).toBeNull(); - expect(detail.querySelector('[data-test-id="cron-toggle-enabled"]')).toBeNull(); - expect(detail.querySelector("wa-dropdown.cron-job-menu")).toBeNull(); - expect(detail.querySelector('[data-test-id="cron-submit"]')).toBeNull(); - expect(detail.querySelector(".cron-editor-actions")).toBeNull(); - expect(getElement(detail, ".cron-editor", HTMLFieldSetElement).disabled).toBe(true); - expect(detail.querySelector('[data-test-id="cron-detail-tab-history"]')).not.toBeNull(); - expect(detail.querySelector('[data-test-id="cron-detail-description"]')?.textContent).toContain( - job.description, - ); - }); - - it("locks the editor and back navigation while a save is pending", () => { - const job = createJob("job-1", { name: "Nightly digest" }); - const container = renderView({ jobs: [job], editingJob: job, busy: true }); - - const editor = getElement(container, ".cron-editor", HTMLFieldSetElement); - const name = getElement(container, "#cron-name", HTMLInputElement); - const back = getElement(container, '[data-test-id="cron-back"]', HTMLButtonElement); - const submit = getElement(container, '[data-test-id="cron-submit"]', HTMLButtonElement); - - expect(editor.disabled).toBe(true); - expect(editor.getAttribute("aria-busy")).toBe("true"); - expect(name.matches(":disabled")).toBe(true); - expect(back.disabled).toBe(true); - expect(submit.disabled).toBe(true); - expect(submit.textContent).toContain("Saving"); - }); - - it("shows run history instead of the editor on the history tab", () => { - const job = createJob("job-1", { - name: "Nightly digest", - description: "Saved description stays visible in history", - }); - const container = renderView({ - jobs: [job], - editingJob: job, - detailTab: "history", - runs: [ - { - ts: 5, - jobId: "job-1", - action: "finished", - jobName: "Nightly digest", - status: "ok", - summary: "ran", - }, - ], - }); - expect(container.querySelector(".cron-run-entry")).not.toBeNull(); - expect(container.querySelector(".cron-editor")).toBeNull(); - const description = container.querySelector('[data-test-id="cron-detail-description"]'); - expect(description?.textContent).toContain(job.description); - }); - - it("shows the paused switch state for disabled jobs", () => { - const onToggle = vi.fn(); - const job = createJob("job-1", { enabled: false }); - const container = renderView({ - jobs: [], - editingJob: job, - onToggle, - }); - const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement); - const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & { - checked: boolean; - }; - expect(toggleInput.checked).toBe(false); - expect(toggle.textContent).toContain("Paused"); - toggleInput.checked = true; - toggleInput.dispatchEvent(new Event("change", { bubbles: true })); - expect(onToggle).toHaveBeenCalledWith(job, true); - }); - - it("renders model-picker suggestions with the remaining text datalists", () => { - const container = renderView({ - createOpen: true, - agentSuggestions: ["main"], - modelSuggestions: ["openai/gpt-5.2"], - thinkingSuggestions: ["low"], - timezoneSuggestions: ["UTC"], - deliveryToSuggestions: ["+15551234"], - accountSuggestions: ["default"], - }); - for (const id of [ - "cron-agent-suggestions", - "cron-thinking-suggestions", - "cron-tz-suggestions", - "cron-delivery-to-suggestions", - "cron-delivery-account-suggestions", - ]) { - expect(container.querySelector(`datalist#${id}`)).not.toBeNull(); - } - const model = getElement(container, "#cron-payload-model-picker", HTMLElement); - expect(model.querySelector('wa-option[value="openai/gpt-5.2"]')).not.toBeNull(); - expect(model.querySelector('[data-provider-icon="codex"]')).not.toBeNull(); - expect(container.querySelector("#cron-payload-model")?.hidden).toBe(true); - // The inherit option must resolve to a real catalog string — a missing key - // renders the raw "common.default" literal to every locale. - const inheritText = model.querySelector('wa-option[value=""]')?.textContent ?? ""; - expect(inheritText).toContain("Default"); - expect(inheritText).not.toContain("common.default"); - }); -}); - describe("cron view selects", () => { it("shows authoritative form values instead of first options in the create form", () => { const container = renderView({ createOpen: true }); diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index 4a6744deb8c1..972b76f661ac 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -17,6 +17,7 @@ import type { CronStatus, CronDeliveryStatus, CronJobsEnabledFilter, + CronJobsScheduleKindFilter, CronJobsTriggerFilter, CronRunsStatusValue, CronJobsSortBy, @@ -51,7 +52,6 @@ import type { CronFieldKey, CronFormState, CronJobsLastStatusFilter, - CronJobsScheduleKindFilter, } from "../../lib/cron/index.ts"; import { formatUiExternalText } from "../../lib/format-error.ts"; import { formatRelativeTimestamp, formatMs } from "../../lib/format.ts"; @@ -72,7 +72,6 @@ type CronProps = { loading: boolean; /** Canonical gateway capability for every mutation-capable cron control. */ canManage: boolean; - triggersEnabled: boolean; jobsLoadingMore: boolean; status: CronStatus | null; failingCount: number | null; @@ -448,6 +447,15 @@ const ENABLED_TABS: Array<{ value: CronJobsEnabledFilter; labelKey: string }> = { value: "disabled", labelKey: "cron.tabs.paused" }, ]; +const SCHEDULE_KIND_FILTER_LABELS: Record = { + all: "cron.jobs.all", + at: "cron.form.at", + every: "cron.form.every", + cron: "cron.form.cronOption", + "on-exit": "cron.form.repeatOnExit", + stream: "cron.form.repeatStream", +}; + function renderListView(props: CronProps) { const hasAdvancedJobsFilters = props.jobsScheduleKindFilter !== "all" || @@ -646,12 +654,10 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) { label: t("cron.jobs.schedule"), value: props.jobsScheduleKindFilter, testId: "cron-jobs-schedule-filter", - options: [ - { value: "all", label: t("cron.jobs.all") }, - { value: "at", label: t("cron.form.at") }, - { value: "every", label: t("cron.form.every") }, - { value: "cron", label: t("cron.form.cronOption") }, - ], + options: Object.entries(SCHEDULE_KIND_FILTER_LABELS).map(([value, labelKey]) => ({ + value, + label: t(labelKey), + })), })} ${renderJobsFilter(props, "cronJobsLastStatusFilter", { label: t("cron.jobs.lastRun"), @@ -1729,7 +1735,10 @@ function renderAdvanced( function renderTriggerRows(props: CronProps) { const scriptPayload = props.form.payloadKind === "script"; - if (!props.triggersEnabled || scriptPayload) { + if (!scriptPayload && props.status === null) { + return nothing; + } + if (props.status?.triggersEnabled !== true || scriptPayload) { return renderSettingsRow({ title: t("cron.form.conditionTrigger"), description: scriptPayload