mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 16:12:21 -06:00
fix: keep status read-only while gateway owns state (#124142)
This commit is contained in:
@@ -338,6 +338,18 @@ describe("resolveExecDefaults", () => {
|
||||
).toEqual({ canExec: false, node: "build-mac" });
|
||||
});
|
||||
|
||||
it("uses an explicitly loaded approval snapshot for read-only callers", () => {
|
||||
const load = vi.mocked(execApprovals.loadExecApprovals);
|
||||
|
||||
expect(
|
||||
resolveNodeExecEligibility({
|
||||
cfg: withDefaultAgent({ tools: { exec: { host: "node", mode: "full" } } }),
|
||||
execApprovals: { version: 1, defaults: { security: "deny" }, agents: {} },
|
||||
}),
|
||||
).toEqual({ canExec: false });
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks node skill eligibility when the gateway denies system.run", () => {
|
||||
expect(
|
||||
resolveNodeExecEligibility({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
loadExecApprovals,
|
||||
type ExecAsk,
|
||||
type ExecApprovalsFile,
|
||||
type ExecHost,
|
||||
type ExecMode,
|
||||
type ExecSecurity,
|
||||
@@ -112,6 +113,7 @@ function resolveExecConfigState(params: {
|
||||
/** Resolves whether node exec is usable and any effective node binding. */
|
||||
export function resolveNodeExecEligibility(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
execApprovals?: ExecApprovalsFile;
|
||||
sessionEntry?: ExecSessionDefaults;
|
||||
execOverrides?: ExecPolicyOverrides;
|
||||
agentId?: string;
|
||||
@@ -131,6 +133,7 @@ export function resolveNodeExecEligibility(params: {
|
||||
/** Resolves effective exec host, mode, approval policy, and node availability. */
|
||||
export function resolveExecDefaults(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
execApprovals?: ExecApprovalsFile;
|
||||
sessionEntry?: ExecSessionDefaults;
|
||||
execOverrides?: ExecPolicyOverrides;
|
||||
agentId?: string;
|
||||
@@ -173,7 +176,7 @@ export function resolveExecDefaults(params: {
|
||||
resolved.effectiveHost === "sandbox"
|
||||
? undefined
|
||||
: resolveExecApprovalsFromFile({
|
||||
file: loadExecApprovals(),
|
||||
file: params.execApprovals ?? loadExecApprovals(),
|
||||
agentId: resolvedAgentId,
|
||||
overrides: {
|
||||
security: defaultSecurity,
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function renderChannelsStatusFallback(params: {
|
||||
runtime.error(
|
||||
`${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`,
|
||||
);
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
const cfg = await requireValidConfig(runtime, { observe: false });
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export async function renderChannelsStatusFallback(params: {
|
||||
mode: "read_only_status",
|
||||
runtime,
|
||||
});
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const snapshot = await readConfigFileSnapshot({ observe: false });
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
const requestedChannel = opts.channel
|
||||
? (normalizeChannelId(opts.channel) ?? normalizeOptionalLowercaseString(opts.channel))
|
||||
|
||||
@@ -82,6 +82,17 @@ describe("requireValidConfig", () => {
|
||||
expect(readConfigFileSnapshot).toHaveBeenCalledWith({ skipPluginValidation: true });
|
||||
});
|
||||
|
||||
it("can validate config without observing persistent health state", async () => {
|
||||
createValidSnapshot();
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(requireValidConfig(runtime, { observe: false })).resolves.toEqual({
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
|
||||
});
|
||||
|
||||
it("emits a non-blocking compatibility advisory when explicitly requested", async () => {
|
||||
createValidSnapshot();
|
||||
const runtime = createRuntime();
|
||||
|
||||
@@ -17,10 +17,18 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
/** Read the config file and exit through the runtime when validation fails. */
|
||||
export async function requireValidConfigFileSnapshot(
|
||||
runtime: RuntimeEnv,
|
||||
opts?: { includeCompatibilityAdvisory?: boolean; skipPluginValidation?: boolean },
|
||||
opts?: {
|
||||
includeCompatibilityAdvisory?: boolean;
|
||||
observe?: boolean;
|
||||
skipPluginValidation?: boolean;
|
||||
},
|
||||
): Promise<ConfigFileSnapshot | null> {
|
||||
const readOptions = {
|
||||
...(opts?.observe === false ? { observe: false } : {}),
|
||||
...(opts?.skipPluginValidation ? { skipPluginValidation: true } : {}),
|
||||
};
|
||||
const snapshot = await readConfigFileSnapshot(
|
||||
opts?.skipPluginValidation ? { skipPluginValidation: true } : undefined,
|
||||
Object.keys(readOptions).length > 0 ? readOptions : undefined,
|
||||
);
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
const issues =
|
||||
@@ -59,7 +67,11 @@ export async function requireValidConfigFileSnapshot(
|
||||
/** Read and return a valid OpenClaw config, or null after reporting validation errors. */
|
||||
export async function requireValidConfig(
|
||||
runtime: RuntimeEnv,
|
||||
opts?: { includeCompatibilityAdvisory?: boolean; skipPluginValidation?: boolean },
|
||||
opts?: {
|
||||
includeCompatibilityAdvisory?: boolean;
|
||||
observe?: boolean;
|
||||
skipPluginValidation?: boolean;
|
||||
},
|
||||
): Promise<OpenClawConfig | null> {
|
||||
return (await requireValidConfigFileSnapshot(runtime, opts))?.config ?? null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveStatusGatewayDiagnosticsSafe: vi.fn(async () => null),
|
||||
resolveStatusGatewayHealthSafe: vi.fn(async () => undefined),
|
||||
resolveNodeExecEligibility: vi.fn(() => ({ canExec: false })),
|
||||
loadExecApprovalsReadOnly: vi.fn(() => ({ version: 1, agents: {} })),
|
||||
buildWorkspaceSkillStatus: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
@@ -27,7 +28,12 @@ vi.mock("../../gateway/net.js", () => ({
|
||||
bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost],
|
||||
}));
|
||||
vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: mocks.inspectPortUsage }));
|
||||
vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null }));
|
||||
vi.mock("../../infra/exec-approvals.js", () => ({
|
||||
loadExecApprovalsReadOnly: mocks.loadExecApprovalsReadOnly,
|
||||
}));
|
||||
vi.mock("../../infra/restart-sentinel.js", () => ({
|
||||
readRestartSentinelReadOnly: async () => null,
|
||||
}));
|
||||
vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] }));
|
||||
vi.mock("../../skills/discovery/status.js", () => ({
|
||||
buildWorkspaceSkillStatus: mocks.buildWorkspaceSkillStatus,
|
||||
@@ -172,6 +178,7 @@ describe("buildStatusAllReportData", () => {
|
||||
|
||||
expect(mocks.resolveNodeExecEligibility).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
execApprovals: { version: 1, agents: {} },
|
||||
agentId: "beta",
|
||||
});
|
||||
expect(mocks.buildWorkspaceSkillStatus).toHaveBeenCalledWith("/tmp/beta", expect.any(Object));
|
||||
|
||||
@@ -5,8 +5,9 @@ import { resolveNodeExecEligibility } from "../../agents/exec-defaults.js";
|
||||
import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js";
|
||||
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
|
||||
import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../../gateway/net.js";
|
||||
import { loadExecApprovalsReadOnly } from "../../infra/exec-approvals.js";
|
||||
import { inspectPortUsage } from "../../infra/ports-inspect.js";
|
||||
import { readRestartSentinel } from "../../infra/restart-sentinel.js";
|
||||
import { readRestartSentinelReadOnly } from "../../infra/restart-sentinel.js";
|
||||
import { resolvePluginControlPlaneWorkspace } from "../../plugins/control-plane-workspace.js";
|
||||
import { buildPluginCompatibilityNotices } from "../../plugins/status.js";
|
||||
import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js";
|
||||
@@ -58,7 +59,7 @@ async function resolveStatusAllLocalDiagnosis(params: {
|
||||
snap: ConfigFileSnapshot | null;
|
||||
remoteUrlMissing: boolean;
|
||||
secretDiagnostics: StatusScanOverviewResult["secretDiagnostics"];
|
||||
sentinel: Awaited<ReturnType<typeof readRestartSentinel>> | null;
|
||||
sentinel: Awaited<ReturnType<typeof readRestartSentinelReadOnly>> | null;
|
||||
lastErr: string | null;
|
||||
port: number;
|
||||
portUsage: Awaited<ReturnType<typeof inspectPortUsage>> | null;
|
||||
@@ -111,7 +112,7 @@ async function resolveStatusAllLocalDiagnosis(params: {
|
||||
|
||||
params.progress.setLabel("Checking local state…");
|
||||
// These probes are intentionally best-effort so status-all can still print a partial report.
|
||||
const sentinel = await readRestartSentinel().catch(() => null);
|
||||
const sentinel = await readRestartSentinelReadOnly().catch(() => null);
|
||||
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
|
||||
const port = resolveGatewayPort(overview.cfg);
|
||||
const bindHost = await resolveGatewayBindHost(
|
||||
@@ -135,6 +136,7 @@ async function resolveStatusAllLocalDiagnosis(params: {
|
||||
// Skill eligibility depends on whether the default agent may request node exec.
|
||||
const nodeSkills = resolveNodeExecEligibility({
|
||||
cfg: overview.cfg,
|
||||
execApprovals: loadExecApprovalsReadOnly(),
|
||||
agentId: controlPlaneWorkspace.agentId,
|
||||
});
|
||||
return buildWorkspaceSkillStatus(defaultWorkspace, {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js";
|
||||
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
||||
import { readRestartSentinelReadOnly } from "../infra/restart-sentinel.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { assertStatusUsageAgentScope, runStatusJsonCommand } from "./status-json-command.ts";
|
||||
@@ -319,7 +319,7 @@ export async function statusCommand(
|
||||
nodeOnlyGateway,
|
||||
});
|
||||
const updateRestartValue = formatUpdateRestartStatusValue(
|
||||
(await readRestartSentinel().catch(() => null))?.payload,
|
||||
(await readRestartSentinelReadOnly().catch(() => null))?.payload,
|
||||
{
|
||||
ok,
|
||||
warn,
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadNodeHostConfig: vi.fn(),
|
||||
loadNodeHostConfigReadOnly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../node-host/config.js", () => ({
|
||||
loadNodeHostConfig: mocks.loadNodeHostConfig,
|
||||
loadNodeHostConfigReadOnly: mocks.loadNodeHostConfigReadOnly,
|
||||
}));
|
||||
|
||||
import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js";
|
||||
|
||||
describe("resolveNodeOnlyGatewayInfo", () => {
|
||||
beforeEach(() => {
|
||||
mocks.loadNodeHostConfig.mockReset();
|
||||
mocks.loadNodeHostConfigReadOnly.mockReset();
|
||||
});
|
||||
|
||||
it("returns node-only gateway details when no local gateway is installed", async () => {
|
||||
mocks.loadNodeHostConfig.mockResolvedValueOnce({
|
||||
mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
nodeId: "node-1",
|
||||
gateway: { host: "gateway.example.com", port: 19000 },
|
||||
@@ -46,7 +46,7 @@ describe("resolveNodeOnlyGatewayInfo", () => {
|
||||
});
|
||||
|
||||
it("does not claim node-only mode when the node service is installed but inactive", async () => {
|
||||
mocks.loadNodeHostConfig.mockResolvedValueOnce({
|
||||
mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
nodeId: "node-1",
|
||||
gateway: { host: "gateway.example.com", port: 19000 },
|
||||
@@ -67,7 +67,7 @@ describe("resolveNodeOnlyGatewayInfo", () => {
|
||||
});
|
||||
|
||||
it("falls back to an unknown gateway target when node-only config is missing", async () => {
|
||||
mocks.loadNodeHostConfig.mockResolvedValueOnce(null);
|
||||
mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
resolveNodeOnlyGatewayInfo({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// On these machines the local gateway daemon is absent by design, but the node service may point at a remote gateway.
|
||||
|
||||
import { DEFAULT_GATEWAY_PORT } from "../config/paths.js";
|
||||
import { loadNodeHostConfig } from "../node-host/config.js";
|
||||
import { loadNodeHostConfigReadOnly } from "../node-host/config.js";
|
||||
|
||||
type NodeOnlyServiceLike = {
|
||||
installed: boolean | null;
|
||||
@@ -66,7 +66,7 @@ export async function resolveNodeOnlyGatewayInfo(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gatewayTarget = resolveNodeGatewayTarget((await loadNodeHostConfig())?.gateway);
|
||||
const gatewayTarget = resolveNodeGatewayTarget((await loadNodeHostConfigReadOnly())?.gateway);
|
||||
return {
|
||||
gatewayTarget,
|
||||
gatewayValue: `node → ${gatewayTarget} · no local gateway`,
|
||||
|
||||
@@ -17,7 +17,6 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
||||
entry: Record<string, unknown>;
|
||||
}>
|
||||
>(() => []),
|
||||
configureTaskRegistryMaintenance: vi.fn(),
|
||||
taskRegistrySummary: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
@@ -40,7 +39,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
||||
},
|
||||
} as TaskRegistrySummary,
|
||||
inspectableTasks: [] as TaskRecord[],
|
||||
reconcileInspectableTasks: vi.fn(() => statusSummaryMocks.inspectableTasks),
|
||||
listInspectableTasksReadOnly: vi.fn(() => statusSummaryMocks.inspectableTasks),
|
||||
getInspectableTaskRegistrySummary: vi.fn(
|
||||
(_tasks?: TaskRecord[]) => statusSummaryMocks.taskRegistrySummary,
|
||||
),
|
||||
@@ -164,8 +163,7 @@ vi.mock("../infra/system-events.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
||||
configureTaskRegistryMaintenance: statusSummaryMocks.configureTaskRegistryMaintenance,
|
||||
reconcileInspectableTasks: statusSummaryMocks.reconcileInspectableTasks,
|
||||
listInspectableTasksReadOnly: statusSummaryMocks.listInspectableTasksReadOnly,
|
||||
getInspectableTaskRegistrySummary: statusSummaryMocks.getInspectableTaskRegistrySummary,
|
||||
getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings,
|
||||
}));
|
||||
@@ -490,7 +488,7 @@ describe("getStatusSummary", () => {
|
||||
|
||||
await getStatusSummary();
|
||||
|
||||
expect(statusSummaryMocks.reconcileInspectableTasks).toHaveBeenCalledTimes(1);
|
||||
expect(statusSummaryMocks.listInspectableTasksReadOnly).toHaveBeenCalledTimes(1);
|
||||
expect(statusSummaryMocks.getInspectableTaskRegistrySummary).toHaveBeenCalledWith(
|
||||
inspectableTasks,
|
||||
);
|
||||
|
||||
@@ -753,6 +753,7 @@ vi.mock("../daemon/node-service.js", () => ({
|
||||
}));
|
||||
vi.mock("../node-host/config.js", () => ({
|
||||
loadNodeHostConfig: mocks.loadNodeHostConfig,
|
||||
loadNodeHostConfigReadOnly: mocks.loadNodeHostConfig,
|
||||
}));
|
||||
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
||||
getInspectableTaskRegistrySummary: mocks.getInspectableTaskRegistrySummary,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-c
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
@@ -255,6 +256,29 @@ export async function readRestartSentinel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the restart sentinel without creating or mutating shared state. */
|
||||
export async function readRestartSentinelReadOnly(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<RestartSentinel | null> {
|
||||
try {
|
||||
const current = withExistingOpenClawStateDatabaseReadOnly(
|
||||
({ db }) => readRestartSentinelRowSync(db),
|
||||
{ env },
|
||||
);
|
||||
if (!current || current.kind === "missing") {
|
||||
return null;
|
||||
}
|
||||
if (current.kind === "invalid") {
|
||||
sentinelLog.warn("Ignoring invalid typed restart sentinel row");
|
||||
return null;
|
||||
}
|
||||
return current.sentinel;
|
||||
} catch (err) {
|
||||
sentinelLog.warn(`Failed to read restart sentinel: ${formatErrorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readUpdateInstallReceiptPayload(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<RestartSentinelPayload | null> {
|
||||
|
||||
+15
-1
@@ -9,6 +9,7 @@ import {
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
@@ -175,7 +176,7 @@ function configToRow(params: {
|
||||
}
|
||||
|
||||
function readNodeHostConfigRow(
|
||||
database: ReturnType<typeof openOpenClawStateDatabase>,
|
||||
database: Pick<ReturnType<typeof openOpenClawStateDatabase>, "db">,
|
||||
): NodeHostConfigRuntimeRow | undefined {
|
||||
return executeSqliteQueryTakeFirstSync(
|
||||
database.db,
|
||||
@@ -208,6 +209,19 @@ export async function loadNodeHostConfig(
|
||||
return row ? rowToNodeHostConfig(row) : null;
|
||||
}
|
||||
|
||||
/** Load existing node-host state without creating or joining the writable shared-state lifecycle. */
|
||||
export async function loadNodeHostConfigReadOnly(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<NodeHostConfig | null> {
|
||||
assertNodeHostLegacyStateMigrated(env);
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
|
||||
const row = readNodeHostConfigRow({ db });
|
||||
return row ? rowToNodeHostConfig(row) : null;
|
||||
}, databaseOptions(env)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically create or replace the complete node-host snapshot.
|
||||
* Candidate facts are prepared before BEGIN; the transaction rereads the authoritative row.
|
||||
|
||||
@@ -380,8 +380,9 @@ export async function getStatusSummary(
|
||||
const mainSessionKey = resolveSystemMainSessionKey(cfg);
|
||||
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
|
||||
const taskMaintenanceModule = await loadTaskRegistryMaintenanceModule();
|
||||
taskMaintenanceModule.configureTaskRegistryMaintenance();
|
||||
const inspectableTasks = taskMaintenanceModule.reconcileInspectableTasks();
|
||||
// Status may overlap a live Gateway, so task inspection must not initialize
|
||||
// the writable process registry or its schema-owning shared-state handle.
|
||||
const inspectableTasks = taskMaintenanceModule.listInspectableTasksReadOnly();
|
||||
const rawTasks = taskMaintenanceModule.getInspectableTaskRegistrySummary(inspectableTasks);
|
||||
const taskAuditFindings = taskMaintenanceModule.getInspectableTaskAuditFindings(inspectableTasks);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -60,7 +60,10 @@ import {
|
||||
summarizeTaskAuditFindings,
|
||||
} from "./task-registry.audit.js";
|
||||
import type { TaskAuditFinding, TaskAuditSummary } from "./task-registry.audit.js";
|
||||
import { listTaskRegistryRecordsByRuntimeSourceIdFromSqlite } from "./task-registry.store.sqlite.js";
|
||||
import {
|
||||
listTaskRegistryRecordsByRuntimeSourceIdFromSqlite,
|
||||
loadTaskRegistryStateFromSqliteReadOnly,
|
||||
} from "./task-registry.store.sqlite.js";
|
||||
import { summarizeTaskRecords } from "./task-registry.summary.js";
|
||||
import type { TaskRecord, TaskRegistrySummary, TaskStatus } from "./task-registry.types.js";
|
||||
import type { ActiveTaskRestartBlocker } from "./task-restart-blocker.js";
|
||||
@@ -795,19 +798,30 @@ function reconcileTaskRecordForOperatorInspection(
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcileInspectableTasks(): TaskRecord[] {
|
||||
taskRegistryMaintenanceRuntime.ensureTaskRegistryReady();
|
||||
function reconcileTaskRecordsForOperatorInspection(tasks: TaskRecord[]): TaskRecord[] {
|
||||
const cronRecoveryContext = createCronRecoveryContext();
|
||||
const backingSessionContext = createBackingSessionLookupContext();
|
||||
return taskRegistryMaintenanceRuntime
|
||||
.listTaskRecords()
|
||||
.map((task) =>
|
||||
reconcileTaskRecordForOperatorInspectionWithContexts(
|
||||
task,
|
||||
cronRecoveryContext,
|
||||
backingSessionContext,
|
||||
),
|
||||
);
|
||||
return tasks.map((task) =>
|
||||
reconcileTaskRecordForOperatorInspectionWithContexts(
|
||||
task,
|
||||
cronRecoveryContext,
|
||||
backingSessionContext,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcileInspectableTasks(): TaskRecord[] {
|
||||
taskRegistryMaintenanceRuntime.ensureTaskRegistryReady();
|
||||
return reconcileTaskRecordsForOperatorInspection(
|
||||
taskRegistryMaintenanceRuntime.listTaskRecords(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads and reconciles persisted tasks without initializing the process task runtime. */
|
||||
export function listInspectableTasksReadOnly(): TaskRecord[] {
|
||||
return reconcileTaskRecordsForOperatorInspection([
|
||||
...loadTaskRegistryStateFromSqliteReadOnly().tasks.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
configureTaskAuditTaskProvider(reconcileInspectableTasks);
|
||||
|
||||
@@ -341,8 +341,7 @@ function withWriteTransaction(write: (database: OpenClawStateDatabase) => void)
|
||||
runOpenClawStateWriteTransaction((database) => write(database));
|
||||
}
|
||||
|
||||
export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot {
|
||||
const { db, path } = openTaskRegistryDatabase();
|
||||
function readTaskRegistrySnapshot({ db, path }: TaskRegistryDatabase): TaskRegistryStoreSnapshot {
|
||||
return runSqliteDeferredTransactionSync(db, () => {
|
||||
assertSqliteTableIntegrity(db, path, "task_runs");
|
||||
assertSqliteTableIntegrity(db, path, "task_delivery_state");
|
||||
@@ -357,23 +356,17 @@ export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot {
|
||||
});
|
||||
}
|
||||
|
||||
export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot {
|
||||
return readTaskRegistrySnapshot(openTaskRegistryDatabase());
|
||||
}
|
||||
|
||||
/** Loads task records without creating or migrating shared state. */
|
||||
export function loadTaskRegistryStateFromSqliteReadOnly(): TaskRegistryStoreSnapshot {
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(({ db, path }) =>
|
||||
runSqliteDeferredTransactionSync(db, () => {
|
||||
assertSqliteTableIntegrity(db, path, "task_runs");
|
||||
assertSqliteTableIntegrity(db, path, "task_delivery_state");
|
||||
const taskRows = selectTaskRows(db);
|
||||
const deliveryRows = selectTaskDeliveryStateRows(db);
|
||||
return {
|
||||
tasks: new Map(taskRows.map((row) => [row.task_id, rowToTaskRecord(row)])),
|
||||
deliveryStates: new Map(
|
||||
deliveryRows.map((row) => [row.task_id, rowToTaskDeliveryState(row)]),
|
||||
),
|
||||
};
|
||||
}),
|
||||
) ?? { tasks: new Map(), deliveryStates: new Map() }
|
||||
withExistingOpenClawStateDatabaseReadOnly(readTaskRegistrySnapshot) ?? {
|
||||
tasks: new Map(),
|
||||
deliveryStates: new Map(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,8 +388,11 @@ export function listTaskRegistryRecordsByRuntimeSourceIdFromSqlite(params: {
|
||||
if (params.sourceId !== undefined && !sourceId) {
|
||||
return [];
|
||||
}
|
||||
const { db } = openTaskRegistryDatabase();
|
||||
return selectTaskRowsByRuntimeSourceId(db, params.runtime, sourceId).map(rowToTaskRecord);
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(({ db }) =>
|
||||
selectTaskRowsByRuntimeSourceId(db, params.runtime, sourceId).map(rowToTaskRecord),
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function saveTaskRegistryStateToSqlite(snapshot: TaskRegistryStoreSnapshot) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Status shared-state E2E tests enforce the CLI/Gateway SQLite ownership boundary.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOpenClawTestInstance } from "./helpers/openclaw-test-instance.js";
|
||||
|
||||
function seedInspectableTask(db: DatabaseSync): void {
|
||||
const now = Date.now();
|
||||
// Seed through the persisted schema so the CLI must inspect state owned by
|
||||
// another process instead of seeing its own in-memory registry.
|
||||
db.prepare(
|
||||
`INSERT INTO task_runs (
|
||||
task_id, runtime, requester_session_key, owner_key, scope_kind,
|
||||
child_session_key, agent_id, task, status, delivery_status,
|
||||
notify_policy, created_at, last_event_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
// Keep the task inside the reconciliation grace window so status should
|
||||
// report the committed running record unchanged.
|
||||
"status-read-only-task",
|
||||
"subagent",
|
||||
"agent:main:main",
|
||||
"agent:main:main",
|
||||
"session",
|
||||
"agent:main:subagent:status-read-only",
|
||||
"main",
|
||||
"Prove status reads shared task state without joining its write lifecycle",
|
||||
"running",
|
||||
"pending",
|
||||
"done_only",
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
describe("status shared-state ownership", () => {
|
||||
it.each([
|
||||
{ name: "text status", args: ["status"] },
|
||||
{ name: "JSON status", args: ["status", "--json"] },
|
||||
{ name: "all status", args: ["status", "--all"] },
|
||||
{ name: "channel probe", args: ["channels", "status", "--probe", "--json"] },
|
||||
])(
|
||||
"does not create shared state during $name",
|
||||
async ({ name, args }) => {
|
||||
const instance = await createOpenClawTestInstance({
|
||||
name: `status-read-only-${name.replaceAll(" ", "-")}`,
|
||||
});
|
||||
const databasePath = path.join(instance.stateDir, "state", "openclaw.sqlite");
|
||||
try {
|
||||
expect(fs.existsSync(databasePath)).toBe(false);
|
||||
|
||||
const status = await instance.cli(args);
|
||||
|
||||
expect(status.code, status.stderr).toBe(0);
|
||||
if (args[0] === "status" && args.includes("--json")) {
|
||||
expect(JSON.parse(status.stdout)).toMatchObject({ tasks: { total: 0 } });
|
||||
}
|
||||
expect(fs.existsSync(databasePath)).toBe(false);
|
||||
} finally {
|
||||
await instance.cleanup();
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
it("reads committed tasks while the Gateway owns state and another writer is active", async () => {
|
||||
const instance = await createOpenClawTestInstance({ name: "status-read-only-live-gateway" });
|
||||
const databasePath = path.join(instance.stateDir, "state", "openclaw.sqlite");
|
||||
let writer: DatabaseSync | undefined;
|
||||
try {
|
||||
await instance.startGateway();
|
||||
writer = new DatabaseSync(databasePath);
|
||||
seedInspectableTask(writer);
|
||||
// A read-only status path can overlap this writer. Writable schema/bootstrap work cannot.
|
||||
writer.exec("BEGIN IMMEDIATE");
|
||||
|
||||
const status = await instance.cli(["status", "--json"], { timeoutMs: 15_000 });
|
||||
|
||||
expect(status.code, status.stderr).toBe(0);
|
||||
expect(JSON.parse(status.stdout)).toMatchObject({ tasks: { total: 1 } });
|
||||
expect(instance.child?.exitCode).toBeNull();
|
||||
|
||||
writer.exec("ROLLBACK");
|
||||
writer.close();
|
||||
writer = undefined;
|
||||
await instance.stopGateway();
|
||||
|
||||
const verifier = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
expect(verifier.prepare("PRAGMA integrity_check").get()).toEqual({
|
||||
integrity_check: "ok",
|
||||
});
|
||||
} finally {
|
||||
verifier.close();
|
||||
}
|
||||
} finally {
|
||||
if (writer?.isTransaction) {
|
||||
writer.exec("ROLLBACK");
|
||||
}
|
||||
writer?.close();
|
||||
await instance.cleanup();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in New Issue
Block a user