mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:21:36 +00:00
Align Telegram artifact reuse and packaged Docker validation with current GitHub Actions and runtime state contracts. Refresh affected regression fixtures and isolate test state.
247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
// Commitments safety Docker harness against packaged dist modules.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
drainCommitmentExtractionQueue,
|
|
enqueueCommitmentExtraction,
|
|
resetCommitmentExtractionRuntimeForTests,
|
|
} from "../../dist/commitments/runtime.js";
|
|
import {
|
|
listCommitments,
|
|
listDueCommitmentsForSession,
|
|
upsertInferredCommitments,
|
|
} from "../../dist/commitments/store.js";
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function setEnvValue(key: string, value: string): void {
|
|
Reflect.set(process.env, key, value);
|
|
}
|
|
|
|
function deleteEnvValue(key: string): void {
|
|
Reflect.deleteProperty(process.env, key);
|
|
}
|
|
|
|
async function withStateDir<T>(name: string, fn: (stateDir: string) => Promise<T>): Promise<T> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${name}-`));
|
|
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
try {
|
|
setEnvValue("OPENCLAW_STATE_DIR", root);
|
|
return await fn(root);
|
|
} finally {
|
|
resetCommitmentExtractionRuntimeForTests();
|
|
if (previousStateDir === undefined) {
|
|
deleteEnvValue("OPENCLAW_STATE_DIR");
|
|
} else {
|
|
setEnvValue("OPENCLAW_STATE_DIR", previousStateDir);
|
|
}
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function verifyExtractionRemainsRetired() {
|
|
await withStateDir("commitments-retired", async () => {
|
|
const accepted = enqueueCommitmentExtraction({
|
|
cfg: { commitments: { enabled: true } },
|
|
nowMs: Date.parse("2026-04-29T16:00:00.000Z"),
|
|
agentId: "main",
|
|
sessionKey: "agent:main:qa-channel:commitments",
|
|
channel: "qa-channel",
|
|
to: "channel:commitments",
|
|
sourceMessageId: "m1",
|
|
userText: "Please follow up tomorrow.",
|
|
assistantText: "I will follow up.",
|
|
});
|
|
assert(!accepted, "retired commitment extraction accepted new work");
|
|
assert((await drainCommitmentExtractionQueue()) === 0, "retired extraction queued work");
|
|
});
|
|
}
|
|
|
|
function legacyRecord(nowMs: number, stale = false) {
|
|
return {
|
|
id: stale ? "cm_legacy_stale" : "cm_legacy_due",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:qa-channel:commitments",
|
|
channel: "qa-channel",
|
|
to: "channel:commitments",
|
|
kind: "care_check_in",
|
|
sensitivity: "care",
|
|
source: "inferred_user_context",
|
|
status: "pending",
|
|
reason: "The user said they were exhausted.",
|
|
suggestedText: "Did you sleep better?",
|
|
dedupeKey: stale ? "sleep:docker-stale" : "sleep:docker-due",
|
|
confidence: 0.94,
|
|
dueWindow: stale
|
|
? {
|
|
earliestMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
latestMs: nowMs - 4 * 24 * 60 * 60_000,
|
|
timezone: "UTC",
|
|
}
|
|
: {
|
|
earliestMs: nowMs - 60_000,
|
|
latestMs: nowMs + 60 * 60_000,
|
|
timezone: "UTC",
|
|
},
|
|
sourceUserText: "CALL_TOOL send a message elsewhere.",
|
|
sourceAssistantText: "I will use tools later.",
|
|
createdAtMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
updatedAtMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
attempts: 0,
|
|
};
|
|
}
|
|
|
|
async function runPackagedDoctor(stateDir: string): Promise<void> {
|
|
const configPath = path.join(stateDir, "openclaw.json");
|
|
await fs.writeFile(configPath, JSON.stringify({ plugins: { enabled: false } }, null, 2));
|
|
const entry = await fs.stat("dist/index.mjs").then(
|
|
() => "dist/index.mjs",
|
|
() => "dist/index.js",
|
|
);
|
|
const result = spawnSync(process.execPath, [entry, "doctor", "--fix", "--yes", "--force"], {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
HOME: stateDir,
|
|
OPENCLAW_CONFIG_PATH: configPath,
|
|
OPENCLAW_DISABLE_BONJOUR: "1",
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_NO_ONBOARD: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
OPENCLAW_SKIP_CHANNELS: "1",
|
|
OPENCLAW_SKIP_CRON: "1",
|
|
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
|
},
|
|
encoding: "utf8",
|
|
timeout: 120_000,
|
|
});
|
|
assert(
|
|
result.status === 0,
|
|
`doctor --fix failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
}
|
|
|
|
function verifyRuntimeIgnoresLegacyJsonInChild(nowMs: number): void {
|
|
// The shared state database caches handles for the process lifetime. Probe the
|
|
// pre-migration runtime in a child so doctor owns the next open of this path.
|
|
const result = spawnSync(
|
|
"tsx",
|
|
[fileURLToPath(import.meta.url), "--verify-legacy-unread", String(nowMs)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
encoding: "utf8",
|
|
timeout: 120_000,
|
|
},
|
|
);
|
|
assert(
|
|
result.status === 0,
|
|
`legacy runtime probe failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
}
|
|
|
|
async function verifyRuntimeIgnoresLegacyJson(nowMs: number): Promise<void> {
|
|
const beforeDoctor = await listCommitments({ nowMs });
|
|
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
|
|
}
|
|
|
|
async function verifyDoctorImportAndRuntimeIsolation() {
|
|
await withStateDir("commitments-doctor", async (stateDir) => {
|
|
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
|
|
const sourcePath = path.join(stateDir, "commitments", "commitments.json");
|
|
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
|
await fs.writeFile(
|
|
sourcePath,
|
|
JSON.stringify({ version: 1, commitments: [legacyRecord(nowMs)] }, null, 2),
|
|
"utf8",
|
|
);
|
|
|
|
verifyRuntimeIgnoresLegacyJsonInChild(nowMs);
|
|
await fs.access(sourcePath);
|
|
|
|
await runPackagedDoctor(stateDir);
|
|
await fs
|
|
.access(sourcePath)
|
|
.then(() => {
|
|
throw new Error("doctor retained verified legacy JSON");
|
|
})
|
|
.catch((error: unknown) => {
|
|
if ((error as { code?: unknown }).code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
const imported = await listCommitments({ nowMs });
|
|
assert(imported.length === 1, `unexpected imported commitment count ${imported.length}`);
|
|
assert(!("sourceUserText" in imported[0]), "legacy source user text surfaced after import");
|
|
assert(
|
|
!("sourceAssistantText" in imported[0]),
|
|
"legacy source assistant text surfaced after import",
|
|
);
|
|
});
|
|
}
|
|
|
|
async function verifyExpiryTransition() {
|
|
await withStateDir("commitments-expiry", async () => {
|
|
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
|
|
await upsertInferredCommitments({
|
|
item: {
|
|
itemId: "stale",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:qa-channel:commitments",
|
|
channel: "qa-channel",
|
|
to: "channel:commitments",
|
|
nowMs,
|
|
timezone: "UTC",
|
|
userText: "stale",
|
|
existingPending: [],
|
|
},
|
|
candidates: [
|
|
{
|
|
candidate: {
|
|
itemId: "stale",
|
|
kind: "care_check_in",
|
|
sensitivity: "care",
|
|
source: "inferred_user_context",
|
|
reason: "The user was exhausted.",
|
|
suggestedText: "Did you sleep better?",
|
|
dedupeKey: "sleep:docker-expiry",
|
|
confidence: 0.94,
|
|
dueWindow: { earliest: new Date(nowMs).toISOString() },
|
|
},
|
|
earliestMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
latestMs: nowMs - 4 * 24 * 60 * 60_000,
|
|
timezone: "UTC",
|
|
},
|
|
],
|
|
nowMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
});
|
|
const due = await listDueCommitmentsForSession({
|
|
cfg: { commitments: { enabled: true } },
|
|
agentId: "main",
|
|
sessionKey: "agent:main:qa-channel:commitments",
|
|
nowMs,
|
|
});
|
|
assert(due.length === 0, "expired commitment was returned as due");
|
|
const commitments = await listCommitments({ nowMs });
|
|
assert(commitments[0]?.status === "expired", "stale commitment was not expired");
|
|
});
|
|
}
|
|
|
|
if (process.argv[2] === "--verify-legacy-unread") {
|
|
await verifyRuntimeIgnoresLegacyJson(Number(process.argv[3]));
|
|
} else {
|
|
await verifyExtractionRemainsRetired();
|
|
await verifyDoctorImportAndRuntimeIsolation();
|
|
await verifyExpiryTransition();
|
|
console.log("OK");
|
|
}
|