mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor(scripts): deslop tooling scripts (#160798)
This commit is contained in:
parent
a9cb020f56
commit
80fd8670e3
57 changed files with 1599 additions and 2388 deletions
|
|
@ -20,7 +20,7 @@ General model auth is covered in [Authentication](/gateway/authentication). The
|
|||
|
||||
- `scripts/setup-auth-system.sh` - one-time setup: checks current auth, helps generate a long-lived `claude setup-token`, and prints systemd/Termux install steps.
|
||||
- `scripts/claude-auth-status.sh [full|json|simple]` - checks Claude Code + OpenClaw auth status.
|
||||
- `scripts/auth-monitor.sh` - polls status and sends a notification (via OpenClaw send, and/or ntfy.sh) when the token nears expiry. Env: `WARN_HOURS` (default `2`), `NOTIFY_PHONE`, `NOTIFY_NTFY`. Run on a schedule via the bundled `scripts/systemd/openclaw-auth-monitor.{service,timer}` (every 30 minutes).
|
||||
- `scripts/auth-monitor.sh` - polls status and sends a notification (via `openclaw message send`, and/or ntfy.sh) when the token nears expiry, including its final hour while authentication remains usable. Env: `WARN_HOURS` (default `2`), `NOTIFY_PHONE`, `NOTIFY_NTFY`. Run on a schedule via the bundled `scripts/systemd/openclaw-auth-monitor.{service,timer}` (every 30 minutes).
|
||||
- `scripts/mobile-reauth.sh` - re-runs `claude setup-token` and prints URLs to open on a phone, for use over SSH from Termux.
|
||||
- `scripts/termux-quick-auth.sh`, `scripts/termux-auth-widget.sh`, `scripts/termux-sync-widget.sh` - Termux:Widget scripts. They SSH to the host and show a status toast. They open the re-auth console/instructions when auth has expired.
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ openclaw doctor --json
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- **Token generation fails:** Setup and launch stop before saving a generated token or starting the container when the selected random source (`openssl`, Python, or `od`) fails. Repair that command and retry.
|
||||
- **Init executable missing (`lookup init binary` / `container-init binary not found on the host`):** Install `catatonit` on the Podman engine host or repair its configured `init_path`/`helper_binaries_dir` in `containers.conf`, then retry. Installing the helper inside the Gateway or sandbox image does not repair the engine host. Keep `--init` enabled; see [Host init prerequisite](/gateway/sandboxing/podman-backend#host-init-prerequisite).
|
||||
- **Permission denied (EACCES) on config or workspace:** The container runs with `--userns=keep-id` and `--user <your uid>:<your gid>` by default. Ensure the host config/workspace paths are owned by your current user.
|
||||
- **Gateway start blocked (missing `gateway.mode=local`):** Ensure `~/.openclaw/openclaw.json` exists and sets `gateway.mode="local"`. `scripts/podman/setup.sh` creates this if missing.
|
||||
|
|
|
|||
|
|
@ -128,14 +128,18 @@ async function waitForEvent(params: {
|
|||
controlDir: string;
|
||||
afterIndex?: number;
|
||||
description: string;
|
||||
predicate: (event: AdapterEvent) => boolean;
|
||||
match: Partial<AdapterEvent>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ event: AdapterEvent; index: number; events: AdapterEvent[] }> {
|
||||
const startedAt = Date.now();
|
||||
const afterIndex = params.afterIndex ?? -1;
|
||||
while (Date.now() - startedAt < (params.timeoutMs ?? EVENT_WAIT_TIMEOUT_MS)) {
|
||||
const events = await readEvents(params.controlDir);
|
||||
const relativeIndex = events.slice(afterIndex + 1).findIndex(params.predicate);
|
||||
const relativeIndex = events
|
||||
.slice(afterIndex + 1)
|
||||
.findIndex((event) =>
|
||||
Object.entries(params.match).every(([key, value]) => event[key] === value),
|
||||
);
|
||||
if (relativeIndex >= 0) {
|
||||
const index = afterIndex + 1 + relativeIndex;
|
||||
return { event: events[index]!, index, events };
|
||||
|
|
@ -495,8 +499,7 @@ async function sendAndObserveTurn(params: {
|
|||
controlDir: params.controlDir,
|
||||
afterIndex: params.afterIndex,
|
||||
description: `completed turn ${params.text}`,
|
||||
predicate: (event) =>
|
||||
event.event === "turn_end" && event.text === params.text && event.stopReason === "end_turn",
|
||||
match: { event: "turn_end", text: params.text, stopReason: "end_turn" },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +529,7 @@ async function spawnAndBindAcpSession(params: {
|
|||
await waitForEvent({
|
||||
controlDir: params.controlDir,
|
||||
description: "spawned ACP adapter session creation",
|
||||
predicate: (event) => event.event === "session_create",
|
||||
match: { event: "session_create" },
|
||||
});
|
||||
await logDriverEvent(params.scenarioDir, "acp_spawn_bound", {
|
||||
sourceSessionKey: CONTROL_SESSION_KEY,
|
||||
|
|
@ -706,12 +709,15 @@ async function runScenario(params: {
|
|||
agentRows: readAgentSessionRows(state.stateDir, acpSessionKey),
|
||||
});
|
||||
|
||||
const baseline = await sendAndObserveTurn({
|
||||
const turn = {
|
||||
client,
|
||||
controlDir,
|
||||
scenarioDir,
|
||||
stateDir: state.stateDir,
|
||||
acpSessionKey,
|
||||
};
|
||||
const baseline = await sendAndObserveTurn({
|
||||
...turn,
|
||||
text: `baseline-${params.scenario}`,
|
||||
});
|
||||
const oldIdentity = identityFromTurn(baseline.event);
|
||||
|
|
@ -726,13 +732,12 @@ async function runScenario(params: {
|
|||
identity: oldIdentity,
|
||||
});
|
||||
|
||||
if (params.scenario === "cancel-timeout") {
|
||||
await writeMarker(controlDir, "hang-cancel");
|
||||
} else if (params.scenario === "runtime-option-timeout") {
|
||||
await writeMarker(controlDir, "hang-close");
|
||||
await writeMarker(
|
||||
controlDir,
|
||||
params.scenario === "cancel-timeout" ? "hang-cancel" : "hang-close",
|
||||
);
|
||||
if (params.scenario === "runtime-option-timeout") {
|
||||
await writeMarker(controlDir, "hang-set-mode");
|
||||
} else {
|
||||
await writeMarker(controlDir, "hang-close");
|
||||
}
|
||||
if (params.scenario === "late-turn") {
|
||||
await writeMarker(controlDir, "cancel-no-abort");
|
||||
|
|
@ -741,14 +746,21 @@ async function runScenario(params: {
|
|||
let heldTurnRunId: string | undefined;
|
||||
let runtimeOptionRunId: string | undefined;
|
||||
let eventCursor = baseline.index;
|
||||
const waitForOldSessionEvent = (
|
||||
description: string,
|
||||
match: Partial<AdapterEvent>,
|
||||
afterIndex = eventCursor,
|
||||
) =>
|
||||
waitForEvent({
|
||||
controlDir,
|
||||
afterIndex,
|
||||
description,
|
||||
match: { ...match, sessionId: oldIdentity.sessionId },
|
||||
});
|
||||
if (params.scenario === "runtime-option-timeout") {
|
||||
runtimeOptionRunId = await sendChat(client, "/acp set-mode plan");
|
||||
const setModeStarted = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "old runtime-option operation start",
|
||||
predicate: (event) =>
|
||||
event.event === "set_mode_start" && event.sessionId === oldIdentity.sessionId,
|
||||
const setModeStarted = await waitForOldSessionEvent("old runtime-option operation start", {
|
||||
event: "set_mode_start",
|
||||
});
|
||||
eventCursor = setModeStarted.index;
|
||||
await logDriverEvent(scenarioDir, "held_runtime_option_started", {
|
||||
|
|
@ -757,14 +769,9 @@ async function runScenario(params: {
|
|||
});
|
||||
} else if (params.scenario !== "close-timeout") {
|
||||
heldTurnRunId = await sendChat(client, "hold-turn");
|
||||
const heldTurn = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "held old turn start",
|
||||
predicate: (event) =>
|
||||
event.event === "turn_start" &&
|
||||
event.text === "hold-turn" &&
|
||||
event.sessionId === oldIdentity.sessionId,
|
||||
const heldTurn = await waitForOldSessionEvent("held old turn start", {
|
||||
event: "turn_start",
|
||||
text: "hold-turn",
|
||||
});
|
||||
eventCursor = heldTurn.index;
|
||||
await logDriverEvent(scenarioDir, "held_turn_started", {
|
||||
|
|
@ -799,11 +806,7 @@ async function runScenario(params: {
|
|||
});
|
||||
|
||||
const fresh = await sendAndObserveTurn({
|
||||
client,
|
||||
controlDir,
|
||||
scenarioDir,
|
||||
stateDir: state.stateDir,
|
||||
acpSessionKey,
|
||||
...turn,
|
||||
text: `fresh-${params.scenario}`,
|
||||
afterIndex: eventCursor,
|
||||
directToAcpSession: true,
|
||||
|
|
@ -833,53 +836,33 @@ async function runScenario(params: {
|
|||
let lateRuntimeOptionCompletedAt: string | undefined;
|
||||
if (params.scenario === "close-timeout") {
|
||||
await writeMarker(controlDir, "release-close");
|
||||
lateCompletion = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "late close completion",
|
||||
predicate: (event) =>
|
||||
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
|
||||
lateCompletion = await waitForOldSessionEvent("late close completion", {
|
||||
event: "close_end",
|
||||
});
|
||||
} else if (params.scenario === "cancel-timeout") {
|
||||
await writeMarker(controlDir, "release-cancel");
|
||||
lateCompletion = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "late cancel completion",
|
||||
predicate: (event) =>
|
||||
event.event === "cancel_end" && event.sessionId === oldIdentity.sessionId,
|
||||
lateCompletion = await waitForOldSessionEvent("late cancel completion", {
|
||||
event: "cancel_end",
|
||||
});
|
||||
await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "cancelled old turn completion",
|
||||
predicate: (event) =>
|
||||
event.event === "turn_end" &&
|
||||
event.sessionId === oldIdentity.sessionId &&
|
||||
event.stopReason === "cancelled",
|
||||
await waitForOldSessionEvent("cancelled old turn completion", {
|
||||
event: "turn_end",
|
||||
stopReason: "cancelled",
|
||||
});
|
||||
} else if (params.scenario === "runtime-option-timeout") {
|
||||
await writeMarker(controlDir, "release-set-mode");
|
||||
await writeMarker(controlDir, "release-close");
|
||||
const lateRuntimeOption = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "late runtime-option completion",
|
||||
predicate: (event) =>
|
||||
event.event === "set_mode_end" && event.sessionId === oldIdentity.sessionId,
|
||||
const lateRuntimeOption = await waitForOldSessionEvent("late runtime-option completion", {
|
||||
event: "set_mode_end",
|
||||
});
|
||||
assert(
|
||||
lateRuntimeOption.index > fresh.index,
|
||||
"runtime-option completion was not observed after the fresh turn",
|
||||
);
|
||||
lateRuntimeOptionCompletedAt = lateRuntimeOption.event.at;
|
||||
lateCompletion = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "late close completion after runtime-option operation",
|
||||
predicate: (event) =>
|
||||
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
|
||||
});
|
||||
lateCompletion = await waitForOldSessionEvent(
|
||||
"late close completion after runtime-option operation",
|
||||
{ event: "close_end" },
|
||||
);
|
||||
if (runtimeOptionRunId) {
|
||||
await client
|
||||
.request(
|
||||
|
|
@ -896,24 +879,17 @@ async function runScenario(params: {
|
|||
});
|
||||
} else {
|
||||
await writeMarker(controlDir, "release-turn");
|
||||
lateCompletion = await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: eventCursor,
|
||||
description: "late old turn completion",
|
||||
predicate: (event) =>
|
||||
event.event === "turn_end" &&
|
||||
event.sessionId === oldIdentity.sessionId &&
|
||||
event.text === "hold-turn" &&
|
||||
event.stopReason === "end_turn",
|
||||
lateCompletion = await waitForOldSessionEvent("late old turn completion", {
|
||||
event: "turn_end",
|
||||
text: "hold-turn",
|
||||
stopReason: "end_turn",
|
||||
});
|
||||
await writeMarker(controlDir, "release-close");
|
||||
await waitForEvent({
|
||||
controlDir,
|
||||
afterIndex: lateCompletion.index,
|
||||
description: "old close completion after late turn",
|
||||
predicate: (event) =>
|
||||
event.event === "close_end" && event.sessionId === oldIdentity.sessionId,
|
||||
});
|
||||
await waitForOldSessionEvent(
|
||||
"old close completion after late turn",
|
||||
{ event: "close_end" },
|
||||
lateCompletion.index,
|
||||
);
|
||||
}
|
||||
eventCursor = lateCompletion.index;
|
||||
|
||||
|
|
@ -946,11 +922,7 @@ async function runScenario(params: {
|
|||
}
|
||||
|
||||
const followup = await sendAndObserveTurn({
|
||||
client,
|
||||
controlDir,
|
||||
scenarioDir,
|
||||
stateDir: state.stateDir,
|
||||
acpSessionKey,
|
||||
...turn,
|
||||
text: `followup-${params.scenario}`,
|
||||
afterIndex: eventCursor,
|
||||
directToAcpSession: true,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import postcss, { type Rule } from "postcss";
|
|||
import selectorParser, { type ClassName, type Selector } from "postcss-selector-parser";
|
||||
import * as ts from "typescript/unstable/ast";
|
||||
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
||||
import { getPropertyNameText } from "./lib/ts-guard-utils.mts";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||
|
|
@ -82,12 +83,9 @@ function groupBy<T, K>(values: Iterable<T>, keyFor: (value: T) => K): Map<K, T[]
|
|||
const groups = new Map<K, T[]>();
|
||||
for (const value of values) {
|
||||
const key = keyFor(value);
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(value);
|
||||
} else {
|
||||
groups.set(key, [value]);
|
||||
}
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(value);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
|
@ -146,11 +144,7 @@ function classMapPropertyName(node: ts.ObjectLiteralElementLike): string | null
|
|||
if (!ts.isPropertyAssignment(node) && !ts.isShorthandPropertyAssignment(node)) {
|
||||
return null;
|
||||
}
|
||||
const name = node.name;
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text;
|
||||
}
|
||||
return null;
|
||||
return getPropertyNameText(node.name);
|
||||
}
|
||||
|
||||
/** Collect literal class tokens and dynamic class stems from TypeScript source. */
|
||||
|
|
|
|||
|
|
@ -44,10 +44,13 @@ send_notification() {
|
|||
|
||||
# Send via OpenClaw if phone configured and auth still valid
|
||||
if [ -n "$NOTIFY_PHONE" ]; then
|
||||
# Check if we can still use openclaw
|
||||
if "$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null | grep -q "OK\|EXPIRING"; then
|
||||
local auth_status="" auth_exit=0
|
||||
auth_status="$("$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null)" || auth_exit=$?
|
||||
# Expiring credentials remain usable; the status helper reports them with exit 2.
|
||||
if [[ "$auth_exit" -eq 0 && "$auth_status" == "OK" ]] ||
|
||||
[[ "$auth_exit" -eq 2 && ( "$auth_status" == "CLAUDE_EXPIRING" || "$auth_status" == "OPENCLAW_EXPIRING" ) ]]; then
|
||||
echo "Sending via OpenClaw to $NOTIFY_PHONE..."
|
||||
if openclaw send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null; then
|
||||
if openclaw message send --target "$NOTIFY_PHONE" --message "$message" 2>/dev/null; then
|
||||
notification_sent=1
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import sys
|
|||
|
||||
|
||||
INPUT_INTERPOLATION_RE = re.compile(r"\$\{\{\s*inputs\.")
|
||||
RUN_LINE_RE = re.compile(r"^(\s*)run:\s*(.*)$")
|
||||
USING_COMPOSITE_RE = re.compile(r"^\s*using:\s*composite\s*$", re.MULTILINE)
|
||||
RUN_LINE_RE = re.compile(r"^(\s*(?:-\s+)?)run:\s*(.*)$")
|
||||
USING_COMPOSITE_RE = re.compile(
|
||||
r"""^\s*using:\s*(?:composite|'composite'|"composite")\s*(?:#.*)?$""",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def indentation(line: str) -> int:
|
||||
|
|
|
|||
|
|
@ -21,16 +21,13 @@ const KNIP_SCANS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
/** Parses compact Knip output into unused file paths. */
|
||||
export function parseKnipCompactUnusedFiles(output: string) {
|
||||
const files = [];
|
||||
let inUnusedFilesSection = false;
|
||||
let sawUnusedFilesSection = false;
|
||||
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
if (/^Unused files \(\d+\)$/u.test(line)) {
|
||||
inUnusedFilesSection = true;
|
||||
sawUnusedFilesSection = true;
|
||||
continue;
|
||||
}
|
||||
if (inUnusedFilesSection && line.trim() === "") {
|
||||
|
|
@ -38,7 +35,7 @@ export function parseKnipCompactUnusedFiles(output: string) {
|
|||
}
|
||||
|
||||
const separatorIndex = line.lastIndexOf(": ");
|
||||
if (separatorIndex === -1 || (sawUnusedFilesSection && !inUnusedFilesSection)) {
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const file = line.slice(separatorIndex + 2).trim();
|
||||
|
|
@ -50,7 +47,6 @@ export function parseKnipCompactUnusedFiles(output: string) {
|
|||
return uniqueSorted(files);
|
||||
}
|
||||
|
||||
/** Rejects every unused file reported by Knip. */
|
||||
export function checkUnusedFiles(output: string) {
|
||||
const files = parseKnipCompactUnusedFiles(output);
|
||||
return {
|
||||
|
|
@ -67,7 +63,6 @@ export function checkUnusedFiles(output: string) {
|
|||
};
|
||||
}
|
||||
|
||||
/** Validates both Knip process completion and the unused-file report. */
|
||||
export function checkKnipUnusedFileScanResult(result: KnipRunResult) {
|
||||
if (result.errorCode || result.status === null || result.status !== 0) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import * as ts from "typescript/unstable/ast";
|
||||
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
||||
import { toLine } from "./lib/ts-guard-utils.mts";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
const SCAN_ROOTS = ["src", "extensions", "packages"];
|
||||
|
|
@ -49,10 +50,6 @@ function normalizeCommentText(comment: string) {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
function lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
||||
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
}
|
||||
|
||||
function isExported(node: ts.ModifiersBase) {
|
||||
return (
|
||||
node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ||
|
||||
|
|
@ -110,7 +107,7 @@ function collectViolations(filePath: string, sourceFile: ts.SourceFile) {
|
|||
!/@deprecated\b/.test(comment)
|
||||
) {
|
||||
violations.push({
|
||||
line: lineOf(sourceFile, node),
|
||||
line: toLine(sourceFile, node),
|
||||
name: symbolName(node),
|
||||
filePath: path.relative(repoRoot, filePath).replaceAll(path.sep, "/"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,48 +166,7 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
|
|||
const generatedExtensionAssetSources = new Set(
|
||||
listGeneratedExtensionAssetSources({ rootDir: repoRoot }),
|
||||
);
|
||||
const collectBoundaryEntries: NonNullable<
|
||||
Parameters<
|
||||
typeof createExtensionImportBoundaryChecker<CollectedBoundaryEntry>
|
||||
>[0]["collectEntries"]
|
||||
> = ({ filePath, relativeFile, references }) => {
|
||||
const extensionRoot = relativeFile.split("/").slice(0, 2).join("/");
|
||||
const entries: CollectedBoundaryEntry[] = [];
|
||||
for (const { kind, line, specifier } of references) {
|
||||
const resolvedPath = resolveBoundarySpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const modes: BoundaryMode[] = [];
|
||||
if (
|
||||
specifier.startsWith(".") &&
|
||||
resolvedPath !== extensionRoot &&
|
||||
!resolvedPath.startsWith(extensionRoot + "/")
|
||||
) {
|
||||
modes.push("relative-outside-package");
|
||||
}
|
||||
if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) {
|
||||
modes.push("src-outside-plugin-sdk");
|
||||
}
|
||||
if (isNormalizationCoreBypass(specifier, resolvedPath)) {
|
||||
modes.push("normalization-core-bypass");
|
||||
}
|
||||
for (const mode of modes) {
|
||||
entries.push({
|
||||
mode,
|
||||
entry: {
|
||||
file: relativeFile,
|
||||
line,
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyReason(mode, kind, resolvedPath, specifier),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const extensionBoundaryChecker = createExtensionImportBoundaryChecker<CollectedBoundaryEntry>({
|
||||
repoRoot,
|
||||
roots: [BUNDLED_PLUGIN_ROOT_DIR],
|
||||
|
|
@ -242,7 +201,44 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
|
|||
!resolvedPath.startsWith(extensionRoot + "/"))
|
||||
);
|
||||
},
|
||||
collectEntries: collectBoundaryEntries,
|
||||
collectEntries({ filePath, relativeFile, references }) {
|
||||
const extensionRoot = relativeFile.split("/").slice(0, 2).join("/");
|
||||
const entries: CollectedBoundaryEntry[] = [];
|
||||
for (const { kind, line, specifier } of references) {
|
||||
const resolvedPath = resolveBoundarySpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const modes: BoundaryMode[] = [];
|
||||
if (
|
||||
specifier.startsWith(".") &&
|
||||
resolvedPath !== extensionRoot &&
|
||||
!resolvedPath.startsWith(extensionRoot + "/")
|
||||
) {
|
||||
modes.push("relative-outside-package");
|
||||
}
|
||||
if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) {
|
||||
modes.push("src-outside-plugin-sdk");
|
||||
}
|
||||
if (isNormalizationCoreBypass(specifier, resolvedPath)) {
|
||||
modes.push("normalization-core-bypass");
|
||||
}
|
||||
for (const mode of modes) {
|
||||
entries.push({
|
||||
mode,
|
||||
entry: {
|
||||
file: relativeFile,
|
||||
line,
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyReason(mode, kind, resolvedPath, specifier),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
compareEntries: (left, right) => compareEntries(left.entry, right.entry),
|
||||
});
|
||||
let allInventoryByModePromise: Promise<BoundaryInventoryByMode> | undefined;
|
||||
|
|
@ -316,9 +312,6 @@ export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: st
|
|||
|
||||
const defaultBoundaryChecker = createExtensionPluginSdkBoundaryChecker();
|
||||
|
||||
/**
|
||||
* Entrypoint wrapper for the extension plugin SDK boundary check.
|
||||
*/
|
||||
async function main(argv?: string[], io?: BoundaryCheckIo): Promise<0 | 1> {
|
||||
const exitCode = await defaultBoundaryChecker.main(argv, io);
|
||||
if (!io) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Runs gateway startup and QA scenarios while checking hot CPU observations.
|
||||
import {
|
||||
spawnSync as defaultSpawnSync,
|
||||
type SpawnSyncOptions,
|
||||
|
|
@ -179,92 +178,56 @@ function readJsonIfExists(filePath: string): unknown {
|
|||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function validateStartupReport(report: unknown): string | null {
|
||||
function parseStartupReport(report: unknown) {
|
||||
if (!isRecord(report)) {
|
||||
return "startup report must be a JSON object";
|
||||
throw new Error("startup report must be a JSON object");
|
||||
}
|
||||
if (!Array.isArray(report.results)) {
|
||||
return "startup report missing results array";
|
||||
throw new Error("startup report missing results array");
|
||||
}
|
||||
if (report.results.length === 0) {
|
||||
return "startup report has no measured results";
|
||||
throw new Error("startup report has no measured results");
|
||||
}
|
||||
return null;
|
||||
return report;
|
||||
}
|
||||
|
||||
function readStartupReport(startupOutput: string) {
|
||||
if (!fs.existsSync(startupOutput)) {
|
||||
function parseConcurrencyReport(report: unknown): ConcurrencyReport {
|
||||
if (!isRecord(report)) {
|
||||
throw new Error("concurrency report must be a JSON object");
|
||||
}
|
||||
if (report.mode !== "mock-streaming-agent") {
|
||||
throw new Error("concurrency report has an unexpected mode");
|
||||
}
|
||||
if (!Array.isArray(report.runs) || report.runs.length === 0) {
|
||||
throw new Error("concurrency report has no measured runs");
|
||||
}
|
||||
if (!isRecord(report.summary)) {
|
||||
throw new Error("concurrency report missing summary");
|
||||
}
|
||||
return report as ConcurrencyReport;
|
||||
}
|
||||
|
||||
function readBenchReport<T>(
|
||||
output: string,
|
||||
kind: "startup" | "concurrency",
|
||||
parse: (report: unknown) => T,
|
||||
) {
|
||||
if (!fs.existsSync(output)) {
|
||||
return {
|
||||
diagnosticFailure: "startup-report-missing",
|
||||
diagnosticDetail: `expected startup bench report at ${startupOutput}`,
|
||||
diagnosticFailure: `${kind}-report-missing`,
|
||||
diagnosticDetail: `expected ${kind} bench report at ${output}`,
|
||||
report: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const report = readJsonIfExists(startupOutput);
|
||||
const invalidReason = validateStartupReport(report);
|
||||
if (invalidReason) {
|
||||
return {
|
||||
diagnosticFailure: "startup-report-invalid",
|
||||
diagnosticDetail: invalidReason,
|
||||
report: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
diagnosticFailure: null,
|
||||
diagnosticDetail: null,
|
||||
report,
|
||||
report: parse(readJsonIfExists(output)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnosticFailure: "startup-report-invalid",
|
||||
diagnosticDetail: error instanceof Error ? error.message : String(error),
|
||||
report: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateConcurrencyReport(report: unknown): string | null {
|
||||
if (!isRecord(report)) {
|
||||
return "concurrency report must be a JSON object";
|
||||
}
|
||||
if (report.mode !== "mock-streaming-agent") {
|
||||
return "concurrency report has an unexpected mode";
|
||||
}
|
||||
if (!Array.isArray(report.runs) || report.runs.length === 0) {
|
||||
return "concurrency report has no measured runs";
|
||||
}
|
||||
if (!isRecord(report.summary)) {
|
||||
return "concurrency report missing summary";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readConcurrencyReport(concurrencyOutput: string) {
|
||||
if (!fs.existsSync(concurrencyOutput)) {
|
||||
return {
|
||||
diagnosticFailure: "concurrency-report-missing",
|
||||
diagnosticDetail: `expected concurrency bench report at ${concurrencyOutput}`,
|
||||
report: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const report = readJsonIfExists(concurrencyOutput);
|
||||
const invalidReason = validateConcurrencyReport(report);
|
||||
return invalidReason
|
||||
? {
|
||||
diagnosticFailure: "concurrency-report-invalid",
|
||||
diagnosticDetail: invalidReason,
|
||||
report: null,
|
||||
}
|
||||
: {
|
||||
diagnosticFailure: null,
|
||||
diagnosticDetail: null,
|
||||
report: report as ConcurrencyReport,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnosticFailure: "concurrency-report-invalid",
|
||||
diagnosticFailure: `${kind}-report-invalid`,
|
||||
diagnosticDetail: error instanceof Error ? error.message : String(error),
|
||||
report: null,
|
||||
};
|
||||
|
|
@ -421,58 +384,49 @@ async function runGatewayCpuScenarios(
|
|||
params,
|
||||
);
|
||||
steps.push(startupBuild);
|
||||
steps.push(
|
||||
startupBuild.status === 0
|
||||
? runStep(
|
||||
"startup bench",
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-gateway-startup.ts",
|
||||
"--runs",
|
||||
String(options.runs),
|
||||
"--warmup",
|
||||
String(options.warmup),
|
||||
"--output",
|
||||
startupOutput,
|
||||
...options.startupCases.flatMap((id) => ["--case", id]),
|
||||
],
|
||||
{ env: baseEnv },
|
||||
params,
|
||||
)
|
||||
: { name: "startup bench", signal: null, status: 1 },
|
||||
);
|
||||
steps.push(
|
||||
startupBuild.status === 0
|
||||
? runStep(
|
||||
"concurrency bench",
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-gateway-concurrency.ts",
|
||||
"--concurrency",
|
||||
String(DEFAULT_GATEWAY_CONCURRENCY),
|
||||
"--workspace-fanout",
|
||||
// Post-fix readyz/sessions.list p100 is 1.3-2.6s across environments;
|
||||
// 4s still catches the pre-fix 8s+ stalls and handshake timeouts.
|
||||
"--max-control-ms",
|
||||
"4000",
|
||||
"--max-handshake-ms",
|
||||
"2000",
|
||||
"--runs",
|
||||
String(options.runs),
|
||||
"--warmup",
|
||||
String(options.warmup),
|
||||
"--output",
|
||||
concurrencyOutput,
|
||||
],
|
||||
{ env: baseEnv },
|
||||
params,
|
||||
)
|
||||
: { name: "concurrency bench", signal: null, status: 1 },
|
||||
);
|
||||
const runArgs = ["--runs", String(options.runs), "--warmup", String(options.warmup)];
|
||||
for (const bench of [
|
||||
{
|
||||
name: "startup bench",
|
||||
script: "scripts/bench-gateway-startup.ts",
|
||||
args: [
|
||||
...runArgs,
|
||||
"--output",
|
||||
startupOutput,
|
||||
...options.startupCases.flatMap((id) => ["--case", id]),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "concurrency bench",
|
||||
script: "scripts/bench-gateway-concurrency.ts",
|
||||
args: [
|
||||
"--concurrency",
|
||||
String(DEFAULT_GATEWAY_CONCURRENCY),
|
||||
"--workspace-fanout",
|
||||
// Post-fix readyz/sessions.list p100 is 1.3-2.6s across environments;
|
||||
// 4s still catches the pre-fix 8s+ stalls and handshake timeouts.
|
||||
"--max-control-ms",
|
||||
"4000",
|
||||
"--max-handshake-ms",
|
||||
"2000",
|
||||
...runArgs,
|
||||
"--output",
|
||||
concurrencyOutput,
|
||||
],
|
||||
},
|
||||
]) {
|
||||
steps.push(
|
||||
startupBuild.status === 0
|
||||
? runStep(
|
||||
bench.name,
|
||||
process.execPath,
|
||||
["--import", "tsx", bench.script, ...bench.args],
|
||||
{ env: baseEnv },
|
||||
params,
|
||||
)
|
||||
: { name: bench.name, signal: null, status: 1 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let privateQaBuildFailed = false;
|
||||
|
|
@ -528,7 +482,9 @@ async function runGatewayCpuScenarios(
|
|||
steps.push(qaStep);
|
||||
}
|
||||
|
||||
const startupReportResult = options.skipStartup ? null : readStartupReport(startupOutput);
|
||||
const startupReportResult = options.skipStartup
|
||||
? null
|
||||
: readBenchReport(startupOutput, "startup", parseStartupReport);
|
||||
const startupReportFailure =
|
||||
steps.find((step) => step.name === "startup bench")?.status === 0
|
||||
? (startupReportResult?.diagnosticFailure ?? null)
|
||||
|
|
@ -536,7 +492,7 @@ async function runGatewayCpuScenarios(
|
|||
const startup = startupReportResult?.report ?? null;
|
||||
const concurrencyReportResult = options.skipStartup
|
||||
? null
|
||||
: readConcurrencyReport(concurrencyOutput);
|
||||
: readBenchReport(concurrencyOutput, "concurrency", parseConcurrencyReport);
|
||||
const concurrencyReportFailure =
|
||||
steps.find((step) => step.name === "concurrency bench")?.status === 0
|
||||
? (concurrencyReportResult?.diagnosticFailure ?? null)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Enforces Kysely and SQLite guardrails in infrastructure code.
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as ts from "typescript/unstable/ast";
|
||||
import type { Expression, ImportDeclaration, Node, SourceFile } from "typescript/unstable/ast";
|
||||
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
collectFileViolations,
|
||||
getPropertyNameText,
|
||||
runAsScript,
|
||||
toLine,
|
||||
|
|
@ -350,11 +348,7 @@ function isLikelySqliteReceiver(expression: Expression) {
|
|||
|
||||
function isPersistedRowExpression(expression: Expression) {
|
||||
const unwrapped = unwrapExpression(expression);
|
||||
if (ts.isPropertyAccessExpression(unwrapped)) {
|
||||
const owner = unwrapExpression(unwrapped.expression);
|
||||
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
|
||||
}
|
||||
if (ts.isElementAccessExpression(unwrapped)) {
|
||||
if (ts.isPropertyAccessExpression(unwrapped) || ts.isElementAccessExpression(unwrapped)) {
|
||||
const owner = unwrapExpression(unwrapped.expression);
|
||||
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
|
||||
}
|
||||
|
|
@ -368,9 +362,6 @@ function isPersistedStringCastType(typeText: string) {
|
|||
].some((pattern) => pattern.test(typeText));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects Kysely/raw SQLite violations from one source file.
|
||||
*/
|
||||
function collectKyselyGuardrailViolations(sourceFile: SourceFile, relativePath: string) {
|
||||
const imports = collectImports(sourceFile);
|
||||
const violations: GuardViolation[] = [];
|
||||
|
|
@ -491,38 +482,31 @@ function collectKyselyGuardrailViolations(sourceFile: SourceFile, relativePath:
|
|||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects Kysely guardrail violations across configured source roots.
|
||||
*/
|
||||
async function collectKyselyGuardrails() {
|
||||
using parser = createNativeTypeScriptParser({ cwd: repoRoot });
|
||||
const files = await collectTypeScriptFilesFromRoots(sourceRoots, { includeTests: true });
|
||||
const violations: Array<GuardViolation & { path: string }> = [];
|
||||
for (const filePath of files) {
|
||||
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = parser.parseSourceFile(filePath, content);
|
||||
for (const violation of collectKyselyGuardrailViolations(sourceFile, relativePath)) {
|
||||
violations.push({ path: relativePath, ...violation });
|
||||
}
|
||||
}
|
||||
const nodeSqliteFiles = await collectTypeScriptFilesFromRoots(nodeSqliteBoundaryRoots, {
|
||||
includeTests: false,
|
||||
});
|
||||
for (const filePath of nodeSqliteFiles) {
|
||||
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = parser.parseSourceFile(filePath, content);
|
||||
for (const violation of collectNodeSqliteBoundaryViolations(sourceFile, relativePath)) {
|
||||
violations.push({ path: relativePath, ...violation });
|
||||
for (const scan of [
|
||||
{ sourceRoots, includeTests: true, collect: collectKyselyGuardrailViolations },
|
||||
{
|
||||
sourceRoots: nodeSqliteBoundaryRoots,
|
||||
includeTests: false,
|
||||
collect: collectNodeSqliteBoundaryViolations,
|
||||
},
|
||||
]) {
|
||||
const found = await collectFileViolations({
|
||||
repoRoot,
|
||||
sourceRoots: scan.sourceRoots,
|
||||
includeTests: scan.includeTests,
|
||||
findViolations: (_content, filePath, sourceFile) =>
|
||||
scan.collect(sourceFile, path.relative(repoRoot, filePath).split(path.sep).join("/")),
|
||||
});
|
||||
for (const violation of found) {
|
||||
violation.path = violation.path.split(path.sep).join("/");
|
||||
violations.push(violation);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the Kysely guardrail check.
|
||||
*/
|
||||
async function main() {
|
||||
const violations = await collectKyselyGuardrails();
|
||||
if (violations.length === 0) {
|
||||
|
|
|
|||
|
|
@ -24,18 +24,6 @@ type CoverageConfig = {
|
|||
readonly classifications: readonly CoverageClassification[];
|
||||
};
|
||||
|
||||
type ConfigDocBaseline = {
|
||||
readonly coreEntries: readonly ConfigDocBaselineEntry[];
|
||||
readonly channelEntries: readonly ConfigDocBaselineEntry[];
|
||||
readonly pluginEntries: readonly ConfigDocBaselineEntry[];
|
||||
};
|
||||
|
||||
function flattenConfigDocBaselineEntries(
|
||||
baseline: ConfigDocBaseline,
|
||||
): readonly ConfigDocBaselineEntry[] {
|
||||
return [...baseline.coreEntries, ...baseline.channelEntries, ...baseline.pluginEntries];
|
||||
}
|
||||
|
||||
type ClassifiedEntry = {
|
||||
readonly path: string;
|
||||
readonly kind: ConfigDocBaselineEntry["kind"];
|
||||
|
|
@ -67,11 +55,14 @@ const configPath = path.join(repoRoot, "scripts/lib/policy-config-coverage.jsonc
|
|||
|
||||
const config = JSON5.parse(await fs.readFile(configPath, "utf8")) as CoverageConfig;
|
||||
const { baseline } = await renderConfigDocBaselineArtifacts();
|
||||
const monitoredEntries = flattenConfigDocBaselineEntries(baseline)
|
||||
.filter((entry) => !entry.hasChildren)
|
||||
.filter((entry) => matchesAny(config.monitored, entry.path))
|
||||
const leafEntries = [
|
||||
...baseline.coreEntries,
|
||||
...baseline.channelEntries,
|
||||
...baseline.pluginEntries,
|
||||
].filter((entry) => !entry.hasChildren);
|
||||
const monitoredEntries = leafEntries
|
||||
.filter((entry) => config.monitored.some((pattern) => pathMatchesPattern(pattern, entry.path)))
|
||||
.toSorted((left, right) => left.path.localeCompare(right.path));
|
||||
const leafEntries = flattenConfigDocBaselineEntries(baseline).filter((entry) => !entry.hasChildren);
|
||||
const unmatchedMonitored = config.monitored
|
||||
.filter(
|
||||
(pattern) =>
|
||||
|
|
@ -196,10 +187,6 @@ function summarize(entries: readonly ClassifiedEntry[]): Record<string, number>
|
|||
return counts;
|
||||
}
|
||||
|
||||
function matchesAny(patterns: readonly string[], value: string): boolean {
|
||||
return patterns.some((pattern) => pathMatchesPattern(pattern, value));
|
||||
}
|
||||
|
||||
function pathMatchesPattern(pattern: string, value: string): boolean {
|
||||
const patternParts = pattern.split(".");
|
||||
const valueParts = value.split(".");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Finds hidden local runtime sidecar loaders missing tsdown entries.
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as ts from "typescript/unstable/ast";
|
||||
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
collectFileViolations,
|
||||
runAsScript,
|
||||
toLine,
|
||||
unwrapExpression,
|
||||
|
|
@ -72,10 +70,6 @@ function isCreateRequireCall(node: ts.Node, createRequireNames: Set<string>): bo
|
|||
);
|
||||
}
|
||||
|
||||
function isLocalRuntimeSpecifier(specifier: string): boolean {
|
||||
return localRuntimeSpecifierPattern.test(specifier);
|
||||
}
|
||||
|
||||
function resolveRuntimeSpecifierSource(importerPath: string, specifier: string): string {
|
||||
const importerDir = path.posix.dirname(normalizeRelativePath(importerPath));
|
||||
const resolved = path.posix.normalize(path.posix.join(importerDir, specifier));
|
||||
|
|
@ -89,9 +83,6 @@ function readObjectEntrySources(entry: unknown): string[] {
|
|||
return Object.values(entry).filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects explicit source entry files from tsdown configuration.
|
||||
*/
|
||||
export function collectTsdownEntrySources(config: unknown): Set<string> {
|
||||
const configs = Array.isArray(config) ? config : [config];
|
||||
return new Set(
|
||||
|
|
@ -105,9 +96,6 @@ export function collectTsdownEntrySources(config: unknown): Set<string> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds local runtime require loaders not represented as explicit tsdown entries.
|
||||
*/
|
||||
export function findRuntimeSidecarLoaderViolations(
|
||||
_content: string,
|
||||
importerPath: string,
|
||||
|
|
@ -118,22 +106,12 @@ export function findRuntimeSidecarLoaderViolations(
|
|||
const requireNames = new Set<string>();
|
||||
const stringConstants = new Map<string, string>();
|
||||
const stringArrays = new Map<string, string[]>();
|
||||
const forOfRuntimeValues: Array<Map<string, string[]>> = [];
|
||||
const forOfRuntimeValues: Array<{ name: string; values: string[] }> = [];
|
||||
const violations: RuntimeSidecarLoaderViolation[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const currentForOfValueMap = (): Map<string, string[]> => {
|
||||
const merged = new Map<string, string[]>();
|
||||
for (const scope of forOfRuntimeValues) {
|
||||
for (const [name, values] of scope) {
|
||||
merged.set(name, values);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const addSpecifier = (specifier: string, node: ts.Node): void => {
|
||||
if (!isLocalRuntimeSpecifier(specifier)) {
|
||||
if (!localRuntimeSpecifierPattern.test(specifier)) {
|
||||
return;
|
||||
}
|
||||
const sourcePath = resolveRuntimeSpecifierSource(importerPath, specifier);
|
||||
|
|
@ -166,7 +144,9 @@ export function findRuntimeSidecarLoaderViolations(
|
|||
return [literal];
|
||||
}
|
||||
if (ts.isIdentifier(unwrapped)) {
|
||||
const loopValues = currentForOfValueMap().get(unwrapped.text);
|
||||
const loopValues = forOfRuntimeValues.findLast(
|
||||
(scope) => scope.name === unwrapped.text,
|
||||
)?.values;
|
||||
if (loopValues) {
|
||||
return loopValues;
|
||||
}
|
||||
|
|
@ -222,7 +202,7 @@ export function findRuntimeSidecarLoaderViolations(
|
|||
) {
|
||||
const values = stringArrays.get(expression.text);
|
||||
if (values) {
|
||||
forOfRuntimeValues.push(new Map([[initializer.declarations[0].name.text, values]]));
|
||||
forOfRuntimeValues.push({ name: initializer.declarations[0].name.text, values });
|
||||
node.statement.forEachChild(visit);
|
||||
forOfRuntimeValues.pop();
|
||||
return;
|
||||
|
|
@ -247,33 +227,26 @@ export function findRuntimeSidecarLoaderViolations(
|
|||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects runtime sidecar loader violations across configured roots.
|
||||
*/
|
||||
async function collectRuntimeSidecarLoaderViolations(params: {
|
||||
repoRoot: string;
|
||||
sourceRoots: string[];
|
||||
explicitEntrySources: Set<string>;
|
||||
}): Promise<LocatedRuntimeSidecarLoaderViolation[]> {
|
||||
using parser = createNativeTypeScriptParser({ cwd: params.repoRoot });
|
||||
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
|
||||
const violations = await collectFileViolations({
|
||||
repoRoot: params.repoRoot,
|
||||
sourceRoots: params.sourceRoots,
|
||||
extraTestSuffixes: [".test-support.ts", ".test-helpers.ts"],
|
||||
skipFile: (filePath) => filePath.endsWith(".d.ts"),
|
||||
findViolations: (content, filePath, sourceFile) =>
|
||||
findRuntimeSidecarLoaderViolations(
|
||||
content,
|
||||
normalizeRelativePath(path.relative(params.repoRoot, filePath)),
|
||||
params.explicitEntrySources,
|
||||
sourceFile,
|
||||
),
|
||||
});
|
||||
const violations: LocatedRuntimeSidecarLoaderViolation[] = [];
|
||||
for (const filePath of files) {
|
||||
if (filePath.endsWith(".d.ts")) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = normalizeRelativePath(path.relative(params.repoRoot, filePath));
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
for (const violation of findRuntimeSidecarLoaderViolations(
|
||||
content,
|
||||
relativePath,
|
||||
params.explicitEntrySources,
|
||||
parser.parseSourceFile(filePath, content),
|
||||
)) {
|
||||
violations.push({ path: relativePath, ...violation });
|
||||
}
|
||||
for (const violation of violations) {
|
||||
violation.path = normalizeRelativePath(violation.path);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,12 +388,9 @@ function findNamedBoundaryViolations(
|
|||
return violations;
|
||||
}
|
||||
|
||||
function findNamedSessionStoreViolations(
|
||||
sourceFile: ts.SourceFile,
|
||||
legacyNames: ReadonlySet<string>,
|
||||
legacyKind: string,
|
||||
) {
|
||||
return findNamedBoundaryViolations(sourceFile, legacyNames, `legacy session store ${legacyKind}`);
|
||||
function namedBoundaryRule(legacyNames: ReadonlySet<string>, subject: string) {
|
||||
return (_content: string, _fileName: string, sourceFile: ts.SourceFile) =>
|
||||
findNamedBoundaryViolations(sourceFile, legacyNames, subject);
|
||||
}
|
||||
|
||||
export function collectSessionStoreRuntimeFileBackedCompatExports(
|
||||
|
|
@ -475,20 +472,13 @@ export function findSessionAccessorBoundaryViolations(
|
|||
) {
|
||||
const legacyNames = legacyNamesForFile(fileName);
|
||||
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
|
||||
return findNamedSessionStoreViolations(sourceFile, legacyNames, legacyKind);
|
||||
return findNamedBoundaryViolations(sourceFile, legacyNames, `legacy session store ${legacyKind}`);
|
||||
}
|
||||
|
||||
export function findReadOnlySessionAccessorViolations(
|
||||
_content: string,
|
||||
_fileName: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
) {
|
||||
return findNamedBoundaryViolations(
|
||||
sourceFile,
|
||||
materializingSessionEntryAccessorNames,
|
||||
"materializing session entry accessor",
|
||||
);
|
||||
}
|
||||
export const findReadOnlySessionAccessorViolations = namedBoundaryRule(
|
||||
materializingSessionEntryAccessorNames,
|
||||
"materializing session entry accessor",
|
||||
);
|
||||
|
||||
export function findEmbeddedAgentSessionTargetViolations(
|
||||
_content: string,
|
||||
|
|
@ -512,16 +502,10 @@ export function findEmbeddedAgentSessionTargetViolations(
|
|||
const visitRunOptions = (options: ts.ObjectLiteralExpression) => {
|
||||
for (const property of options.properties) {
|
||||
if (
|
||||
ts.isPropertyAssignment(property) &&
|
||||
(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
|
||||
getPropertyNameText(property.name) === "sessionFile"
|
||||
) {
|
||||
recordDeprecatedSessionFile(property.name);
|
||||
} else if (
|
||||
ts.isShorthandPropertyAssignment(property) &&
|
||||
ts.isIdentifier(property.name) &&
|
||||
property.name.text === "sessionFile"
|
||||
) {
|
||||
recordDeprecatedSessionFile(property.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -542,25 +526,15 @@ export function findEmbeddedAgentSessionTargetViolations(
|
|||
return violations;
|
||||
}
|
||||
|
||||
export function findSessionAccessorWriteBoundaryViolations(
|
||||
_content: string,
|
||||
_fileName: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
) {
|
||||
return findNamedSessionStoreViolations(sourceFile, legacyWriterNames, "writer");
|
||||
}
|
||||
export const findSessionAccessorWriteBoundaryViolations = namedBoundaryRule(
|
||||
legacyWriterNames,
|
||||
"legacy session store writer",
|
||||
);
|
||||
|
||||
export function findTranscriptWriterBoundaryViolations(
|
||||
_content: string,
|
||||
_fileName: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
) {
|
||||
return findNamedBoundaryViolations(
|
||||
sourceFile,
|
||||
legacyTranscriptWriterNames,
|
||||
"legacy transcript writer",
|
||||
);
|
||||
}
|
||||
export const findTranscriptWriterBoundaryViolations = namedBoundaryRule(
|
||||
legacyTranscriptWriterNames,
|
||||
"legacy transcript writer",
|
||||
);
|
||||
|
||||
export function findGatewaySessionCreateLifecycleViolations(
|
||||
_content: string,
|
||||
|
|
@ -598,29 +572,15 @@ export function findGatewaySessionCreateLifecycleViolations(
|
|||
return violations;
|
||||
}
|
||||
|
||||
export function findSessionCompactManualTrimBoundaryViolations(
|
||||
_content: string,
|
||||
_fileName: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
) {
|
||||
return findNamedSessionStoreViolations(
|
||||
sourceFile,
|
||||
legacyManualCompactTrimNames,
|
||||
"manual compact trim",
|
||||
);
|
||||
}
|
||||
export const findSessionCompactManualTrimBoundaryViolations = namedBoundaryRule(
|
||||
legacyManualCompactTrimNames,
|
||||
"legacy session store manual compact trim",
|
||||
);
|
||||
|
||||
export function findSessionLifecycleCleanupBoundaryViolations(
|
||||
_content: string,
|
||||
_fileName: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
) {
|
||||
return findNamedSessionStoreViolations(
|
||||
sourceFile,
|
||||
legacyLifecycleCleanupNames,
|
||||
"lifecycle cleanup",
|
||||
);
|
||||
}
|
||||
export const findSessionLifecycleCleanupBoundaryViolations = namedBoundaryRule(
|
||||
legacyLifecycleCleanupNames,
|
||||
"legacy session store lifecycle cleanup",
|
||||
);
|
||||
|
||||
// Source roots shared by the enforced boundary checks in main() and the debt
|
||||
// ratchet below; keeping one list prevents the two scans from drifting apart.
|
||||
|
|
|
|||
|
|
@ -216,31 +216,22 @@ export function findSessionTranscriptReaderBoundaryViolations(
|
|||
}
|
||||
}
|
||||
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
||||
const receiver = unwrapExpression(node.expression);
|
||||
const name = ts.isPropertyAccessExpression(node)
|
||||
? node.name
|
||||
: ts.isStringLiteral(node.argumentExpression)
|
||||
? node.argumentExpression
|
||||
: undefined;
|
||||
if (
|
||||
ts.isIdentifier(receiver) &&
|
||||
legacyNamespaces.has(receiver.text) &&
|
||||
transcriptReaderNames.has(node.name.text)
|
||||
name &&
|
||||
transcriptReaderNames.has(name.text)
|
||||
) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.name),
|
||||
reason: `references legacy transcript reader "${node.name.text}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isElementAccessExpression(node)) {
|
||||
const receiver = unwrapExpression(node.expression);
|
||||
if (
|
||||
ts.isIdentifier(receiver) &&
|
||||
legacyNamespaces.has(receiver.text) &&
|
||||
ts.isStringLiteral(node.argumentExpression) &&
|
||||
transcriptReaderNames.has(node.argumentExpression.text)
|
||||
) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node.argumentExpression),
|
||||
reason: `references legacy transcript reader "${node.argumentExpression.text}"`,
|
||||
line: toLine(sourceFile, name),
|
||||
reason: `references legacy transcript reader "${name.text}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
#!/bin/bash
|
||||
# Claude Code Authentication Status Checker
|
||||
# Checks both Claude Code and OpenClaw auth status
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CLAUDE_CREDS="$HOME/.claude/.credentials.json"
|
||||
OPENCLAW_AUTH="$HOME/.openclaw/agents/main/agent/auth-profiles.json"
|
||||
|
||||
# Colors for terminal output
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -16,11 +12,7 @@ NC='\033[0m' # No Color
|
|||
# Output mode: "full" (default), "json", or "simple"
|
||||
OUTPUT_MODE="${1:-full}"
|
||||
|
||||
fetch_models_status_json() {
|
||||
openclaw models status --json 2>/dev/null || true
|
||||
}
|
||||
|
||||
STATUS_JSON="$(fetch_models_status_json)"
|
||||
STATUS_JSON="$(openclaw models status --json 2>/dev/null || true)"
|
||||
USE_JSON=0
|
||||
if [ -n "$STATUS_JSON" ]; then
|
||||
USE_JSON=1
|
||||
|
|
@ -56,6 +48,27 @@ format_epoch_seconds() {
|
|||
date -r "$epoch_seconds" 2>/dev/null || date -d "@$epoch_seconds"
|
||||
}
|
||||
|
||||
print_full_expiry() {
|
||||
local expires_at="$1" expired_hint="$2" expiring_hint="${3:-}"
|
||||
local now_ms=$(( $(date +%s) * 1000 ))
|
||||
local diff_ms=$((expires_at - now_ms))
|
||||
local hours=$((diff_ms / 3600000))
|
||||
local mins=$(((diff_ms % 3600000) / 60000))
|
||||
|
||||
if [ "$diff_ms" -lt 0 ]; then
|
||||
echo -e " Status: ${RED}EXPIRED${NC}"
|
||||
echo "$expired_hint"
|
||||
elif [ "$diff_ms" -lt 3600000 ]; then
|
||||
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
|
||||
if [ -n "$expiring_hint" ]; then
|
||||
echo "$expiring_hint"
|
||||
fi
|
||||
else
|
||||
echo -e " Status: ${GREEN}OK${NC}"
|
||||
echo " Expires: $(format_epoch_seconds "$((expires_at/1000))") (${hours}h ${mins}m)"
|
||||
fi
|
||||
}
|
||||
|
||||
json_expires_for_claude_cli() {
|
||||
echo "$STATUS_JSON" | jq -r '
|
||||
[.auth.oauth.profiles[]
|
||||
|
|
@ -91,57 +104,44 @@ json_anthropic_api_key_count() {
|
|||
}
|
||||
|
||||
check_claude_code_auth() {
|
||||
local expires_at
|
||||
if [ "$USE_JSON" -eq 1 ]; then
|
||||
local expires_at
|
||||
expires_at=$(json_expires_for_claude_cli)
|
||||
calc_status_from_expires "$expires_at"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ ! -f "$CLAUDE_CREDS" ]; then
|
||||
elif [ -f "$CLAUDE_CREDS" ]; then
|
||||
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
|
||||
else
|
||||
echo "MISSING"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local expires_at
|
||||
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
|
||||
calc_status_from_expires "$expires_at"
|
||||
}
|
||||
|
||||
check_openclaw_auth() {
|
||||
local expires_at
|
||||
if [ "$USE_JSON" -eq 1 ]; then
|
||||
local api_keys
|
||||
api_keys=$(json_anthropic_api_key_count)
|
||||
if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then
|
||||
api_keys=0
|
||||
fi
|
||||
local expires_at
|
||||
expires_at=$(json_expires_for_anthropic_any)
|
||||
|
||||
if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then
|
||||
echo "OK:static"
|
||||
return 0
|
||||
fi
|
||||
|
||||
calc_status_from_expires "$expires_at"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ ! -f "$OPENCLAW_AUTH" ]; then
|
||||
elif [ -f "$OPENCLAW_AUTH" ]; then
|
||||
expires_at=$(jq -r '
|
||||
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
|
||||
| max // 0
|
||||
' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
|
||||
else
|
||||
echo "MISSING"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local expires
|
||||
expires=$(jq -r '
|
||||
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
|
||||
| max // 0
|
||||
' "$OPENCLAW_AUTH" 2>/dev/null || echo "0")
|
||||
|
||||
calc_status_from_expires "$expires"
|
||||
calc_status_from_expires "$expires_at"
|
||||
}
|
||||
|
||||
# JSON output mode
|
||||
if [ "$OUTPUT_MODE" = "json" ]; then
|
||||
claude_status=$(check_claude_code_auth 2>/dev/null || true)
|
||||
openclaw_status=$(check_openclaw_auth 2>/dev/null || true)
|
||||
|
|
@ -169,7 +169,6 @@ if [ "$OUTPUT_MODE" = "json" ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Simple output mode (for scripts/widgets)
|
||||
if [ "$OUTPUT_MODE" = "simple" ]; then
|
||||
claude_status=$(check_claude_code_auth 2>/dev/null || true)
|
||||
openclaw_status=$(check_openclaw_auth 2>/dev/null || true)
|
||||
|
|
@ -192,11 +191,9 @@ if [ "$OUTPUT_MODE" = "simple" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Full output mode (default)
|
||||
echo "=== Claude Code Auth Status ==="
|
||||
echo ""
|
||||
|
||||
# Claude Code credentials
|
||||
echo "Claude Code (~/.claude/.credentials.json):"
|
||||
if [ "$USE_JSON" -eq 1 ]; then
|
||||
expires_at=$(json_expires_for_claude_cli)
|
||||
|
|
@ -215,21 +212,9 @@ if [ "$expires_at" -le 0 ]; then
|
|||
echo -e " Status: ${RED}NOT FOUND${NC}"
|
||||
echo " Action needed: Run 'claude setup-token'"
|
||||
else
|
||||
now_ms=$(( $(date +%s) * 1000 ))
|
||||
diff_ms=$((expires_at - now_ms))
|
||||
hours=$((diff_ms / 3600000))
|
||||
mins=$(((diff_ms % 3600000) / 60000))
|
||||
|
||||
if [ "$diff_ms" -lt 0 ]; then
|
||||
echo -e " Status: ${RED}EXPIRED${NC}"
|
||||
echo " Action needed: Run 'claude setup-token' or re-authenticate"
|
||||
elif [ "$diff_ms" -lt 3600000 ]; then
|
||||
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
|
||||
echo " Consider running: claude setup-token"
|
||||
else
|
||||
echo -e " Status: ${GREEN}OK${NC}"
|
||||
echo " Expires: $(format_epoch_seconds "$((expires_at/1000))") (${hours}h ${mins}m)"
|
||||
fi
|
||||
print_full_expiry "$expires_at" \
|
||||
" Action needed: Run 'claude setup-token' or re-authenticate" \
|
||||
" Consider running: claude setup-token"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
|
@ -260,20 +245,7 @@ elif [ "$expires" -le 0 ]; then
|
|||
echo -e " Status: ${RED}NOT FOUND${NC}"
|
||||
echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
|
||||
else
|
||||
now_ms=$(( $(date +%s) * 1000 ))
|
||||
diff_ms=$((expires - now_ms))
|
||||
hours=$((diff_ms / 3600000))
|
||||
mins=$(((diff_ms % 3600000) / 60000))
|
||||
|
||||
if [ "$diff_ms" -lt 0 ]; then
|
||||
echo -e " Status: ${RED}EXPIRED${NC}"
|
||||
echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
|
||||
elif [ "$diff_ms" -lt 3600000 ]; then
|
||||
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
|
||||
else
|
||||
echo -e " Status: ${GREEN}OK${NC}"
|
||||
echo " Expires: $(format_epoch_seconds "$((expires/1000))") (${hours}h ${mins}m)"
|
||||
fi
|
||||
print_full_expiry "$expires" " Note: Run 'openclaw doctor --yes' to sync from Claude Code"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -199,19 +199,20 @@ function managedImageUrl(fileName: string): string {
|
|||
|
||||
export function buildChatAttachmentHistory(baseTime: number): unknown[] {
|
||||
const assets = getChatAttachmentAssets();
|
||||
const assetSize = (fileName: string): number => {
|
||||
const fixtureAsset = (fileName: string): FixtureAsset => {
|
||||
const asset = assets[fileName];
|
||||
if (!asset) {
|
||||
throw new Error(`Missing chat attachment fixture asset: ${fileName}`);
|
||||
}
|
||||
return asset.body.byteLength;
|
||||
return asset;
|
||||
};
|
||||
const documentAttachment = (fileName: string, mimeType: string) => ({
|
||||
const assetSize = (fileName: string) => fixtureAsset(fileName).body.byteLength;
|
||||
const documentAttachment = (fileName: string) => ({
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
kind: "document",
|
||||
label: fileName,
|
||||
mimeType,
|
||||
mimeType: fixtureAsset(fileName).contentType,
|
||||
url: fixtureUrl(fileName),
|
||||
sizeBytes: assetSize(fileName),
|
||||
},
|
||||
|
|
@ -251,57 +252,42 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
|
|||
],
|
||||
timestamp: baseTime,
|
||||
},
|
||||
textMessage("Documents", baseTime + 1),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
documentAttachment("notes.md", "text/markdown"),
|
||||
documentAttachment("notes.txt", "text/plain"),
|
||||
documentAttachment("styles.css", "text/css"),
|
||||
documentAttachment("settings.json", "application/json"),
|
||||
documentAttachment("script.js", "text/javascript"),
|
||||
documentAttachment("brief.pdf", "application/pdf"),
|
||||
documentAttachment(
|
||||
...[
|
||||
{
|
||||
title: "Documents",
|
||||
files: [
|
||||
"notes.md",
|
||||
"notes.txt",
|
||||
"styles.css",
|
||||
"settings.json",
|
||||
"script.js",
|
||||
"brief.pdf",
|
||||
"brief.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
],
|
||||
timestamp: baseTime + 2,
|
||||
},
|
||||
textMessage("File icon families", baseTime + 3),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
documentAttachment("mystery.blob", "application/octet-stream"),
|
||||
documentAttachment("sample-image.png", "image/png"),
|
||||
documentAttachment("config.xml", "application/xml"),
|
||||
documentAttachment("deploy.yaml", "application/yaml"),
|
||||
documentAttachment("worker.py", "text/x-python"),
|
||||
documentAttachment("vector.svg", "image/svg+xml"),
|
||||
documentAttachment("broken-vector.svg", "image/svg+xml"),
|
||||
documentAttachment("readme.rtf", "application/rtf"),
|
||||
],
|
||||
timestamp: baseTime + 4,
|
||||
},
|
||||
textMessage("HTML", baseTime + 5),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [documentAttachment("preview.html", "text/html")],
|
||||
timestamp: baseTime + 6,
|
||||
},
|
||||
textMessage("CSV / XLSX", baseTime + 7),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
documentAttachment("rows.csv", "text/csv"),
|
||||
documentAttachment("wide.csv", "text/csv"),
|
||||
documentAttachment(
|
||||
"report.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
],
|
||||
timestamp: baseTime + 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "File icon families",
|
||||
files: [
|
||||
"mystery.blob",
|
||||
"sample-image.png",
|
||||
"config.xml",
|
||||
"deploy.yaml",
|
||||
"worker.py",
|
||||
"vector.svg",
|
||||
"broken-vector.svg",
|
||||
"readme.rtf",
|
||||
],
|
||||
},
|
||||
{ title: "HTML", files: ["preview.html"] },
|
||||
{ title: "CSV / XLSX", files: ["rows.csv", "wide.csv", "report.xlsx"] },
|
||||
].flatMap(({ title, files }, index) => [
|
||||
textMessage(title, baseTime + index * 2 + 1),
|
||||
{
|
||||
role: "assistant",
|
||||
content: files.map(documentAttachment),
|
||||
timestamp: baseTime + index * 2 + 2,
|
||||
},
|
||||
]),
|
||||
textMessage("Before — current generic delivery cards", baseTime + 9),
|
||||
{
|
||||
role: "assistant",
|
||||
|
|
@ -310,12 +296,12 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
|
|||
type: "text",
|
||||
text: "Current WebChat delivery removes inline playback from every media file.",
|
||||
},
|
||||
documentAttachment("voice---a75c70c7-0112-4d07-8fb5-40c82c979ee8.mp3", "audio/mpeg"),
|
||||
documentAttachment("reply.ogg", "audio/ogg"),
|
||||
documentAttachment("reply.m4a", "audio/x-m4a"),
|
||||
documentAttachment("reply.flac", "audio/flac"),
|
||||
documentAttachment("sample-video.mp4", "video/mp4"),
|
||||
documentAttachment("sample-video.webm", "video/webm"),
|
||||
documentAttachment("voice---a75c70c7-0112-4d07-8fb5-40c82c979ee8.mp3"),
|
||||
documentAttachment("reply.ogg"),
|
||||
documentAttachment("reply.m4a"),
|
||||
documentAttachment("reply.flac"),
|
||||
documentAttachment("sample-video.mp4"),
|
||||
documentAttachment("sample-video.webm"),
|
||||
],
|
||||
timestamp: baseTime + 10,
|
||||
},
|
||||
|
|
@ -369,41 +355,23 @@ export function buildChatAttachmentHistory(baseTime: number): unknown[] {
|
|||
textMessage("Archive", baseTime + 13),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [documentAttachment("bundle.zip", "application/zip")],
|
||||
content: [documentAttachment("bundle.zip")],
|
||||
timestamp: baseTime + 14,
|
||||
},
|
||||
textMessage("Unavailable / failed / removed", baseTime + 15),
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ label: "temporarily-unavailable.pdf", mimeType: "application/pdf" },
|
||||
{ label: "download-failed.zip", mimeType: "application/zip" },
|
||||
{
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
kind: "document",
|
||||
label: "temporarily-unavailable.pdf",
|
||||
mimeType: "application/pdf",
|
||||
url: fixtureUrl("temporarily-unavailable.pdf"),
|
||||
},
|
||||
label: "removed-file.docx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
},
|
||||
{
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
kind: "document",
|
||||
label: "download-failed.zip",
|
||||
mimeType: "application/zip",
|
||||
url: fixtureUrl("download-failed.zip"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
kind: "document",
|
||||
label: "removed-file.docx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
url: fixtureUrl("removed-file.docx"),
|
||||
},
|
||||
},
|
||||
],
|
||||
].map(({ label, mimeType }) => ({
|
||||
type: "attachment",
|
||||
attachment: { kind: "document", label, mimeType, url: fixtureUrl(label) },
|
||||
})),
|
||||
timestamp: baseTime + 16,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -303,36 +303,17 @@ export function buildCronMocks(
|
|||
),
|
||||
};
|
||||
const runByJobId = new Map(runs.map((entry) => [entry.jobId, entry]));
|
||||
const sortedJobLists = [
|
||||
{
|
||||
match: { sortBy: "nextRunAtMs", sortDir: "asc" },
|
||||
jobs: jobs.toSorted(
|
||||
(left, right) => (left.state?.nextRunAtMs ?? 0) - (right.state?.nextRunAtMs ?? 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
match: { sortBy: "nextRunAtMs", sortDir: "desc" },
|
||||
jobs: jobs.toSorted(
|
||||
(left, right) => (right.state?.nextRunAtMs ?? 0) - (left.state?.nextRunAtMs ?? 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
match: { sortBy: "updatedAtMs", sortDir: "asc" },
|
||||
jobs: jobs.toSorted((left, right) => (left.updatedAtMs ?? 0) - (right.updatedAtMs ?? 0)),
|
||||
},
|
||||
{
|
||||
match: { sortBy: "updatedAtMs", sortDir: "desc" },
|
||||
jobs: jobs.toSorted((left, right) => (right.updatedAtMs ?? 0) - (left.updatedAtMs ?? 0)),
|
||||
},
|
||||
{
|
||||
match: { sortBy: "name", sortDir: "asc" },
|
||||
jobs: jobs.toSorted((left, right) => left.name.localeCompare(right.name)),
|
||||
},
|
||||
{
|
||||
match: { sortBy: "name", sortDir: "desc" },
|
||||
jobs: jobs.toSorted((left, right) => right.name.localeCompare(left.name)),
|
||||
},
|
||||
];
|
||||
const sortComparators: Record<string, (left: CronJob, right: CronJob) => number> = {
|
||||
nextRunAtMs: (left, right) => (left.state?.nextRunAtMs ?? 0) - (right.state?.nextRunAtMs ?? 0),
|
||||
updatedAtMs: (left, right) => (left.updatedAtMs ?? 0) - (right.updatedAtMs ?? 0),
|
||||
name: (left, right) => left.name.localeCompare(right.name),
|
||||
};
|
||||
const sortedJobLists = Object.entries(sortComparators).flatMap(([sortBy, compare]) =>
|
||||
["asc", "desc"].map((sortDir) => ({
|
||||
match: { sortBy, sortDir },
|
||||
jobs: jobs.toSorted(sortDir === "asc" ? compare : (left, right) => compare(right, left)),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
"cron.status": status,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Control Ui Mock Dev script supports OpenClaw repository automation.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs, { rmSync } from "node:fs";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
|
|
@ -74,37 +73,32 @@ import {
|
|||
} from "./control-ui-mock-skill-workshop.js";
|
||||
import { buildProfileUsageMocks } from "./control-ui-mock-usage.ts";
|
||||
|
||||
const FIXTURES = [
|
||||
"approval",
|
||||
"attachments",
|
||||
"avatars",
|
||||
"board",
|
||||
"code-fences",
|
||||
"dashboards",
|
||||
"goal",
|
||||
"plugins-dense",
|
||||
"sidebar-roster",
|
||||
"swarm",
|
||||
"update-available",
|
||||
"update-blocked",
|
||||
"update-failed",
|
||||
"workboard",
|
||||
"workboard-states",
|
||||
] as const;
|
||||
|
||||
type CliOptions = {
|
||||
allowedHosts: string[];
|
||||
fixture?:
|
||||
| "approval"
|
||||
| "attachments"
|
||||
| "avatars"
|
||||
| "board"
|
||||
| "code-fences"
|
||||
| "dashboards"
|
||||
| "goal"
|
||||
| "plugins-dense"
|
||||
| "sidebar-roster"
|
||||
| "swarm"
|
||||
| "update-available"
|
||||
| "update-blocked"
|
||||
| "update-failed"
|
||||
| "workboard"
|
||||
| "workboard-states";
|
||||
fixture?: (typeof FIXTURES)[number];
|
||||
host: string;
|
||||
operatorScopes?: string[];
|
||||
port: number;
|
||||
};
|
||||
|
||||
type SessionListOptions = {
|
||||
owners?: readonly SessionActorFixture[];
|
||||
hasMore: boolean;
|
||||
nextOffset: number | null;
|
||||
offset?: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
type SessionActorFixture = { type: "human" | "agent"; id: string; label: string };
|
||||
|
||||
const MOCK_ACTOR_PETER: SessionActorFixture = {
|
||||
|
|
@ -398,26 +392,11 @@ function parseFixture(value: string | undefined): CliOptions["fixture"] {
|
|||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
value !== "approval" &&
|
||||
value !== "attachments" &&
|
||||
value !== "avatars" &&
|
||||
value !== "board" &&
|
||||
value !== "code-fences" &&
|
||||
value !== "dashboards" &&
|
||||
value !== "goal" &&
|
||||
value !== "plugins-dense" &&
|
||||
value !== "sidebar-roster" &&
|
||||
value !== "swarm" &&
|
||||
value !== "update-available" &&
|
||||
value !== "update-blocked" &&
|
||||
value !== "update-failed" &&
|
||||
value !== "workboard" &&
|
||||
value !== "workboard-states"
|
||||
) {
|
||||
const fixture = FIXTURES.find((candidate) => candidate === value);
|
||||
if (!fixture) {
|
||||
throw new Error(`Unknown Control UI mock fixture: ${value}`);
|
||||
}
|
||||
return value;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number): number {
|
||||
|
|
@ -433,27 +412,6 @@ function parseOperatorScopes(value: string | undefined): string[] | undefined {
|
|||
return scopes.length > 0 ? scopes : undefined;
|
||||
}
|
||||
|
||||
function sessionsListResponse(sessions: Array<{ key: string }>, options: SessionListOptions) {
|
||||
return {
|
||||
count: sessions.length,
|
||||
defaults: {
|
||||
contextTokens: 200_000,
|
||||
model: "gpt-5-mini",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
hasMore: options.hasMore,
|
||||
limitApplied: 50,
|
||||
nextOffset: options.nextOffset,
|
||||
...(options.owners ? { owners: options.owners } : {}),
|
||||
offset: options.offset ?? 0,
|
||||
path: "",
|
||||
// Cases select membership; canonical metadata comes from the scenario's rows.
|
||||
sessions: sessions.map(({ key }) => ({ key })),
|
||||
totalCount: options.totalCount,
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function pagedSessionsListResponse(
|
||||
sessions: Array<{ key: string }>,
|
||||
offset: number,
|
||||
|
|
@ -462,13 +420,24 @@ function pagedSessionsListResponse(
|
|||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
||||
const page = sessions.slice(normalizedOffset, normalizedOffset + SESSION_PAGE_SIZE);
|
||||
const nextOffset = normalizedOffset + SESSION_PAGE_SIZE;
|
||||
return sessionsListResponse(page, {
|
||||
owners,
|
||||
return {
|
||||
count: page.length,
|
||||
defaults: {
|
||||
contextTokens: 200_000,
|
||||
model: "gpt-5-mini",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
hasMore: nextOffset < sessions.length,
|
||||
limitApplied: 50,
|
||||
nextOffset: nextOffset < sessions.length ? nextOffset : null,
|
||||
...(owners ? { owners } : {}),
|
||||
offset: normalizedOffset,
|
||||
path: "",
|
||||
// Cases select membership; canonical metadata comes from the scenario's rows.
|
||||
sessions: page.map(({ key }) => ({ key })),
|
||||
totalCount: sessions.length,
|
||||
});
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionRows(params: {
|
||||
|
|
@ -636,6 +605,124 @@ function buildSessionDiffMock() {
|
|||
};
|
||||
}
|
||||
|
||||
function buildSessionCatalogMocks(baseTime: number) {
|
||||
const catalogs = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: false, startTerminal: true },
|
||||
sessions: [
|
||||
{
|
||||
threadId: "codex-thread-1",
|
||||
name: "Release checklist sweep",
|
||||
cwd: "/Users/demo/projects/openclaw",
|
||||
updatedAt: baseTime - 10 * 60_000,
|
||||
items: [
|
||||
{
|
||||
id: "release-checklist-answer",
|
||||
type: "agentMessage",
|
||||
text: "The release checklist is complete and ready for review.",
|
||||
},
|
||||
{
|
||||
id: "release-checklist-request",
|
||||
type: "userMessage",
|
||||
text: "Please sweep the release checklist for anything we missed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: "codex-thread-2",
|
||||
name: "Sidebar context-menu proof",
|
||||
cwd: "/Users/demo/projects/openclaw",
|
||||
updatedAt: baseTime - 45 * 60_000,
|
||||
items: [
|
||||
{
|
||||
id: "sidebar-context-menu-answer",
|
||||
type: "agentMessage",
|
||||
text: "The sidebar context menu behaves as expected.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
capabilities: { continueSession: true, archive: false },
|
||||
sessions: [
|
||||
{
|
||||
threadId: "claude-thread-1",
|
||||
name: "Docs refresh",
|
||||
cwd: "/Users/demo/projects/peekaboo",
|
||||
updatedAt: baseTime - 30 * 60_000,
|
||||
items: [
|
||||
{
|
||||
id: "docs-refresh-answer",
|
||||
type: "agentMessage",
|
||||
text: "The documentation refresh is ready for review.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return {
|
||||
"sessions.catalog.list": {
|
||||
catalogs: catalogs.map(({ id, label, capabilities, sessions }) => ({
|
||||
id,
|
||||
label,
|
||||
capabilities,
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway",
|
||||
label: "This Mac",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: sessions.map(({ threadId, name, cwd, updatedAt }) => ({
|
||||
threadId,
|
||||
name,
|
||||
cwd,
|
||||
status: "idle",
|
||||
updatedAt,
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
"sessions.catalog.read": {
|
||||
cases: catalogs.flatMap(({ id, sessions }) =>
|
||||
sessions.map(({ threadId, items }) => ({
|
||||
match: { catalogId: id, hostId: "gateway", threadId },
|
||||
response: { hostId: "gateway", threadId, items },
|
||||
})),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function directoryListCase(
|
||||
directory: string,
|
||||
names: string[],
|
||||
match: Record<string, unknown> = { path: directory },
|
||||
) {
|
||||
return {
|
||||
match,
|
||||
response: {
|
||||
path: directory,
|
||||
parent: path.posix.dirname(directory),
|
||||
home: "/Users/demo",
|
||||
entries: names.map((name) => ({
|
||||
name,
|
||||
path: `${directory}/${name}`,
|
||||
...(name.startsWith(".") ? { hidden: true } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelProviderMocks(baseTime: number) {
|
||||
const hour = 60 * 60 * 1000;
|
||||
const expiry = (remainingMs: number, label: string) => ({
|
||||
|
|
@ -2453,125 +2540,7 @@ async function createChatPickerScenario(
|
|||
// right-click menu, hide/restore preference) are exercised in the mock.
|
||||
// Ids must match registered plugin catalogs (`claude`, `codex`) or the
|
||||
// sidebar cannot resolve bundled brand marks.
|
||||
"sessions.catalog.list": {
|
||||
catalogs: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
capabilities: { continueSession: true, archive: false, startTerminal: true },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway",
|
||||
label: "This Mac",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "codex-thread-1",
|
||||
name: "Release checklist sweep",
|
||||
cwd: "/Users/demo/projects/openclaw",
|
||||
status: "idle",
|
||||
updatedAt: baseTime - 10 * 60_000,
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
{
|
||||
threadId: "codex-thread-2",
|
||||
name: "Sidebar context-menu proof",
|
||||
cwd: "/Users/demo/projects/openclaw",
|
||||
status: "idle",
|
||||
updatedAt: baseTime - 45 * 60_000,
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
capabilities: { continueSession: true, archive: false },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway",
|
||||
label: "This Mac",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "claude-thread-1",
|
||||
name: "Docs refresh",
|
||||
cwd: "/Users/demo/projects/peekaboo",
|
||||
status: "idle",
|
||||
updatedAt: baseTime - 30 * 60_000,
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"sessions.catalog.read": {
|
||||
cases: [
|
||||
{
|
||||
match: { catalogId: "codex", hostId: "gateway", threadId: "codex-thread-1" },
|
||||
response: {
|
||||
hostId: "gateway",
|
||||
threadId: "codex-thread-1",
|
||||
items: [
|
||||
{
|
||||
id: "release-checklist-answer",
|
||||
type: "agentMessage",
|
||||
text: "The release checklist is complete and ready for review.",
|
||||
},
|
||||
{
|
||||
id: "release-checklist-request",
|
||||
type: "userMessage",
|
||||
text: "Please sweep the release checklist for anything we missed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match: { catalogId: "codex", hostId: "gateway", threadId: "codex-thread-2" },
|
||||
response: {
|
||||
hostId: "gateway",
|
||||
threadId: "codex-thread-2",
|
||||
items: [
|
||||
{
|
||||
id: "sidebar-context-menu-answer",
|
||||
type: "agentMessage",
|
||||
text: "The sidebar context menu behaves as expected.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
catalogId: "claude",
|
||||
hostId: "gateway",
|
||||
threadId: "claude-thread-1",
|
||||
},
|
||||
response: {
|
||||
hostId: "gateway",
|
||||
threadId: "claude-thread-1",
|
||||
items: [
|
||||
{
|
||||
id: "docs-refresh-answer",
|
||||
type: "agentMessage",
|
||||
text: "The documentation refresh is ready for review.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...buildSessionCatalogMocks(baseTime),
|
||||
"system.info": {
|
||||
machineName: "Mock-Workstation",
|
||||
hostname: "mock-workstation.invalid",
|
||||
|
|
@ -2596,78 +2565,33 @@ async function createChatPickerScenario(
|
|||
},
|
||||
"fs.listDir": {
|
||||
cases: [
|
||||
{
|
||||
match: { path: "/Users/demo/Projects/openclaw" },
|
||||
response: {
|
||||
path: "/Users/demo/Projects/openclaw",
|
||||
parent: "/Users/demo/Projects",
|
||||
home: "/Users/demo",
|
||||
entries: [
|
||||
{ name: "ui", path: "/Users/demo/Projects/openclaw/ui" },
|
||||
{ name: "src", path: "/Users/demo/Projects/openclaw/src" },
|
||||
{ name: "docs", path: "/Users/demo/Projects/openclaw/docs" },
|
||||
{ name: "packages", path: "/Users/demo/Projects/openclaw/packages" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match: { path: "/Users/demo/Projects" },
|
||||
response: {
|
||||
path: "/Users/demo/Projects",
|
||||
parent: "/Users/demo",
|
||||
home: "/Users/demo",
|
||||
entries: [
|
||||
{ name: "openclaw", path: "/Users/demo/Projects/openclaw" },
|
||||
{ name: "clawdbot", path: "/Users/demo/Projects/clawdbot" },
|
||||
{ name: "sweetistics", path: "/Users/demo/Projects/sweetistics" },
|
||||
{ name: "Peekaboo", path: "/Users/demo/Projects/Peekaboo" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {},
|
||||
response: {
|
||||
path: "/Users/demo",
|
||||
parent: "/Users",
|
||||
home: "/Users/demo",
|
||||
entries: [
|
||||
{ name: "Projects", path: "/Users/demo/Projects" },
|
||||
{ name: "Downloads", path: "/Users/demo/Downloads" },
|
||||
{ name: ".config", path: "/Users/demo/.config", hidden: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
directoryListCase("/Users/demo/Projects/openclaw", ["ui", "src", "docs", "packages"]),
|
||||
directoryListCase("/Users/demo/Projects", [
|
||||
"openclaw",
|
||||
"clawdbot",
|
||||
"sweetistics",
|
||||
"Peekaboo",
|
||||
]),
|
||||
directoryListCase("/Users/demo", ["Projects", "Downloads", ".config"], {}),
|
||||
],
|
||||
},
|
||||
"worktrees.branches": {
|
||||
cases: [
|
||||
{
|
||||
match: { repoRoot: "/Users/demo/Projects/openclaw" },
|
||||
response: {
|
||||
repoRoot: "/Users/demo/Projects/openclaw",
|
||||
branches: [
|
||||
{ kind: "local", name: "main" },
|
||||
{ kind: "local", name: "steipete/place-picker" },
|
||||
],
|
||||
repositoryStatus: "git",
|
||||
defaultBranch: "main",
|
||||
headBranch: "main",
|
||||
},
|
||||
{ repoRoot: "/Users/demo/Projects/openclaw", branch: "steipete/place-picker" },
|
||||
{ repoRoot: "/Users/demo/Projects/clawdbot", branch: "steipete/storage-selector-design" },
|
||||
].map(({ repoRoot: repositoryRoot, branch }) => ({
|
||||
match: { repoRoot: repositoryRoot },
|
||||
response: {
|
||||
repoRoot: repositoryRoot,
|
||||
branches: [
|
||||
{ kind: "local", name: "main" },
|
||||
{ kind: "local", name: branch },
|
||||
],
|
||||
repositoryStatus: "git",
|
||||
defaultBranch: "main",
|
||||
headBranch: "main",
|
||||
},
|
||||
{
|
||||
match: { repoRoot: "/Users/demo/Projects/clawdbot" },
|
||||
response: {
|
||||
repoRoot: "/Users/demo/Projects/clawdbot",
|
||||
branches: [
|
||||
{ kind: "local", name: "main" },
|
||||
{ kind: "local", name: "steipete/storage-selector-design" },
|
||||
],
|
||||
repositoryStatus: "git",
|
||||
defaultBranch: "main",
|
||||
headBranch: "main",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
"environments.list": {
|
||||
environments: [
|
||||
|
|
|
|||
|
|
@ -260,25 +260,23 @@ export function buildPluginCatalogMock(options: PluginCatalogMockOptions = {}) {
|
|||
...(params.hasIcon ? { hasIcon: true } : {}),
|
||||
...(params.install ? { install: params.install } : {}),
|
||||
});
|
||||
const bundledChannel = (id: string, name: string, description: string) =>
|
||||
entry({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
});
|
||||
const plugins = [
|
||||
entry({
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
description: "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
description: "OpenClaw Telegram channel plugin.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
bundledChannel(
|
||||
"whatsapp",
|
||||
"WhatsApp",
|
||||
"OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
|
||||
),
|
||||
bundledChannel("telegram", "Telegram", "OpenClaw Telegram channel plugin."),
|
||||
entry({
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
|
|
@ -290,51 +288,27 @@ export function buildPluginCatalogMock(options: PluginCatalogMockOptions = {}) {
|
|||
enabled: false,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "OpenClaw Google Chat channel plugin for spaces and direct messages.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "signal",
|
||||
name: "Signal",
|
||||
description: "OpenClaw Signal channel plugin.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "imessage",
|
||||
name: "iMessage",
|
||||
description: "OpenClaw iMessage channel plugin using imsg on a signed-in Mac.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
entry({
|
||||
id: "nostr",
|
||||
name: "Nostr",
|
||||
description: "OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
|
||||
category: "channel",
|
||||
origin: "bundled",
|
||||
installed: true,
|
||||
hasIcon: true,
|
||||
}),
|
||||
bundledChannel(
|
||||
"googlechat",
|
||||
"Google Chat",
|
||||
"OpenClaw Google Chat channel plugin for spaces and direct messages.",
|
||||
),
|
||||
bundledChannel(
|
||||
"slack",
|
||||
"Slack",
|
||||
"OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
|
||||
),
|
||||
bundledChannel("signal", "Signal", "OpenClaw Signal channel plugin."),
|
||||
bundledChannel(
|
||||
"imessage",
|
||||
"iMessage",
|
||||
"OpenClaw iMessage channel plugin using imsg on a signed-in Mac.",
|
||||
),
|
||||
bundledChannel(
|
||||
"nostr",
|
||||
"Nostr",
|
||||
"OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages.",
|
||||
),
|
||||
entry({
|
||||
id: "memory-wiki",
|
||||
name: "Memory Wiki",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { spawn, type SpawnOptions } from "node:child_process";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
|
||||
|
||||
const KNIP_VERSION = "6.32.2";
|
||||
|
|
@ -146,6 +147,8 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
|
|||
let bufferExceeded = false;
|
||||
let outputBytes = 0;
|
||||
const output: string[] = [];
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let exitStatus: number | null = null;
|
||||
let exitSignal: string | null = null;
|
||||
|
|
@ -212,6 +215,10 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
|
|||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(killTimer);
|
||||
cleanupParentSignalHandlers();
|
||||
// A clipped final code point is not malformed child output; leave it out at the byte cap.
|
||||
if (!bufferExceeded) {
|
||||
output.push(stdoutDecoder.end(), stderrDecoder.end());
|
||||
}
|
||||
resolve({ ...result, output: output.join("") });
|
||||
};
|
||||
const finishAfterProcessTreeCleanup = async (result: Omit<KnipRunResult, "output">) => {
|
||||
|
|
@ -225,27 +232,27 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
|
|||
finish(result);
|
||||
};
|
||||
|
||||
const appendOutput = (chunk: string | Uint8Array) => {
|
||||
const appendOutput = (decoder: StringDecoder, chunk: string | Uint8Array) => {
|
||||
if (settled || bufferExceeded) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const remainingBytes = maxBufferBytes - outputBytes;
|
||||
if (buffer.length <= remainingBytes) {
|
||||
output.push(buffer.toString("utf8"));
|
||||
output.push(decoder.write(buffer));
|
||||
outputBytes += buffer.length;
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
|
||||
output.push(decoder.write(buffer.subarray(0, remainingBytes)));
|
||||
outputBytes = maxBufferBytes;
|
||||
}
|
||||
bufferExceeded = true;
|
||||
writeStatus(
|
||||
`[deadcode] Knip ${scanName} exceeded ${maxBufferBytes} output bytes; terminating.`,
|
||||
);
|
||||
child.stdout?.off?.("data", appendOutput);
|
||||
child.stderr?.off?.("data", appendOutput);
|
||||
child.stdout?.off?.("data", appendStdout);
|
||||
child.stderr?.off?.("data", appendStderr);
|
||||
child.stdout?.destroy?.();
|
||||
child.stderr?.destroy?.();
|
||||
clearInterval(heartbeatTimer);
|
||||
|
|
@ -253,8 +260,10 @@ export async function runKnip(knipArgs: string[], params: KnipRunParams = {}) {
|
|||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
};
|
||||
|
||||
child.stdout?.on("data", appendOutput);
|
||||
child.stderr?.on("data", appendOutput);
|
||||
const appendStdout = (chunk: string | Uint8Array) => appendOutput(stdoutDecoder, chunk);
|
||||
const appendStderr = (chunk: string | Uint8Array) => appendOutput(stderrDecoder, chunk);
|
||||
child.stdout?.on("data", appendStdout);
|
||||
child.stderr?.on("data", appendStderr);
|
||||
child.on("error", (error) =>
|
||||
finish({
|
||||
errorCode: spawnErrorCode(error),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Builds dependency change reports from lockfile and manifest diffs.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
|
@ -33,20 +32,12 @@ const DEPENDENCY_DIFF_PATHS = [
|
|||
|
||||
type DependencyPayload = Record<string, string[]>;
|
||||
type DependencyFileChange = { oldPath: string | null; path: string; status: string };
|
||||
const nullableString = (value: string | null) => value;
|
||||
|
||||
function payloadFromLockfile(lockfileText: string): DependencyPayload {
|
||||
const packages = collectAllResolvedPackagesFromLockfile(lockfileText);
|
||||
return createBulkAdvisoryPayload(packages) satisfies DependencyPayload;
|
||||
}
|
||||
|
||||
function versionsFor(payload: DependencyPayload, packageName: string) {
|
||||
return new Set(payload[packageName] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a structured dependency diff report from base/head payloads.
|
||||
*/
|
||||
export function createDependencyChangesReport({
|
||||
basePayload,
|
||||
headPayload,
|
||||
|
|
@ -74,8 +65,8 @@ export function createDependencyChangesReport({
|
|||
}> = [];
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const baseVersions = versionsFor(basePayload, packageName);
|
||||
const headVersions = versionsFor(headPayload, packageName);
|
||||
const baseVersions = new Set(basePayload[packageName] ?? []);
|
||||
const headVersions = new Set(headPayload[packageName] ?? []);
|
||||
if (baseVersions.size === 0) {
|
||||
addedPackages.push({
|
||||
packageName,
|
||||
|
|
@ -166,16 +157,15 @@ function renderMarkdownReport(report: ReturnType<typeof createDependencyChangesR
|
|||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.addedPackages.length > 0) {
|
||||
lines.push("## Added Resolved Packages", "");
|
||||
for (const item of report.addedPackages) {
|
||||
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
|
||||
for (const [title, packages] of [
|
||||
["Added", report.addedPackages],
|
||||
["Removed", report.removedPackages],
|
||||
] as const) {
|
||||
if (packages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (report.removedPackages.length > 0) {
|
||||
lines.push("## Removed Resolved Packages", "");
|
||||
for (const item of report.removedPackages) {
|
||||
lines.push(`## ${title} Resolved Packages`, "");
|
||||
for (const item of packages) {
|
||||
lines.push(`- ${markdownCode(item.packageName)}: ${item.versions.join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
|
@ -201,9 +191,6 @@ function readGitFile(ref: string, filePath: string, cwd: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a path is a dependency-related file.
|
||||
*/
|
||||
export function isDependencyFile(filePath: unknown) {
|
||||
if (typeof filePath !== "string") {
|
||||
return false;
|
||||
|
|
@ -211,9 +198,6 @@ export function isDependencyFile(filePath: unknown) {
|
|||
return DEPENDENCY_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns git pathspecs used for dependency diff collection.
|
||||
*/
|
||||
export function dependencyDiffPathspecs() {
|
||||
return [...DEPENDENCY_DIFF_PATHS];
|
||||
}
|
||||
|
|
@ -254,13 +238,20 @@ function gitDiffDependencyFiles(baseRef: string, cwd: string) {
|
|||
}
|
||||
|
||||
export function parseArgs(argv: string[]) {
|
||||
const options = {
|
||||
const options: {
|
||||
rootDir: string;
|
||||
baseRef: string | null;
|
||||
baseLockfile: string | null;
|
||||
headLockfile: string;
|
||||
jsonPath: string | null;
|
||||
markdownPath: string | null;
|
||||
} = {
|
||||
rootDir: process.cwd(),
|
||||
baseRef: nullableString(null),
|
||||
baseLockfile: nullableString(null),
|
||||
baseRef: null,
|
||||
baseLockfile: null,
|
||||
headLockfile: "pnpm-lock.yaml",
|
||||
jsonPath: nullableString(null),
|
||||
markdownPath: nullableString(null),
|
||||
jsonPath: null,
|
||||
markdownPath: null,
|
||||
};
|
||||
const flagEntries = [
|
||||
["--root", "rootDir"],
|
||||
|
|
@ -295,9 +286,6 @@ export function parseArgs(argv: string[]) {
|
|||
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and writes dependency change report artifacts.
|
||||
*/
|
||||
async function runDependencyChangesReport(options: ReturnType<typeof parseArgs>) {
|
||||
const headLockfileText = await readFile(path.join(options.rootDir, options.headLockfile), "utf8");
|
||||
const baseLockfileText =
|
||||
|
|
@ -315,9 +303,6 @@ async function runDependencyChangesReport(options: ReturnType<typeof parseArgs>)
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the dependency changes report CLI.
|
||||
*/
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
const report = await runDependencyChangesReport(options);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Reports dependency ownership, closure, and risk surface from lockfile data.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
|
@ -12,16 +11,14 @@ import { pnpmLockfileDocuments } from "./lib/pnpm-lockfile-documents.mjs";
|
|||
import { collectRootDependencyOwnershipAudit } from "./root-dependency-ownership-audit.mts";
|
||||
|
||||
const DEFAULT_OWNERSHIP_PATH = "scripts/lib/dependency-ownership.json";
|
||||
const PROD_IMPORTER_SECTIONS = ["dependencies", "optionalDependencies"];
|
||||
const TRANSITIVE_SECTIONS = ["dependencies", "optionalDependencies"];
|
||||
const DEPENDENCY_SECTIONS = ["dependencies", "optionalDependencies"];
|
||||
const compareStrings = (left: string, right: string) => left.localeCompare(right);
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type ImporterRecord = Record<string, unknown>;
|
||||
type Lockfile = {
|
||||
importers?: Record<string, ImporterRecord>;
|
||||
importers?: Record<string, JsonObject>;
|
||||
packages?: Record<string, JsonObject>;
|
||||
snapshots?: Record<string, ImporterRecord>;
|
||||
snapshots?: Record<string, JsonObject>;
|
||||
};
|
||||
type RootDependency = {
|
||||
name: string;
|
||||
|
|
@ -47,9 +44,9 @@ function readJson(filePath: string): JsonObject {
|
|||
return value;
|
||||
}
|
||||
|
||||
function normalizeDependencies(record: ImporterRecord = {}): RootDependency[] {
|
||||
function normalizeDependencies(record: JsonObject = {}): RootDependency[] {
|
||||
const entries: RootDependency[] = [];
|
||||
for (const section of PROD_IMPORTER_SECTIONS) {
|
||||
for (const section of DEPENDENCY_SECTIONS) {
|
||||
const sectionRecord = record[section];
|
||||
if (!isRecord(sectionRecord)) {
|
||||
continue;
|
||||
|
|
@ -67,21 +64,14 @@ function normalizeDependencies(record: ImporterRecord = {}): RootDependency[] {
|
|||
return entries.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the package name from a pnpm lockfile package key.
|
||||
*/
|
||||
export function packageNameFromLockKey(lockKey: unknown) {
|
||||
if (typeof lockKey !== "string") {
|
||||
return lockKey;
|
||||
}
|
||||
const peerSuffixIndex = lockKey.indexOf("(");
|
||||
const baseKey = peerSuffixIndex >= 0 ? lockKey.slice(0, peerSuffixIndex) : lockKey;
|
||||
if (baseKey.startsWith("@")) {
|
||||
const secondAt = baseKey.indexOf("@", 1);
|
||||
return secondAt >= 0 ? baseKey.slice(0, secondAt) : baseKey;
|
||||
}
|
||||
const firstAt = baseKey.indexOf("@");
|
||||
return firstAt >= 0 ? baseKey.slice(0, firstAt) : baseKey;
|
||||
const versionAt = baseKey.indexOf("@", baseKey.startsWith("@") ? 1 : 0);
|
||||
return versionAt >= 0 ? baseKey.slice(0, versionAt) : baseKey;
|
||||
}
|
||||
|
||||
function lockKeyForDependency(name: string, version: string) {
|
||||
|
|
@ -100,9 +90,9 @@ function lockKeyForDependency(name: string, version: string) {
|
|||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
function dependencyEntriesFromSnapshot(snapshot: ImporterRecord = {}) {
|
||||
function dependencyEntriesFromSnapshot(snapshot: JsonObject = {}) {
|
||||
const entries: Array<{ name: string; version: string }> = [];
|
||||
for (const section of TRANSITIVE_SECTIONS) {
|
||||
for (const section of DEPENDENCY_SECTIONS) {
|
||||
const sectionRecord = snapshot[section];
|
||||
if (!isRecord(sectionRecord)) {
|
||||
continue;
|
||||
|
|
@ -120,11 +110,7 @@ function collectClosure(lockfile: Lockfile, rootKeys: Array<string | undefined>)
|
|||
const seen = new Set<string>();
|
||||
const missing = new Set<string>();
|
||||
const queue = rootKeys.filter((key): key is string => typeof key === "string");
|
||||
while (queue.length > 0) {
|
||||
const key = queue.shift();
|
||||
if (key === undefined) {
|
||||
break;
|
||||
}
|
||||
for (const key of queue) {
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -208,9 +194,6 @@ function collectReportTarget({
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects dependency ownership and transitive surface metadata.
|
||||
*/
|
||||
export function collectDependencyOwnershipSurfaceReport(params: ReportParams = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const packageJson = readJson(path.join(repoRoot, "package.json"));
|
||||
|
|
@ -349,9 +332,6 @@ export function collectDependencyOwnershipSurfaceReport(params: ReportParams = {
|
|||
|
||||
type DependencyOwnershipReport = ReturnType<typeof collectDependencyOwnershipSurfaceReport>;
|
||||
|
||||
/**
|
||||
* Collects policy errors from a dependency ownership surface report.
|
||||
*/
|
||||
export function collectDependencyOwnershipSurfaceCheckErrors(report: DependencyOwnershipReport) {
|
||||
return report.ownershipGaps.map(
|
||||
(name) => `root dependency '${name}' is missing from ${DEFAULT_OWNERSHIP_PATH}`,
|
||||
|
|
@ -359,16 +339,7 @@ export function collectDependencyOwnershipSurfaceCheckErrors(report: DependencyO
|
|||
}
|
||||
|
||||
function renderTargetPackage(target: DependencyOwnershipReport["target"]) {
|
||||
if (!target?.packageName && !target?.packageVersion) {
|
||||
return "unknown";
|
||||
}
|
||||
if (!target.packageName) {
|
||||
return target.packageVersion ?? "unknown";
|
||||
}
|
||||
if (!target.packageVersion) {
|
||||
return target.packageName;
|
||||
}
|
||||
return `${target.packageName}@${target.packageVersion}`;
|
||||
return [target?.packageName, target?.packageVersion].filter(Boolean).join("@") || "unknown";
|
||||
}
|
||||
|
||||
function markdownCode(value: unknown) {
|
||||
|
|
@ -379,9 +350,6 @@ function pluralize(count: number, singular: string, plural = `${singular}s`) {
|
|||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dependency ownership surface report as Markdown.
|
||||
*/
|
||||
export function renderDependencyOwnershipSurfaceMarkdownReport(
|
||||
typedReport: DependencyOwnershipReport,
|
||||
) {
|
||||
|
|
@ -473,10 +441,6 @@ export function renderDependencyOwnershipSurfaceMarkdownReport(
|
|||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function printTextReport(report: DependencyOwnershipReport) {
|
||||
process.stdout.write(renderDependencyOwnershipSurfaceMarkdownReport(report));
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): ParseOptions {
|
||||
const options: ParseOptions = {
|
||||
rootDir: process.cwd(),
|
||||
|
|
@ -573,7 +537,7 @@ function main(argv: string[] = process.argv.slice(2)) {
|
|||
);
|
||||
return;
|
||||
}
|
||||
printTextReport(report);
|
||||
process.stdout.write(renderDependencyOwnershipSurfaceMarkdownReport(report));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
|
|
|||
|
|
@ -111,14 +111,15 @@ function resultText(result: ToolResult): string {
|
|||
|
||||
function wireResult(result: ToolResult): JsonRecord {
|
||||
const details = result.details as { result?: unknown } | undefined;
|
||||
if (details?.result && typeof details.result === "object" && !Array.isArray(details.result)) {
|
||||
return details.result as JsonRecord;
|
||||
const structured = record(details?.result);
|
||||
if (structured) {
|
||||
return structured;
|
||||
}
|
||||
for (const line of resultText(result).split("\n")) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as JsonRecord;
|
||||
const parsed = record(JSON.parse(line));
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Mutating actions prefix their follow-up screenshot with one JSON result line.
|
||||
|
|
@ -170,7 +171,7 @@ function summarizeOutcome(outcome: ActionOutcome): JsonRecord {
|
|||
|
||||
async function saveImage(name: string, result: ToolResult): Promise<string> {
|
||||
const image = result.content.find((block) => block.type === "image");
|
||||
if (!image || image.type !== "image") {
|
||||
if (!image) {
|
||||
throw new Error(`missing model-visible image in ${name}`);
|
||||
}
|
||||
const extension = image.mimeType === "image/jpeg" ? "jpeg" : "png";
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ validate_provider() {
|
|||
esac
|
||||
}
|
||||
|
||||
validate_preparation() {
|
||||
local profile="$1" port="$2" scratch="$3"
|
||||
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
|
||||
fail "profile must contain only letters, digits, underscores, and dashes"
|
||||
case "$profile" in
|
||||
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
|
||||
esac
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
|
||||
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
|
||||
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
|
||||
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
||||
}
|
||||
|
||||
require_unoccupied_port() {
|
||||
local port="$1"
|
||||
if ! node - "$port" >/dev/null 2>&1 <<'NODE'; then
|
||||
|
|
@ -199,15 +212,7 @@ prepare() {
|
|||
local scratch="$4"
|
||||
local provider="${5:-peekaboo}"
|
||||
|
||||
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
|
||||
fail "profile must contain only letters, digits, underscores, and dashes"
|
||||
case "$profile" in
|
||||
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
|
||||
esac
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
|
||||
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
|
||||
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
|
||||
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
||||
validate_preparation "$profile" "$port" "$scratch"
|
||||
validate_provider "$provider"
|
||||
require_unoccupied_port "$port"
|
||||
|
||||
|
|
@ -271,15 +276,7 @@ prepare_linux() {
|
|||
local scratch="$3"
|
||||
|
||||
require_linux_x11
|
||||
[[ "$profile" =~ ^[A-Za-z0-9][A-Za-z0-9_-]+$ ]] ||
|
||||
fail "profile must contain only letters, digits, underscores, and dashes"
|
||||
case "$profile" in
|
||||
default | main | local) fail "choose a fresh, explicitly isolated profile" ;;
|
||||
esac
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || fail "port must be numeric"
|
||||
((port >= 1024 && port <= 65535)) || fail "port must be between 1024 and 65535"
|
||||
((port != 18789)) || fail "port 18789 belongs to the operator gateway"
|
||||
[[ "$scratch" = /* ]] || fail "scratch path must be absolute"
|
||||
validate_preparation "$profile" "$port" "$scratch"
|
||||
require_unoccupied_port "$port"
|
||||
|
||||
git -C "$repo_root" diff --quiet -- src packages extensions scripts ||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Test Device Pair Telegram script supports OpenClaw repository automation.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getRuntimeConfig } from "../../src/config/config.js";
|
||||
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
|
||||
|
|
@ -51,11 +50,7 @@ function writeStdoutLine(...parts: string[]): void {
|
|||
process.stdout.write(`${parts.join(" ")}\n`);
|
||||
}
|
||||
|
||||
function writeStderrLine(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function readArg(args: string[], flag: string, short?: string): string | undefined {
|
||||
function readArg(args: readonly string[], flag: string, short?: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
return args[idx + 1];
|
||||
|
|
@ -101,8 +96,8 @@ function validateArgs(args: readonly string[]): void {
|
|||
function parseDevicePairTelegramArgs(args: readonly string[]): DevicePairTelegramArgs {
|
||||
validateArgs(args);
|
||||
return {
|
||||
accountId: readArg([...args], "--account", "-a"),
|
||||
chatId: readArg([...args], "--chat", "-c"),
|
||||
accountId: readArg(args, "--account", "-a"),
|
||||
chatId: readArg(args, "--chat", "-c"),
|
||||
help: args.includes("--help") || args.includes("-h"),
|
||||
};
|
||||
}
|
||||
|
|
@ -134,10 +129,7 @@ async function runDevicePairTelegram(
|
|||
deps: DevicePairTelegramDeps = createDefaultDeps(),
|
||||
): Promise<DevicePairTelegramResult> {
|
||||
const { accountId, chatId, help } = parseDevicePairTelegramArgs(args);
|
||||
if (help) {
|
||||
throw new UsageError(usage());
|
||||
}
|
||||
if (!chatId) {
|
||||
if (help || !chatId) {
|
||||
throw new UsageError(usage());
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +187,7 @@ async function main(): Promise<void> {
|
|||
result.messageId ? `message=${result.messageId}` : "",
|
||||
);
|
||||
} catch (error) {
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = error instanceof UsageError ? error.exitCode : 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Tui Pty Test Watch script supports OpenClaw repository automation.
|
||||
import { mkdir, open, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
|
@ -50,10 +49,6 @@ type ChildStopper = {
|
|||
|
||||
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
function readOption(args: string[], name: string): string | undefined {
|
||||
const idx = args.indexOf(name);
|
||||
if (idx < 0) {
|
||||
|
|
@ -116,10 +111,6 @@ function parseOptions(args = process.argv.slice(2)): Options {
|
|||
};
|
||||
}
|
||||
|
||||
function shouldUseAltScreen(options: Options) {
|
||||
return options.altScreen && process.stdout.isTTY;
|
||||
}
|
||||
|
||||
function resolveVitestCliEntry(): string {
|
||||
const vitestPackageJson = require.resolve("vitest/package.json");
|
||||
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
|
||||
|
|
@ -165,19 +156,14 @@ function createChildStopper(
|
|||
killTimer = setTimeout(() => {
|
||||
signalChild(child, "SIGKILL");
|
||||
}, CHILD_SIGKILL_GRACE_MS);
|
||||
unrefTimer(killTimer);
|
||||
killTimer.unref();
|
||||
}, CHILD_SIGTERM_GRACE_MS);
|
||||
unrefTimer(termTimer);
|
||||
termTimer.unref();
|
||||
};
|
||||
|
||||
return { cancel, stop };
|
||||
}
|
||||
|
||||
async function createMirrorFile(mirrorPath: string): Promise<void> {
|
||||
await mkdir(path.dirname(mirrorPath), { recursive: true });
|
||||
await writeFile(mirrorPath, "", "utf8");
|
||||
}
|
||||
|
||||
async function readNewMirrorData(
|
||||
mirrorPath: string,
|
||||
offset: number,
|
||||
|
|
@ -234,8 +220,9 @@ async function main(): Promise<void> {
|
|||
process.stdout.write(`${usage()}\n`);
|
||||
return;
|
||||
}
|
||||
const useAltScreen = shouldUseAltScreen(options);
|
||||
await createMirrorFile(options.mirrorPath);
|
||||
const useAltScreen = options.altScreen && process.stdout.isTTY;
|
||||
await mkdir(path.dirname(options.mirrorPath), { recursive: true });
|
||||
await writeFile(options.mirrorPath, "", "utf8");
|
||||
|
||||
const { child, completion } = spawnOwnedVitestProcess({
|
||||
homeMode: resolveVitestHomeSelection(
|
||||
|
|
@ -270,7 +257,6 @@ async function main(): Promise<void> {
|
|||
|
||||
let childStdout: Buffer = Buffer.alloc(0);
|
||||
let childStderr: Buffer = Buffer.alloc(0);
|
||||
let restored = false;
|
||||
let mirrorOffset = 0;
|
||||
let mirrorFilterPending = "";
|
||||
let sawMirrorOutput = false;
|
||||
|
|
@ -309,16 +295,6 @@ async function main(): Promise<void> {
|
|||
process.stdout.write(filteredChunk);
|
||||
};
|
||||
|
||||
const restoreScreen = () => {
|
||||
if (restored) {
|
||||
return;
|
||||
}
|
||||
restored = true;
|
||||
if (useAltScreen) {
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
}
|
||||
};
|
||||
|
||||
const childStopper = createChildStopper(child);
|
||||
const stopChild = childStopper.stop;
|
||||
|
||||
|
|
@ -345,13 +321,6 @@ async function main(): Promise<void> {
|
|||
}
|
||||
};
|
||||
|
||||
const drainParentInput = async () => {
|
||||
if (!useAltScreen || !process.stdin.isTTY) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
};
|
||||
|
||||
const renderWaitingStatus = () => {
|
||||
if (!useAltScreen || sawMirrorOutput) {
|
||||
return;
|
||||
|
|
@ -416,7 +385,7 @@ async function main(): Promise<void> {
|
|||
await delay(sawMirrorOutput ? 25 : 250);
|
||||
}
|
||||
|
||||
mirrorOffset = await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
|
||||
await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
|
||||
} finally {
|
||||
if (!childFinished) {
|
||||
stopChild();
|
||||
|
|
@ -425,12 +394,14 @@ async function main(): Promise<void> {
|
|||
for (const signal of parentSignals) {
|
||||
process.off(signal, stopChild);
|
||||
}
|
||||
await drainParentInput();
|
||||
if (useAltScreen && process.stdin.isTTY) {
|
||||
await delay(100);
|
||||
}
|
||||
restoreInput();
|
||||
if (useAltScreen) {
|
||||
process.stdout.write("\x1b[?2026l\x1b[?2004l\x1b[>4;0m\x1b[?25h");
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
}
|
||||
restoreScreen();
|
||||
}
|
||||
|
||||
const outcome = await childOutcome;
|
||||
|
|
|
|||
|
|
@ -113,9 +113,6 @@ func setPostprocessVersion(frontMatter, version string) string {
|
|||
if xi18nLine == -1 {
|
||||
return frontMatter
|
||||
}
|
||||
if insertAt == -1 {
|
||||
insertAt = len(lines)
|
||||
}
|
||||
|
||||
lines = append(lines[:insertAt], append([]string{childIndent + "postprocess_version: " + version}, lines[insertAt:]...)...)
|
||||
return strings.Join(lines, "\n")
|
||||
|
|
|
|||
|
|
@ -285,10 +285,6 @@ function isLocalizedDocPath(p: string) {
|
|||
return /^\/?[a-z]{2}(?:-[A-Za-z]{2,8})+\//.test(p);
|
||||
}
|
||||
|
||||
function isGeneratedTranslatedDoc(relPath: string) {
|
||||
return isLocalizedDocPath(relPath);
|
||||
}
|
||||
|
||||
function createRedirectMap(docsConfig: Record<string, unknown>): Map<string, string> {
|
||||
const redirects = new Map<string, string>();
|
||||
const redirectEntries = Array.isArray(docsConfig.redirects) ? docsConfig.redirects : [];
|
||||
|
|
@ -323,7 +319,7 @@ function buildAuditIndex(
|
|||
return false;
|
||||
}
|
||||
const rel = normalizeSlashes(path.relative(docsDir, abs));
|
||||
return !isGeneratedTranslatedDoc(rel);
|
||||
return !isLocalizedDocPath(rel);
|
||||
});
|
||||
const routes = new Set<string>();
|
||||
|
||||
|
|
@ -348,7 +344,6 @@ function buildAuditIndex(
|
|||
docsDir,
|
||||
docsConfig,
|
||||
redirects,
|
||||
allFiles,
|
||||
relAllFiles,
|
||||
markdownFiles,
|
||||
routes,
|
||||
|
|
@ -443,9 +438,6 @@ function mirroredFragmentReason(terminal: string, hash: string) {
|
|||
return `fragment unverified without the ClawHub source checkout (terminal: ${terminal}${hash}); set ${CLAWHUB_REPO_ENV}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audits local docs links against route, file, and redirect indexes.
|
||||
*/
|
||||
export function auditDocsLinks(
|
||||
options: { docsDir?: string; allowExternalClawHubRoutes?: boolean; anchors?: boolean } = {},
|
||||
) {
|
||||
|
|
@ -630,7 +622,7 @@ export function auditDocsLinks(
|
|||
}
|
||||
|
||||
for (const page of collectNavPageEntries(index.docsConfig.navigation || [])) {
|
||||
if (isGeneratedTranslatedDoc(page)) {
|
||||
if (isLocalizedDocPath(page)) {
|
||||
continue;
|
||||
}
|
||||
checked++;
|
||||
|
|
@ -654,7 +646,6 @@ export function auditDocsLinks(
|
|||
return { checked, broken, collisions, unverifiedMirroredFragments };
|
||||
}
|
||||
|
||||
/** Runs the docs link audit CLI. */
|
||||
function runDocsLinkAuditCli() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "--prepare-external-links") {
|
||||
|
|
|
|||
|
|
@ -51,12 +51,10 @@ function pass(label: string) {
|
|||
console.log(`✅ ${label}`);
|
||||
}
|
||||
|
||||
// ── Load production code ──────────────────────────────────────────────
|
||||
const { resolveSandboxMountSelection }: WorkspaceMountModule = await import(
|
||||
path.join(repoRoot, "src/agents/sandbox/workspace-mounts.js")
|
||||
);
|
||||
|
||||
// ── Resolve protected skill mounts ────────────────────────────────────
|
||||
console.log("\n--- Protected skill mounts ---");
|
||||
const selection = resolveSandboxMountSelection({
|
||||
workspaceDir,
|
||||
|
|
@ -70,7 +68,6 @@ console.log(
|
|||
selection.readOnlyWorkspaceSkillMounts.map((m) => `${m.hostPath} -> ${m.containerPath}`),
|
||||
);
|
||||
|
||||
// ── Filter user binds ─────────────────────────────────────────────────
|
||||
console.log("\nUser binds:", userBinds);
|
||||
const safeBinds = selection.custom;
|
||||
console.log(
|
||||
|
|
@ -78,14 +75,12 @@ console.log(
|
|||
safeBinds.length === 0 ? "(none)" : safeBinds,
|
||||
);
|
||||
|
||||
// Conflicting bind should be filtered out (no safe binds remain)
|
||||
if (safeBinds.length > 0) {
|
||||
fail("conflicting user bind was not filtered out");
|
||||
} else {
|
||||
pass("conflicting user bind correctly skipped");
|
||||
}
|
||||
|
||||
// ── Build container create args ───────────────────────────────────────
|
||||
const createArgs = [
|
||||
"create",
|
||||
"--name",
|
||||
|
|
@ -101,7 +96,6 @@ const createArgs = [
|
|||
];
|
||||
createArgs.push(image, "sleep", "infinity");
|
||||
|
||||
// ── Duplicate check ───────────────────────────────────────────────────
|
||||
console.log(`\n--- ${engine} args ---`);
|
||||
let nextIsMount = false;
|
||||
for (const a of createArgs) {
|
||||
|
|
@ -110,8 +104,6 @@ for (const a of createArgs) {
|
|||
nextIsMount = false;
|
||||
} else if (a === "-v") {
|
||||
nextIsMount = true;
|
||||
} else if (a.startsWith("-")) {
|
||||
console.log(` ${a}`);
|
||||
} else {
|
||||
console.log(` ${a}`);
|
||||
}
|
||||
|
|
@ -134,7 +126,6 @@ if (dupes === 0) {
|
|||
fail(`found ${dupes} duplicate container paths`);
|
||||
}
|
||||
|
||||
// ── Helper: run the selected engine with argv (no shell string) ───────
|
||||
function runEngine(args: string[], opts: { stdio?: "pipe" | "inherit" } = {}) {
|
||||
return spawnSync(useSudo ? "sudo" : engine, useSudo ? [engine, ...args] : args, {
|
||||
encoding: "utf8",
|
||||
|
|
@ -143,7 +134,6 @@ function runEngine(args: string[], opts: { stdio?: "pipe" | "inherit" } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Container create ──────────────────────────────────────────────────
|
||||
console.log(`\n--- ${engine} create ${containerName} ---`);
|
||||
let created = false;
|
||||
try {
|
||||
|
|
@ -187,7 +177,6 @@ try {
|
|||
fail(`/workspace/skills appears ${skillsCount} times (expected ≤1)`);
|
||||
}
|
||||
|
||||
// Verify protected mount source (not user bind)
|
||||
const skillMount = mounts.find((mount) => mount.Destination === "/workspace/skills");
|
||||
const mountSrc = typeof skillMount?.Source === "string" ? skillMount.Source : "";
|
||||
console.log(`Mount source for /workspace/skills: ${mountSrc}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env -S pnpm tsx
|
||||
// Linux Smoke script supports OpenClaw repository automation.
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
|
@ -27,9 +26,7 @@ import {
|
|||
withProgressOnStderr,
|
||||
writeJson,
|
||||
writeSummaryMarkdown,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
|
|
@ -90,33 +87,6 @@ interface LinuxOptions extends SmokeCliOptions {
|
|||
vmNameExplicit: boolean;
|
||||
}
|
||||
|
||||
interface LinuxSummary {
|
||||
vm: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
installVersion: string;
|
||||
targetPackageSpec: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
daemon: string;
|
||||
freshMain: {
|
||||
status: string;
|
||||
version: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
upgrade: {
|
||||
status: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions = (): LinuxOptions => ({
|
||||
apiKeyEnv: undefined,
|
||||
hostIp: undefined,
|
||||
|
|
@ -218,7 +188,7 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
|||
this.phases = new PhaseRunner(this.runDir);
|
||||
this.tgzDir = await makeTempDir("openclaw-parallels-linux-tgz.");
|
||||
try {
|
||||
this.options.vmName = this.resolveVmName();
|
||||
this.options.vmName = resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
|
||||
validateSnapshotRestoreMode(this.options.mode, "Linux smoke");
|
||||
this.snapshot = shouldSkipSnapshotRestore()
|
||||
? currentRunningSnapshotInfo(this.options.vmName)
|
||||
|
|
@ -252,94 +222,84 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
|||
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
|
||||
}
|
||||
|
||||
private resolveVmName(): string {
|
||||
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
|
||||
}
|
||||
|
||||
protected async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
|
||||
await this.phases.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phases.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
|
||||
this.bootstrapGuest(),
|
||||
);
|
||||
await this.phase("fresh.reset-state", 180, () => this.resetState());
|
||||
await this.phase("fresh.preflight", 90, () => this.logGuestPreflight());
|
||||
await this.phase("fresh.ensure-runtime", 420, () =>
|
||||
await this.phases.phase("fresh.reset-state", 180, () => this.resetState());
|
||||
await this.phases.phase("fresh.preflight", 90, () => this.logGuestPreflight());
|
||||
await this.phases.phase("fresh.ensure-runtime", 420, () =>
|
||||
ensureSmokeGuestRuntime({
|
||||
runShell: (script) => this.guestBash(script),
|
||||
runShell: (script) => this.guest.bash(script),
|
||||
bootstrap: () => this.installLatestRelease(),
|
||||
}),
|
||||
);
|
||||
await this.phase("fresh.install-main", 420, () =>
|
||||
await this.phases.phase("fresh.install-main", 420, () =>
|
||||
this.installMainTgz("openclaw-main-fresh.tgz"),
|
||||
);
|
||||
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
|
||||
await this.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phase("fresh.install-companions", 600, () =>
|
||||
await this.phases.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phases.phase("fresh.install-companions", 600, () =>
|
||||
installSmokeRuntimeCompanions({
|
||||
provider: this.options.provider,
|
||||
readCli: (args) => this.guestExec(["openclaw", ...args]),
|
||||
readCli: (args) => this.guest.exec(["openclaw", ...args]),
|
||||
installCli: (args) => {
|
||||
this.guestExec(["openclaw", ...args]);
|
||||
this.guest.exec(["openclaw", ...args]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phase("fresh.inject-bad-plugin", 90, () =>
|
||||
await this.phases.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phases.phase("fresh.inject-bad-plugin", 90, () =>
|
||||
this.maybeInjectBadPluginFixture("fresh"),
|
||||
);
|
||||
await this.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phase("fresh.bad-plugin-diagnostic", 90, () =>
|
||||
await this.phases.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phases.phase("fresh.bad-plugin-diagnostic", 90, () =>
|
||||
this.maybeVerifyBadPluginDiagnostic("fresh"),
|
||||
);
|
||||
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
await this.phases.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
await this.phases.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
protected async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
|
||||
await this.phases.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phases.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
|
||||
this.bootstrapGuest(),
|
||||
);
|
||||
await this.phase("upgrade.reset-state", 180, () => this.resetState());
|
||||
await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
|
||||
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
|
||||
await this.phases.phase("upgrade.reset-state", 180, () => this.resetState());
|
||||
await this.phases.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
|
||||
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
|
||||
await this.phase("upgrade.verify-latest-version", 90, () =>
|
||||
await this.phases.phase("upgrade.verify-latest-version", 90, () =>
|
||||
this.verifyVersionContains(this.latestVersion),
|
||||
);
|
||||
await this.phase("upgrade.install-main", 420, () =>
|
||||
await this.phases.phase("upgrade.install-main", 420, () =>
|
||||
this.installMainTgz("openclaw-main-upgrade.tgz"),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
|
||||
await this.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phase("upgrade.inject-bad-plugin", 90, () =>
|
||||
await this.phases.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
|
||||
await this.phases.phase("upgrade.inject-bad-plugin", 90, () =>
|
||||
this.maybeInjectBadPluginFixture("upgrade"),
|
||||
);
|
||||
await this.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phase("upgrade.bad-plugin-diagnostic", 90, () =>
|
||||
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phases.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground());
|
||||
await this.phases.phase("upgrade.bad-plugin-diagnostic", 90, () =>
|
||||
this.maybeVerifyBadPluginDiagnostic("upgrade"),
|
||||
);
|
||||
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
await this.phases.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
await this.phases.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
|
||||
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
|
||||
this.phases.remainingTimeoutMs(fallbackMs);
|
||||
|
||||
private logGuestPreflight(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
this.guest.bash(String.raw`set -euo pipefail
|
||||
printf 'preflight.user=%s\n' "$(whoami)"
|
||||
printf 'preflight.home=%s\n' "$HOME"
|
||||
printf 'preflight.path=%s\n' "$PATH"
|
||||
|
|
@ -347,17 +307,6 @@ printf 'preflight.umask=%s\n' "$(umask)"
|
|||
printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
||||
}
|
||||
|
||||
private log = (text: string): void => this.phases.append(text);
|
||||
|
||||
private guestExec = (
|
||||
args: string[],
|
||||
options: { check?: boolean; timeoutMs?: number } = {},
|
||||
): string => this.guest.exec(args, options);
|
||||
|
||||
private guestBash(script: string): string {
|
||||
return this.guest.bash(script);
|
||||
}
|
||||
|
||||
private waitForGuestReady(timeoutSeconds = 180): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
|
|
@ -365,7 +314,7 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
|||
run("prlctl", ["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", "/bin/true"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
}).status === 0
|
||||
) {
|
||||
return;
|
||||
|
|
@ -386,21 +335,21 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
|||
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
|
||||
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
});
|
||||
ensureVmRunning(this.options.vmName, 180, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
|
||||
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
|
||||
transitionTimeoutMs: () => this.phases.remainingTimeoutMs(120_000),
|
||||
});
|
||||
this.waitForGuestReady();
|
||||
}
|
||||
|
||||
private bootstrapGuest(): void {
|
||||
const hostNow = `@${Math.floor(Date.now() / 1000)}`;
|
||||
this.guestExec(["date", "-u", "-s", hostNow]);
|
||||
this.guestExec(["hwclock", "--systohc"], { check: false });
|
||||
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
|
||||
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
|
||||
this.guest.exec(["date", "-u", "-s", hostNow]);
|
||||
this.guest.exec(["hwclock", "--systohc"], { check: false });
|
||||
this.guest.exec(["timedatectl", "set-ntp", "true"], { check: false });
|
||||
this.guest.exec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
|
||||
this.guest.bash(`
|
||||
set -e
|
||||
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
|
||||
|
|
@ -438,7 +387,7 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
|
|||
}
|
||||
|
||||
private resetState(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
this.guest.bash(String.raw`set -euo pipefail
|
||||
pkill -f '[o]penclaw.*gateway run' >/dev/null 2>&1 || true
|
||||
pkill -f '[o]penclaw-gateway' >/dev/null 2>&1 || true
|
||||
pkill -f '[o]penclaw.mjs gateway' >/dev/null 2>&1 || true
|
||||
|
|
@ -449,26 +398,15 @@ rm -f /tmp/openclaw-parallels-linux-gateway.log`);
|
|||
|
||||
private installLatestRelease(): void {
|
||||
this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh");
|
||||
if (this.options.installVersion) {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
"OPENCLAW_NO_ONBOARD=1",
|
||||
"bash",
|
||||
"/tmp/openclaw-install.sh",
|
||||
"--version",
|
||||
this.options.installVersion,
|
||||
"--no-onboard",
|
||||
]);
|
||||
} else {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
"OPENCLAW_NO_ONBOARD=1",
|
||||
"bash",
|
||||
"/tmp/openclaw-install.sh",
|
||||
"--no-onboard",
|
||||
]);
|
||||
}
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
this.guest.exec([
|
||||
"/usr/bin/env",
|
||||
"OPENCLAW_NO_ONBOARD=1",
|
||||
"bash",
|
||||
"/tmp/openclaw-install.sh",
|
||||
...(this.options.installVersion ? ["--version", this.options.installVersion] : []),
|
||||
"--no-onboard",
|
||||
]);
|
||||
this.guest.exec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
private downloadGuestFile(url: string, outputPath: string): void {
|
||||
|
|
@ -490,8 +428,8 @@ fi`);
|
|||
this.guestEnv = npmRegistryEnv(this.options.npmRegistry ?? this.server.registry?.url);
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
|
||||
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
this.guest.exec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
|
||||
this.guest.exec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
private async verifyTargetVersion(): Promise<void> {
|
||||
|
|
@ -506,14 +444,14 @@ fi`);
|
|||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
const version = this.guestExec(["openclaw", "--version"]);
|
||||
const version = this.guest.exec(["openclaw", "--version"]);
|
||||
if (!version.includes(needle)) {
|
||||
throw new Error(`version mismatch: expected substring ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
private runRefOnboard(): void {
|
||||
this.guestExec([
|
||||
this.guest.exec([
|
||||
"/usr/bin/env",
|
||||
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
|
||||
"openclaw",
|
||||
|
|
@ -538,7 +476,7 @@ fi`);
|
|||
}
|
||||
|
||||
private injectBadPluginFixture(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
this.guest.bash(String.raw`set -euo pipefail
|
||||
plugin_dir=/root/.openclaw/test-bad-plugin
|
||||
mkdir -p "$plugin_dir"
|
||||
cat >"$plugin_dir/package.json" <<'JSON'
|
||||
|
|
@ -593,7 +531,7 @@ PY`);
|
|||
|
||||
private maybeInjectBadPluginFixture(lane: "fresh" | "upgrade"): void {
|
||||
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
|
||||
this.log(
|
||||
this.phases.append(
|
||||
`Skipping bad plugin diagnostic fixture for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
|
||||
);
|
||||
return;
|
||||
|
|
@ -603,7 +541,7 @@ PY`);
|
|||
|
||||
private startGatewayBackground(): void {
|
||||
const bonjourEnv = this.disableBonjour ? " OPENCLAW_DISABLE_BONJOUR=1" : "";
|
||||
this.guestBash(
|
||||
this.guest.bash(
|
||||
String.raw`pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
|
||||
rm -f /tmp/openclaw-parallels-linux-gateway.log
|
||||
setsid sh -lc ` +
|
||||
|
|
@ -625,7 +563,7 @@ setsid sh -lc ` +
|
|||
}
|
||||
|
||||
private showGatewayStatusCompat(check = true): boolean {
|
||||
const help = this.guestExec(["openclaw", "gateway", "status", "--help"], { check: false });
|
||||
const help = this.guest.exec(["openclaw", "gateway", "status", "--help"], { check: false });
|
||||
const args = help.includes("--require-rpc")
|
||||
? ["openclaw", "gateway", "status", "--deep", "--require-rpc"]
|
||||
: ["openclaw", "gateway", "status", "--deep"];
|
||||
|
|
@ -655,7 +593,7 @@ setsid sh -lc ` +
|
|||
|
||||
private async maybeVerifyBadPluginDiagnostic(lane: "fresh" | "upgrade"): Promise<void> {
|
||||
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
|
||||
this.log(
|
||||
this.phases.append(
|
||||
`Skipping bad plugin diagnostic assertion for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
|
||||
);
|
||||
return;
|
||||
|
|
@ -669,8 +607,8 @@ setsid sh -lc ` +
|
|||
if (!gatewayStartLog.includes(warning)) {
|
||||
throw new Error(`bad plugin diagnostic missing: ${warning}`);
|
||||
}
|
||||
this.log(warning);
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
this.phases.append(warning);
|
||||
this.guest.bash(String.raw`set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
|
@ -690,7 +628,7 @@ rm -rf /root/.openclaw/test-bad-plugin`);
|
|||
}
|
||||
|
||||
private restrictAgentTurnPlugins(): void {
|
||||
this.guestBash(
|
||||
this.guest.bash(
|
||||
posixProviderOnlyPluginIsolationScript({
|
||||
fallbackPluginId: this.options.provider,
|
||||
modelId: this.auth.modelId,
|
||||
|
|
@ -699,18 +637,18 @@ rm -rf /root/.openclaw/test-bad-plugin`);
|
|||
}
|
||||
|
||||
private verifyLocalTurn(): void {
|
||||
this.guestBash(`set -euo pipefail\n${posixStopGatewayScript()}`);
|
||||
this.guestExec(["openclaw", "models", "set", this.auth.modelId]);
|
||||
this.guest.bash(`set -euo pipefail\n${posixStopGatewayScript()}`);
|
||||
this.guest.exec(["openclaw", "models", "set", this.auth.modelId]);
|
||||
const modelProviderConfigBatch = modelProviderConfigBatchJson(this.auth.modelId, "linux");
|
||||
if (modelProviderConfigBatch) {
|
||||
this.guestBash(`provider_config_batch="$(mktemp)"
|
||||
this.guest.bash(`provider_config_batch="$(mktemp)"
|
||||
cat >"$provider_config_batch" <<'JSON'
|
||||
${modelProviderConfigBatch}
|
||||
JSON
|
||||
openclaw config set --batch-file "$provider_config_batch" --strict-json
|
||||
rm -f "$provider_config_batch"`);
|
||||
}
|
||||
this.guestExec([
|
||||
this.guest.exec([
|
||||
"openclaw",
|
||||
"config",
|
||||
"set",
|
||||
|
|
@ -718,10 +656,10 @@ rm -f "$provider_config_batch"`);
|
|||
"true",
|
||||
"--strict-json",
|
||||
]);
|
||||
this.guestExec(["openclaw", "config", "set", "tools.profile", "minimal"]);
|
||||
this.guest.exec(["openclaw", "config", "set", "tools.profile", "minimal"]);
|
||||
this.restrictAgentTurnPlugins();
|
||||
this.prepareAgentWorkspace();
|
||||
this.guestBash(
|
||||
this.guest.bash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant."));
|
||||
this.guest.bash(
|
||||
`${posixCodexPlatformPackageRepairFunction()}
|
||||
${posixAgentTurnScript({
|
||||
command: `/usr/bin/env OPENCLAW_ALLOW_ROOT=1 ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} openclaw agent --local --agent main --session-id "$session_id" --message ${shellQuote(
|
||||
|
|
@ -734,10 +672,6 @@ ${posixAgentTurnScript({
|
|||
);
|
||||
}
|
||||
|
||||
private prepareAgentWorkspace(): void {
|
||||
this.guestBash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant."));
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseId: string): Promise<string> {
|
||||
return await extractLastOpenClawVersion(
|
||||
this.runDir,
|
||||
|
|
@ -748,7 +682,7 @@ ${posixAgentTurnScript({
|
|||
|
||||
protected async writeSummary(): Promise<string> {
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
const summary: LinuxSummary = {
|
||||
const summary = {
|
||||
daemon: this.status.daemon,
|
||||
...buildCommonSmokeSummary({
|
||||
artifact: this.artifact,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
#!/usr/bin/env -S pnpm tsx
|
||||
// Macos Smoke script supports OpenClaw repository automation.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
|
|
@ -33,15 +31,11 @@ import {
|
|||
withProgressOnStderr,
|
||||
writeJson,
|
||||
writeSummaryMarkdown,
|
||||
type HostServer,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
import { MacosGuest } from "./guest-transports.ts";
|
||||
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
|
||||
import { MacosDiscordSmoke } from "./macos-discord.ts";
|
||||
import { runMacosHostCommand as run } from "./macos-exec.ts";
|
||||
import { resolveMacosVmName, waitForVmStatus } from "./parallels-vm.ts";
|
||||
|
|
@ -52,8 +46,10 @@ import {
|
|||
npmRegistryEnv,
|
||||
packAndServeSmokeArtifact,
|
||||
parseSmokeCliArgs,
|
||||
printSmokeTargetSummary,
|
||||
posixAgentTurnScript,
|
||||
posixStopGatewayScript,
|
||||
SmokeRunController,
|
||||
type SmokeCliOptions,
|
||||
} from "./smoke-common.ts";
|
||||
|
||||
|
|
@ -65,38 +61,6 @@ interface MacosOptions extends SmokeCliOptions {
|
|||
discordChannelId?: string;
|
||||
}
|
||||
|
||||
interface MacosSummary {
|
||||
vm: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
installVersion: string;
|
||||
targetPackageSpec: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
freshMain: {
|
||||
status: string;
|
||||
version: string;
|
||||
gateway: string;
|
||||
dashboard: string;
|
||||
agent: string;
|
||||
discord: string;
|
||||
};
|
||||
upgrade: {
|
||||
precheck: string;
|
||||
status: string;
|
||||
path: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
gateway: string;
|
||||
dashboard: string;
|
||||
agent: string;
|
||||
discord: string;
|
||||
};
|
||||
}
|
||||
|
||||
const guestPath =
|
||||
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
const guestOpenClaw = "openclaw";
|
||||
|
|
@ -181,15 +145,10 @@ export function parseArgs(argv: string[]): MacosOptions {
|
|||
});
|
||||
}
|
||||
|
||||
class MacosSmoke {
|
||||
class MacosSmoke extends SmokeRunController<MacosOptions> {
|
||||
private agentTimeoutSeconds: number;
|
||||
private auth: ProviderAuth;
|
||||
private discordToken = "";
|
||||
private hostIp = "";
|
||||
private hostPort = 0;
|
||||
private server: HostServer | null = null;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private targetExpectVersion = "";
|
||||
private latestVersion = "";
|
||||
|
|
@ -204,9 +163,7 @@ class MacosSmoke {
|
|||
private modelTimeoutSeconds: number;
|
||||
private updateDevTimeoutSeconds: number;
|
||||
private devTargetCommit: string | undefined;
|
||||
private options: MacosOptions;
|
||||
|
||||
private status = {
|
||||
protected status = {
|
||||
freshAgent: "skip",
|
||||
freshDashboard: "skip",
|
||||
freshDiscord: "skip",
|
||||
|
|
@ -224,7 +181,7 @@ class MacosSmoke {
|
|||
};
|
||||
|
||||
constructor(options: MacosOptions) {
|
||||
this.options = options;
|
||||
super(options);
|
||||
this.auth = resolveProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
|
|
@ -311,28 +268,10 @@ class MacosSmoke {
|
|||
).stdout.trim();
|
||||
}
|
||||
|
||||
if (this.options.mode === "fresh" || this.options.mode === "both") {
|
||||
await this.runLane("fresh", async () => this.runFreshLane());
|
||||
}
|
||||
if (this.options.mode === "upgrade" || this.options.mode === "both") {
|
||||
await this.runLane("upgrade", async () => this.runUpgradeLane());
|
||||
}
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
this.printSummary(summaryPath);
|
||||
}
|
||||
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await this.runLanesAndFinish();
|
||||
} finally {
|
||||
if (!this.options.keepServer) {
|
||||
await this.server?.stop().catch(() => undefined);
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
await this.cleanupDiscordMessages().catch(() => undefined);
|
||||
await this.cleanupArtifacts();
|
||||
await this.discord?.cleanupMessages().catch(() => undefined);
|
||||
await this.stopVmAfterSuccessfulDiscordSmoke().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
|
@ -403,65 +342,61 @@ class MacosSmoke {
|
|||
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
|
||||
}
|
||||
|
||||
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
|
||||
await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status));
|
||||
}
|
||||
|
||||
private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void {
|
||||
if (name === "fresh") {
|
||||
this.status.freshMain = status;
|
||||
} else {
|
||||
this.status.upgrade = status;
|
||||
}
|
||||
}
|
||||
|
||||
private async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.reset-state", 180, () => this.resetState());
|
||||
await this.phase("fresh.install-main", this.targetInstallsDirectly() ? 420 : 420, () =>
|
||||
protected async runFreshLane(): Promise<void> {
|
||||
await this.phases.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot());
|
||||
await this.phases.phase("fresh.reset-state", 180, () => this.resetState());
|
||||
await this.phases.phase("fresh.install-main", 420, () =>
|
||||
this.installMain("openclaw-main-fresh.tgz"),
|
||||
);
|
||||
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
|
||||
await this.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion());
|
||||
await this.phase("fresh.verify-bundle-permissions", 180, () => this.verifyBundlePermissions());
|
||||
await this.phase("fresh.install-companions", 600, () =>
|
||||
await this.phases.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion());
|
||||
await this.phases.phase("fresh.verify-bundle-permissions", 180, () =>
|
||||
this.verifyBundlePermissions(),
|
||||
);
|
||||
await this.phases.phase("fresh.install-companions", 600, () =>
|
||||
installSmokeRuntimeCompanions({
|
||||
provider: this.options.provider,
|
||||
readCli: (args) => this.guestExec([guestOpenClaw, ...args]),
|
||||
readCli: (args) => this.guest.exec([guestOpenClaw, ...args]),
|
||||
installCli: (args) => {
|
||||
this.guestExec([guestOpenClaw, ...args]);
|
||||
this.guest.exec([guestOpenClaw, ...args]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded());
|
||||
await this.phase("fresh.gateway-status", 180, () => this.verifyGateway());
|
||||
await this.phases.phase("fresh.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phases.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded());
|
||||
await this.phases.phase("fresh.gateway-status", 180, () => this.verifyGateway());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase("fresh.dashboard-load", 180, () => this.verifyDashboardLoad());
|
||||
await this.phases.phase("fresh.dashboard-load", 180, () => this.verifyDashboardLoad());
|
||||
this.status.freshDashboard = "pass";
|
||||
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
await this.phases.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
if (this.discordEnabled()) {
|
||||
this.status.freshDiscord = "fail";
|
||||
await this.phase("fresh.discord-config", 600, () => this.configureDiscord());
|
||||
await this.phase("fresh.discord-gateway-ready", 180, () => this.ensureDiscordGatewayReady());
|
||||
await this.phase("fresh.discord-roundtrip", 180, () => this.runDiscordRoundtrip("fresh"));
|
||||
await this.phases.phase("fresh.discord-config", 600, () => this.discord?.configure());
|
||||
await this.phases.phase("fresh.discord-gateway-ready", 180, () =>
|
||||
this.ensureDiscordGatewayReady(),
|
||||
);
|
||||
await this.phases.phase("fresh.discord-roundtrip", 180, () =>
|
||||
this.runDiscordRoundtrip("fresh"),
|
||||
);
|
||||
this.status.freshDiscord = "pass";
|
||||
}
|
||||
}
|
||||
|
||||
private async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.reset-state", 180, () => this.resetState());
|
||||
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
|
||||
protected async runUpgradeLane(): Promise<void> {
|
||||
await this.phases.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot());
|
||||
await this.phases.phase("upgrade.reset-state", 180, () => this.resetState());
|
||||
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
|
||||
await this.phase("upgrade.verify-latest-version", 60, () =>
|
||||
await this.phases.phase("upgrade.verify-latest-version", 60, () =>
|
||||
this.verifyVersionContains(this.installVersion),
|
||||
);
|
||||
if (this.options.skipLatestRefCheck) {
|
||||
this.status.upgradePrecheck = "skipped";
|
||||
} else if (
|
||||
await this.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
|
||||
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
|
||||
this.captureLatestRefFailure(),
|
||||
)
|
||||
) {
|
||||
|
|
@ -470,77 +405,52 @@ class MacosSmoke {
|
|||
this.status.upgradePrecheck = "latest-ref-fail";
|
||||
}
|
||||
if (this.options.targetPackageSpec) {
|
||||
await this.phase("upgrade.install-main", this.targetInstallsDirectly() ? 420 : 420, () =>
|
||||
await this.phases.phase("upgrade.install-main", 420, () =>
|
||||
this.installMain("openclaw-main-upgrade.tgz"),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
|
||||
await this.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion());
|
||||
await this.phase("upgrade.verify-bundle-permissions", 180, () =>
|
||||
await this.phases.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion());
|
||||
await this.phases.phase("upgrade.verify-bundle-permissions", 180, () =>
|
||||
this.verifyBundlePermissions(),
|
||||
);
|
||||
} else {
|
||||
await this.phase("upgrade.update-dev", this.updateDevTimeoutSeconds, () =>
|
||||
await this.phases.phase("upgrade.update-dev", this.updateDevTimeoutSeconds, () =>
|
||||
this.runDevChannelUpdate(),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
|
||||
await this.phase("upgrade.verify-dev-channel", 60, () => this.verifyDevChannelUpdate());
|
||||
await this.phases.phase("upgrade.verify-dev-channel", 60, () =>
|
||||
this.verifyDevChannelUpdate(),
|
||||
);
|
||||
}
|
||||
await this.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phase("upgrade.gateway-start", 180, () => this.startManualGatewayIfNeeded());
|
||||
await this.phase("upgrade.gateway-status", 180, () => this.verifyGateway());
|
||||
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
|
||||
await this.phases.phase("upgrade.gateway-start", 180, () => this.startManualGatewayIfNeeded());
|
||||
await this.phases.phase("upgrade.gateway-status", 180, () => this.verifyGateway());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase("upgrade.dashboard-load", 180, () => this.verifyDashboardLoad());
|
||||
await this.phases.phase("upgrade.dashboard-load", 180, () => this.verifyDashboardLoad());
|
||||
this.status.upgradeDashboard = "pass";
|
||||
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
await this.phases.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
if (this.discordEnabled()) {
|
||||
this.status.upgradeDiscord = "fail";
|
||||
await this.phase("upgrade.discord-config", 600, () => this.configureDiscord());
|
||||
await this.phase("upgrade.discord-gateway-ready", 180, () =>
|
||||
await this.phases.phase("upgrade.discord-config", 600, () => this.discord?.configure());
|
||||
await this.phases.phase("upgrade.discord-gateway-ready", 180, () =>
|
||||
this.ensureDiscordGatewayReady(),
|
||||
);
|
||||
await this.phase("upgrade.discord-roundtrip", 180, () => this.runDiscordRoundtrip("upgrade"));
|
||||
await this.phases.phase("upgrade.discord-roundtrip", 180, () =>
|
||||
this.runDiscordRoundtrip("upgrade"),
|
||||
);
|
||||
this.status.upgradeDiscord = "pass";
|
||||
}
|
||||
}
|
||||
|
||||
private async phase(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
|
||||
return this.phases.remainingTimeoutMs(fallbackMs);
|
||||
}
|
||||
|
||||
private async phaseReturns(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private log(text: string): void {
|
||||
this.phases.append(text);
|
||||
}
|
||||
|
||||
private guestExec(
|
||||
args: string[],
|
||||
options: { check?: boolean; env?: Record<string, string> } = {},
|
||||
): string {
|
||||
return this.guest.exec(args, options);
|
||||
}
|
||||
|
||||
private guestOpenClawEntryExec(
|
||||
args: string[],
|
||||
options: { check?: boolean; env?: Record<string, string> } = {},
|
||||
): string {
|
||||
const argv = args.map((arg) => shellQuote(arg)).join(" ");
|
||||
return this.guestSh(
|
||||
return this.guest.sh(
|
||||
`set -e
|
||||
entry="$(npm root -g)/openclaw/openclaw.mjs"
|
||||
exec node "$entry" ${argv}`,
|
||||
|
|
@ -548,10 +458,6 @@ exec node "$entry" ${argv}`,
|
|||
);
|
||||
}
|
||||
|
||||
private guestSh(script: string, env: Record<string, string> = {}): string {
|
||||
return this.guest.sh(script, env);
|
||||
}
|
||||
|
||||
private waitForCurrentUser(timeoutSeconds = 360): void {
|
||||
const prlctlDeadline = Date.now() + 45_000;
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
|
|
@ -559,7 +465,7 @@ exec node "$entry" ${argv}`,
|
|||
const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
});
|
||||
const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
|
||||
if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
|
||||
|
|
@ -582,7 +488,7 @@ exec node "$entry" ${argv}`,
|
|||
const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
});
|
||||
const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
|
||||
if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
|
||||
|
|
@ -600,7 +506,7 @@ exec node "$entry" ${argv}`,
|
|||
run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(30_000),
|
||||
})
|
||||
.stdout.trim()
|
||||
.replaceAll("\r", "")
|
||||
|
|
@ -619,7 +525,7 @@ exec node "$entry" ${argv}`,
|
|||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(30_000),
|
||||
},
|
||||
).stdout.replaceAll("\r", "");
|
||||
for (const line of users.split("\n")) {
|
||||
|
|
@ -650,7 +556,7 @@ exec node "$entry" ${argv}`,
|
|||
`/Users/${user}`,
|
||||
"NFSHomeDirectory",
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
|
||||
{ check: false, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(30_000) },
|
||||
).stdout.replaceAll("\r", "");
|
||||
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
|
||||
return match?.[1]?.trim() || `/Users/${user}`;
|
||||
|
|
@ -670,10 +576,10 @@ exec node "$entry" ${argv}`,
|
|||
const result = run(
|
||||
"prlctl",
|
||||
["snapshot-switch", this.options.vmName, "--id", this.snapshot.id],
|
||||
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(360_000) },
|
||||
{ check: false, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(360_000) },
|
||||
);
|
||||
this.log(result.stdout);
|
||||
this.log(result.stderr);
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
if (result.status === 0) {
|
||||
restored = true;
|
||||
break;
|
||||
|
|
@ -682,16 +588,16 @@ exec node "$entry" ${argv}`,
|
|||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(60_000),
|
||||
}).stdout;
|
||||
if (status.includes(" running") || status.includes(" suspended")) {
|
||||
run("prlctl", ["stop", this.options.vmName, "--kill"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(120_000),
|
||||
});
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
|
||||
});
|
||||
}
|
||||
run("sleep", ["3"], { quiet: true });
|
||||
|
|
@ -702,29 +608,29 @@ exec node "$entry" ${argv}`,
|
|||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(60_000),
|
||||
}).stdout;
|
||||
if (this.snapshot.state === "poweroff" || status.includes(" stopped")) {
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
|
||||
});
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(120_000),
|
||||
});
|
||||
} else if (status.includes(" suspended")) {
|
||||
say(`Resume restored snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(120_000),
|
||||
});
|
||||
}
|
||||
this.waitForCurrentUser();
|
||||
}
|
||||
|
||||
private resetState(): void {
|
||||
this.guestSh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
|
||||
this.guest.sh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true
|
||||
printf 'preflight.user=%s\n' "$(whoami)"
|
||||
|
|
@ -740,7 +646,7 @@ rm -f /tmp/openclaw-parallels-macos-gateway.log`);
|
|||
}
|
||||
|
||||
private installLatestRelease(): void {
|
||||
this.guestSh(
|
||||
this.guest.sh(
|
||||
`export OPENCLAW_NO_ONBOARD=1
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
this.options.installUrl,
|
||||
|
|
@ -753,8 +659,8 @@ ${guestOpenClaw} --version`,
|
|||
private installMain(tempName: string): void {
|
||||
this.guestEnv = npmRegistryEnv(this.options.npmRegistry ?? this.server?.registry?.url);
|
||||
if (this.targetInstallsDirectly()) {
|
||||
this
|
||||
.guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")}
|
||||
this.guest
|
||||
.sh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")}
|
||||
for attempt in 1 2; do
|
||||
if ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")}; then
|
||||
break
|
||||
|
|
@ -772,7 +678,7 @@ ${guestOpenClaw} --version`);
|
|||
die("package artifact/server missing");
|
||||
}
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
|
||||
this.guest.sh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)}
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
|
||||
tgzUrl,
|
||||
)} -o /tmp/${tempName}
|
||||
|
|
@ -795,14 +701,14 @@ ${guestOpenClaw} --version`);
|
|||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
const version = this.guestExec([guestOpenClaw, "--version"]);
|
||||
const version = this.guest.exec([guestOpenClaw, "--version"]);
|
||||
if (!version.includes(needle)) {
|
||||
throw new Error(`version mismatch: expected substring ${needle}`);
|
||||
}
|
||||
}
|
||||
|
||||
private verifyBundlePermissions(): void {
|
||||
this.guestSh(String.raw`set -eu
|
||||
this.guest.sh(String.raw`set -eu
|
||||
root=$(npm root -g)
|
||||
check_path() {
|
||||
path="$1"
|
||||
|
|
@ -825,7 +731,7 @@ fi`);
|
|||
|
||||
private runRefOnboard(): void {
|
||||
const daemonFlag = this.guestTransport === "sudo" ? "--skip-health" : "--install-daemon";
|
||||
this.guestExec([
|
||||
this.guest.exec([
|
||||
"/usr/bin/env",
|
||||
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
|
||||
guestOpenClaw,
|
||||
|
|
@ -860,7 +766,7 @@ fi`);
|
|||
) as { packageManager: string };
|
||||
const spec = packageManager.replace(/\+.*$/u, "");
|
||||
const version = spec.slice("pnpm@".length);
|
||||
this.guestSh(String.raw`set -eu
|
||||
this.guest.sh(String.raw`set -eu
|
||||
bootstrap_root=/tmp/openclaw-smoke-pnpm-bootstrap
|
||||
bootstrap_bin="$bootstrap_root/node_modules/.bin"
|
||||
if [ -x "$bootstrap_bin/pnpm" ] && [ "$("$bootstrap_bin/pnpm" --version)" = ${shellQuote(version)} ]; then
|
||||
|
|
@ -907,7 +813,7 @@ ${guestOpenClawEntryRunner} update status --json`,
|
|||
private verifyDevChannelUpdate(): void {
|
||||
const status = this.guestOpenClawEntryExec(["update", "status", "--json"]);
|
||||
assertDevChannelUpdate(status, this.devTargetCommit, () =>
|
||||
this.guestSh(`git -C ${shellQuote(`${this.guestHome()}/openclaw`)} rev-parse HEAD`),
|
||||
this.guest.sh(`git -C ${shellQuote(`${this.guestHome()}/openclaw`)} rev-parse HEAD`),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -916,7 +822,7 @@ ${guestOpenClawEntryRunner} update status --json`,
|
|||
return;
|
||||
}
|
||||
const home = this.guestHome();
|
||||
this.guestSh(
|
||||
this.guest.sh(
|
||||
`set -euo pipefail
|
||||
trap '' HUP
|
||||
/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
|
||||
|
|
@ -933,10 +839,14 @@ sleep 1`,
|
|||
|
||||
private verifyGateway(): void {
|
||||
for (let attempt = 1; attempt <= 8; attempt++) {
|
||||
const result = this.guestOpenClaw(
|
||||
["gateway", "status", "--deep", "--require-rpc", "--timeout", "15000"],
|
||||
false,
|
||||
);
|
||||
const result = this.guestOpenClaw([
|
||||
"gateway",
|
||||
"status",
|
||||
"--deep",
|
||||
"--require-rpc",
|
||||
"--timeout",
|
||||
"15000",
|
||||
]);
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -949,25 +859,22 @@ sleep 1`,
|
|||
}
|
||||
|
||||
private showGatewayStatusCompat(): void {
|
||||
const help = this.guestExec([guestOpenClaw, "gateway", "status", "--help"], { check: false });
|
||||
const help = this.guest.exec([guestOpenClaw, "gateway", "status", "--help"], { check: false });
|
||||
const args = help.includes("--require-rpc")
|
||||
? ["gateway", "status", "--deep", "--require-rpc"]
|
||||
: ["gateway", "status", "--deep"];
|
||||
if (!this.guestOpenClaw(args, false)) {
|
||||
if (!this.guestOpenClaw(args)) {
|
||||
throw new Error("gateway status failed");
|
||||
}
|
||||
}
|
||||
|
||||
private guestOpenClaw(args: string[], check: boolean): boolean {
|
||||
private guestOpenClaw(args: string[]): boolean {
|
||||
const result = this.guest.run([guestOpenClaw, ...args], { check: false });
|
||||
if (check && result.status !== 0) {
|
||||
throw new Error(`openclaw ${args.join(" ")} failed`);
|
||||
}
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
private verifyDashboardLoad(): void {
|
||||
this.guestSh(String.raw`set -eu
|
||||
this.guest.sh(String.raw`set -eu
|
||||
deadline=$((SECONDS + 120))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
if curl -fsSL --connect-timeout 2 --max-time 5 http://127.0.0.1:18789/ >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then
|
||||
|
|
@ -1004,7 +911,7 @@ exit 1`);
|
|||
}
|
||||
|
||||
private restrictAgentTurnPlugins(): void {
|
||||
this.guestSh(
|
||||
this.guest.sh(
|
||||
posixProviderOnlyPluginIsolationScript({
|
||||
fallbackPluginId: this.options.provider,
|
||||
homeFallback: this.guestHome(),
|
||||
|
|
@ -1015,7 +922,7 @@ exit 1`);
|
|||
}
|
||||
|
||||
private verifyTurn(): void {
|
||||
this.guestSh(
|
||||
this.guest.sh(
|
||||
`set -euo pipefail\n${posixStopGatewayScript(this.guestTransport === "sudo" ? undefined : guestOpenClawEntryRunner)}`,
|
||||
);
|
||||
this.guestOpenClawEntryExec(["models", "set", this.auth.modelId]);
|
||||
|
|
@ -1025,7 +932,7 @@ exit 1`);
|
|||
this.modelTimeoutSeconds,
|
||||
);
|
||||
if (modelProviderConfigBatch) {
|
||||
this.guestSh(`provider_config_batch="$(mktemp)"
|
||||
this.guest.sh(`provider_config_batch="$(mktemp)"
|
||||
cat >"$provider_config_batch" <<'JSON'
|
||||
${modelProviderConfigBatch}
|
||||
JSON
|
||||
|
|
@ -1041,7 +948,7 @@ rm -f "$provider_config_batch"`);
|
|||
]);
|
||||
this.guestOpenClawEntryExec(["config", "set", "tools.profile", "minimal"]);
|
||||
this.restrictAgentTurnPlugins();
|
||||
this.guestSh(
|
||||
this.guest.sh(
|
||||
`${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")}
|
||||
${posixCodexPlatformPackageRepairFunction()}
|
||||
${posixAgentTurnScript({
|
||||
|
|
@ -1055,10 +962,6 @@ ${posixAgentTurnScript({
|
|||
);
|
||||
}
|
||||
|
||||
private configureDiscord(): void {
|
||||
this.discord?.configure();
|
||||
}
|
||||
|
||||
private ensureDiscordGatewayReady(): void {
|
||||
this.startManualGatewayIfNeeded();
|
||||
this.verifyGateway();
|
||||
|
|
@ -1075,10 +978,6 @@ ${posixAgentTurnScript({
|
|||
await this.discord.runRoundtrip(phase);
|
||||
}
|
||||
|
||||
private async cleanupDiscordMessages(): Promise<void> {
|
||||
await this.discord?.cleanupMessages();
|
||||
}
|
||||
|
||||
private async stopVmAfterSuccessfulDiscordSmoke(): Promise<void> {
|
||||
this.discord?.stopVmAfterSuccessfulSmoke(this.status.freshDiscord, this.status.upgradeDiscord);
|
||||
}
|
||||
|
|
@ -1089,7 +988,7 @@ ${posixAgentTurnScript({
|
|||
}
|
||||
return this.guestTransport === "sudo"
|
||||
? this.resolveDesktopHome(this.guestUser)
|
||||
: this.guestExec(["/usr/bin/id", "-P"]).split(":")[8] || `/Users/${this.guestUser}`;
|
||||
: this.guest.exec(["/usr/bin/id", "-P"]).split(":")[8] || `/Users/${this.guestUser}`;
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseName: string): Promise<string> {
|
||||
|
|
@ -1100,8 +999,8 @@ ${posixAgentTurnScript({
|
|||
return this.options.targetPackageSpec ? "latest->target-package" : "latest->dev";
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
const summary: MacosSummary = {
|
||||
protected async writeSummary(): Promise<string> {
|
||||
const summary = {
|
||||
currentHead:
|
||||
this.artifact?.buildCommitShort ||
|
||||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
|
||||
|
|
@ -1151,14 +1050,12 @@ ${posixAgentTurnScript({
|
|||
return summaryPath;
|
||||
}
|
||||
|
||||
private printSummary(summaryPath: string): void {
|
||||
protected printSummary(summaryPath: string): void {
|
||||
process.stdout.write("\nSummary:\n");
|
||||
if (this.options.targetPackageSpec) {
|
||||
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
|
||||
}
|
||||
if (this.installVersion) {
|
||||
process.stdout.write(` baseline-install-version: ${this.installVersion}\n`);
|
||||
}
|
||||
printSmokeTargetSummary({
|
||||
targetPackageSpec: this.options.targetPackageSpec,
|
||||
installVersion: this.installVersion,
|
||||
});
|
||||
process.stdout.write(
|
||||
` fresh-main: ${this.status.freshMain} (${this.status.freshVersion}) discord=${this.status.freshDiscord}\n`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env -S pnpm tsx
|
||||
// Windows Smoke script supports OpenClaw repository automation.
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
|
|
@ -21,9 +20,7 @@ import {
|
|||
withProgressOnStderr,
|
||||
writeSummaryMarkdown,
|
||||
writeJson,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
type ProviderAuth,
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
|
|
@ -62,33 +59,6 @@ interface WindowsOptions extends SmokeCliOptions {
|
|||
skipLatestRefCheck: boolean;
|
||||
}
|
||||
|
||||
interface WindowsSummary {
|
||||
vm: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
latestVersion: string;
|
||||
installVersion: string;
|
||||
targetPackageSpec: string;
|
||||
currentHead: string;
|
||||
runDir: string;
|
||||
freshMain: {
|
||||
status: string;
|
||||
version: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
upgrade: {
|
||||
precheck: string;
|
||||
status: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
gateway: string;
|
||||
agent: string;
|
||||
};
|
||||
}
|
||||
|
||||
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS = 900;
|
||||
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS = WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS * 1000;
|
||||
|
||||
|
|
@ -286,18 +256,18 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
}
|
||||
|
||||
protected async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phase("fresh.ensure-git", 1200, () =>
|
||||
await this.phases.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phases.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phases.phase("fresh.ensure-git", 1200, () =>
|
||||
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
|
||||
);
|
||||
await this.phase("fresh.preflight", 120, () => this.logGuestPreflight(true));
|
||||
await this.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
|
||||
await this.phases.phase("fresh.preflight", 120, () => this.logGuestPreflight(true));
|
||||
await this.phases.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
|
||||
this.installMain("openclaw-main-fresh.tgz"),
|
||||
);
|
||||
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
|
||||
await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
|
||||
await this.phase("fresh.install-companions", 600, () =>
|
||||
await this.phases.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
|
||||
await this.phases.phase("fresh.install-companions", 600, () =>
|
||||
installSmokeRuntimeCompanions({
|
||||
provider: this.options.provider,
|
||||
readCli: (args) =>
|
||||
|
|
@ -310,26 +280,28 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
),
|
||||
}),
|
||||
);
|
||||
await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
await this.phases.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phases.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phases.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase("fresh.gateway-stop-before-local-agent", 420, () =>
|
||||
await this.phases.phase("fresh.gateway-stop-before-local-agent", 420, () =>
|
||||
this.gatewayAction("stop"),
|
||||
);
|
||||
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
await this.phases.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
protected async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phase("upgrade.ensure-git", 1200, () =>
|
||||
await this.phases.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
|
||||
await this.phases.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
|
||||
await this.phases.phase("upgrade.ensure-git", 1200, () =>
|
||||
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
|
||||
);
|
||||
await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
|
||||
await this.phases.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
|
||||
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
|
||||
await this.phase(
|
||||
await this.phases.phase(
|
||||
"upgrade.install-baseline-package",
|
||||
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
|
||||
() => this.installMain("openclaw-main-upgrade.tgz"),
|
||||
|
|
@ -337,24 +309,26 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
this.status.latestInstalledVersion = await this.extractLastVersion(
|
||||
"upgrade.install-baseline-package",
|
||||
);
|
||||
await this.phase("upgrade.verify-baseline-package-version", 120, () =>
|
||||
await this.phases.phase("upgrade.verify-baseline-package-version", 120, () =>
|
||||
this.verifyTargetVersion(),
|
||||
);
|
||||
} else {
|
||||
await this.phase("upgrade.install-baseline", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
|
||||
this.installLatestRelease(),
|
||||
await this.phases.phase(
|
||||
"upgrade.install-baseline",
|
||||
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
|
||||
() => this.installLatestRelease(),
|
||||
);
|
||||
this.status.latestInstalledVersion = await this.extractLastVersion(
|
||||
"upgrade.install-baseline",
|
||||
);
|
||||
await this.phase("upgrade.verify-baseline-version", 120, () =>
|
||||
await this.phases.phase("upgrade.verify-baseline-version", 120, () =>
|
||||
this.verifyVersionContains(this.installVersion),
|
||||
);
|
||||
}
|
||||
if (this.options.skipLatestRefCheck) {
|
||||
this.status.upgradePrecheck = "skipped";
|
||||
} else if (
|
||||
await this.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
|
||||
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
|
||||
this.captureLatestRefFailure(),
|
||||
)
|
||||
) {
|
||||
|
|
@ -362,38 +336,28 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
} else {
|
||||
this.status.upgradePrecheck = "latest-ref-fail";
|
||||
}
|
||||
await this.phase("upgrade.gateway-stop-before-update", 420, () => this.gatewayAction("stop"));
|
||||
await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
|
||||
await this.phases.phase("upgrade.gateway-stop-before-update", 420, () =>
|
||||
this.gatewayAction("stop"),
|
||||
);
|
||||
await this.phases.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
|
||||
this.runDevChannelUpdate(),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
|
||||
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
|
||||
await this.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
|
||||
await this.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
await this.phases.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
|
||||
await this.phases.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
|
||||
await this.phases.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
|
||||
await this.phases.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phases.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase("upgrade.gateway-stop-before-local-agent", 420, () =>
|
||||
await this.phases.phase("upgrade.gateway-stop-before-local-agent", 420, () =>
|
||||
this.gatewayAction("stop"),
|
||||
);
|
||||
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
await this.phases.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
|
||||
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
|
||||
this.phases.remainingTimeoutMs(fallbackMs);
|
||||
|
||||
private phaseReturns = async (
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<boolean> => await this.phases.phaseReturns(name, timeoutSeconds, fn);
|
||||
|
||||
private log = (text: string): void => this.phases.append(text);
|
||||
|
||||
private guestPowerShell(
|
||||
script: string,
|
||||
options: { check?: boolean; timeoutMs?: number } = {},
|
||||
|
|
@ -418,11 +382,11 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
},
|
||||
);
|
||||
this.log(result.stdout);
|
||||
this.log(result.stderr);
|
||||
this.phases.append(result.stdout);
|
||||
this.phases.append(result.stderr);
|
||||
if (result.status === 0) {
|
||||
restored = true;
|
||||
break;
|
||||
|
|
@ -439,8 +403,8 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
}
|
||||
this.waitForVmNotRestoring(240);
|
||||
ensureVmRunning(this.options.vmName, 240, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
|
||||
probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000),
|
||||
transitionTimeoutMs: () => this.phases.remainingTimeoutMs(120_000),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -450,7 +414,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(30_000),
|
||||
}).stdout;
|
||||
if (!status.includes(" restoring")) {
|
||||
return;
|
||||
|
|
@ -469,7 +433,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
|||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
timeoutMs: this.phases.remainingTimeoutMs(),
|
||||
},
|
||||
);
|
||||
if (result.status === 0) {
|
||||
|
|
@ -505,7 +469,7 @@ $script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -Time
|
|||
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
|
||||
Invoke-OpenClaw --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
|
||||
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
|
||||
this.phases.remainingTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
|
||||
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
|
@ -525,23 +489,20 @@ npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error
|
|||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" }
|
||||
Invoke-OpenClaw --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
|
||||
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
|
||||
this.phases.remainingTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
|
||||
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyTargetVersion(): Promise<void> {
|
||||
if (this.options.targetPackageSpec) {
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
|
||||
return;
|
||||
}
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
|
||||
this.verifyVersionContains(
|
||||
await (this.options.targetPackageSpec
|
||||
? expectedPackageTargetVersion(this.artifact)
|
||||
: expectedPackageBuildCommit(this.artifact)),
|
||||
);
|
||||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
|
|
@ -586,7 +547,7 @@ ${this.windowsPluginIsolationScript()}`,
|
|||
): Promise<void> {
|
||||
await runWindowsBackgroundPowerShell({
|
||||
append: (chunk) =>
|
||||
this.log(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
|
||||
this.phases.append(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
|
||||
beforeLaunchAttempt: () => {
|
||||
ensureVmRunning(this.options.vmName, 120);
|
||||
this.waitForGuestReady(120);
|
||||
|
|
@ -595,7 +556,7 @@ ${this.windowsPluginIsolationScript()}`,
|
|||
env: this.guestEnv,
|
||||
onLaunchRetry: warn,
|
||||
script: `${windowsOpenClawResolver}\n${script}`,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(timeoutMs) ?? timeoutMs,
|
||||
timeoutMs: this.phases.remainingTimeoutMs(timeoutMs) ?? timeoutMs,
|
||||
vmName: this.options.vmName,
|
||||
});
|
||||
}
|
||||
|
|
@ -774,7 +735,7 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
|
|||
status: this.status,
|
||||
vmName: this.options.vmName,
|
||||
});
|
||||
const summary: WindowsSummary = {
|
||||
const summary = {
|
||||
...common,
|
||||
upgrade: {
|
||||
...common.upgrade,
|
||||
|
|
|
|||
|
|
@ -111,18 +111,14 @@ function generateTypes(db: DatabaseSync): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function readUtf8(file: string): string {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function generate(schema: SchemaTarget): void {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
try {
|
||||
db.exec(readUtf8(schema.schema));
|
||||
db.exec(fs.readFileSync(schema.schema, "utf8"));
|
||||
const typesSource = generateTypes(db);
|
||||
|
||||
if (verify) {
|
||||
if (typesSource !== readUtf8(schema.outFile)) {
|
||||
if (typesSource !== fs.readFileSync(schema.outFile, "utf8")) {
|
||||
console.error(`${schema.outFile} is out of date. Run pnpm db:kysely:gen.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,16 +23,14 @@ export async function runCancelableCommand(run: (signal: AbortSignal) => Promise
|
|||
process.on(signal, handler);
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const status = await run(controller.signal);
|
||||
return received ? signalExitCode(received) : status;
|
||||
} catch (error) {
|
||||
// Unverified extinction must reach artifact ownership, never become an exit code.
|
||||
if (!received || !isCommandCancellation(error)) {
|
||||
throw error;
|
||||
}
|
||||
return signalExitCode(received);
|
||||
const status = await run(controller.signal);
|
||||
return received ? signalExitCode(received) : status;
|
||||
} catch (error) {
|
||||
// Unverified extinction must reach artifact ownership, never become an exit code.
|
||||
if (!received || !isCommandCancellation(error)) {
|
||||
throw error;
|
||||
}
|
||||
return signalExitCode(received);
|
||||
} finally {
|
||||
for (const [signal, handler] of handlers) {
|
||||
process.off(signal, handler);
|
||||
|
|
|
|||
|
|
@ -261,6 +261,10 @@ function price(row: ModeResult): number | null {
|
|||
return row.accounting?.costComplete ? (row.accounting.costUsd ?? null) : null;
|
||||
}
|
||||
|
||||
function measuredRatio(direct: number | null, code: number | null): number | null {
|
||||
return direct !== null && direct > 0 && code !== null ? code / direct : null;
|
||||
}
|
||||
|
||||
function percentile(sorted: readonly number[], quantile: number): number | null {
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
|
|
@ -322,7 +326,7 @@ function pairMeasurement(pair: ModePair, measure: (row: ModeResult) => number |
|
|||
const direct = measure(pair.direct);
|
||||
const code = measure(pair.code);
|
||||
return completedTask(pair.direct) && completedTask(pair.code) && direct !== null && code !== null
|
||||
? { direct, code, delta: code - direct, ratio: direct > 0 ? code / direct : null }
|
||||
? { direct, code, delta: code - direct, ratio: measuredRatio(direct, code) }
|
||||
: null;
|
||||
}
|
||||
|
||||
|
|
@ -335,14 +339,11 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
|
|||
);
|
||||
const usageComplete = unmatched === 0 && direct.usageMissing === 0 && code.usageMissing === 0;
|
||||
const costComplete = usageComplete && direct.costMissing === 0 && code.costMissing === 0;
|
||||
const tokenDeltas = successfulPairs.flatMap((pair) => {
|
||||
const observation = pairMeasurement(pair, tokens);
|
||||
return observation ? [observation.delta] : [];
|
||||
});
|
||||
const costDeltas = successfulPairs.flatMap((pair) => {
|
||||
const observation = pairMeasurement(pair, price);
|
||||
return observation ? [observation.delta] : [];
|
||||
});
|
||||
const pairedDeltas = (measure: (row: ModeResult) => number | null) =>
|
||||
successfulPairs.flatMap((pair) => {
|
||||
const observation = pairMeasurement(pair, measure);
|
||||
return observation ? [observation.delta] : [];
|
||||
});
|
||||
const latenciesComplete = successfulPairs.every(
|
||||
(pair) =>
|
||||
pair.direct.gateway?.taskElapsedMs !== undefined &&
|
||||
|
|
@ -361,8 +362,8 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
|
|||
code,
|
||||
pairedSuccessDifference: distribution(successDeltas),
|
||||
pairedSuccessfulDeltas: {
|
||||
totalTokens: usageComplete ? distribution(tokenDeltas) : null,
|
||||
costUsd: costComplete ? distribution(costDeltas) : null,
|
||||
totalTokens: usageComplete ? distribution(pairedDeltas(tokens)) : null,
|
||||
costUsd: costComplete ? distribution(pairedDeltas(price)) : null,
|
||||
taskElapsedMs:
|
||||
unmatched === 0 && latenciesComplete
|
||||
? distribution(
|
||||
|
|
@ -373,20 +374,12 @@ function summarizeModeGroup(rows: readonly ModeResult[], pairs: readonly ModePai
|
|||
: null,
|
||||
},
|
||||
operationalRatios: {
|
||||
totalTokensPerCompletedTask:
|
||||
usageComplete &&
|
||||
direct.tokensPerCompletedTask !== null &&
|
||||
direct.tokensPerCompletedTask > 0 &&
|
||||
code.tokensPerCompletedTask !== null
|
||||
? code.tokensPerCompletedTask / direct.tokensPerCompletedTask
|
||||
: null,
|
||||
costPerCompletedTask:
|
||||
costComplete &&
|
||||
direct.costPerCompletedTask !== null &&
|
||||
direct.costPerCompletedTask > 0 &&
|
||||
code.costPerCompletedTask !== null
|
||||
? code.costPerCompletedTask / direct.costPerCompletedTask
|
||||
: null,
|
||||
totalTokensPerCompletedTask: usageComplete
|
||||
? measuredRatio(direct.tokensPerCompletedTask, code.tokensPerCompletedTask)
|
||||
: null,
|
||||
costPerCompletedTask: costComplete
|
||||
? measuredRatio(direct.costPerCompletedTask, code.costPerCompletedTask)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
|
||||
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
|
||||
import type { MatrixPerformanceFixture } from "./code-mode-matrix-performance-types.ts";
|
||||
|
||||
|
|
@ -245,12 +246,7 @@ api.registerTool({
|
|||
fs.readFile(path.join(params.workspace, name), "utf8").catch(() => undefined),
|
||||
),
|
||||
);
|
||||
let actual: unknown;
|
||||
try {
|
||||
actual = JSON.parse(jsonText ?? "");
|
||||
} catch {
|
||||
actual = undefined;
|
||||
}
|
||||
const actual = safeParseJson(jsonText ?? "");
|
||||
const receipts = params.receipts.filter(
|
||||
(row): row is Record<string, unknown> => typeof row === "object" && row !== null,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
|
||||
import type {
|
||||
|
|
@ -58,14 +59,6 @@ const REPORT_SCHEMA = {
|
|||
additionalProperties: false,
|
||||
};
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function finite(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
|
@ -308,7 +301,7 @@ Every report uses nonce ${nonce}. After all children finish, write ${ARTIFACT} w
|
|||
});
|
||||
const raw = await fs.readFile(path.join(params.workspace, ARTIFACT), "utf8").catch(() => "");
|
||||
return {
|
||||
finalArtifact: isDeepStrictEqual(parseJson(raw), expected),
|
||||
finalArtifact: isDeepStrictEqual(safeParseJson(raw), expected),
|
||||
exactSubmissions: submissions.length === 7 && submittedByChildren,
|
||||
collectorLaunches:
|
||||
launches.length === 7 &&
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
|
||||
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
|
||||
import { isRecord as record } from "@openclaw/normalization-core/record-coerce";
|
||||
import { parse } from "acorn";
|
||||
|
|
@ -350,6 +351,11 @@ function outputDetails(message: RecordValue): RecordValue {
|
|||
return record(message.details) ? message.details : {};
|
||||
}
|
||||
|
||||
function addObservedNumber(total: number | undefined, value: unknown): number | undefined {
|
||||
const number = asFiniteNumber(value);
|
||||
return total !== undefined && number !== undefined ? total + number : undefined;
|
||||
}
|
||||
|
||||
/** Only actual assistant calls and persisted terminal activity count as execution. */
|
||||
export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMatrixTrace {
|
||||
const calls: ToolCall[] = [];
|
||||
|
|
@ -358,16 +364,12 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
|
|||
const models = new Set<string>();
|
||||
let assistantTurns = 0;
|
||||
let usageSamples = 0;
|
||||
let costSamples = 0;
|
||||
let totalSamples = 0;
|
||||
let cacheReadSamples = 0;
|
||||
let cacheWriteSamples = 0;
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let total = 0;
|
||||
let costUsd = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let total: number | undefined = 0;
|
||||
let costUsd: number | undefined = 0;
|
||||
let cacheRead: number | undefined = 0;
|
||||
let cacheWrite: number | undefined = 0;
|
||||
for (const [eventIndex, event] of events.entries()) {
|
||||
if (!record(event)) {
|
||||
continue;
|
||||
|
|
@ -387,26 +389,10 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
|
|||
usageSamples += 1;
|
||||
input += inputTokens;
|
||||
output += outputTokens;
|
||||
const totalTokens = asFiniteNumber(usage.totalTokens);
|
||||
if (totalTokens !== undefined) {
|
||||
totalSamples += 1;
|
||||
total += totalTokens;
|
||||
}
|
||||
const readTokens = asFiniteNumber(usage.cacheRead);
|
||||
if (readTokens !== undefined) {
|
||||
cacheReadSamples += 1;
|
||||
cacheRead += readTokens;
|
||||
}
|
||||
const writeTokens = asFiniteNumber(usage.cacheWrite);
|
||||
if (writeTokens !== undefined) {
|
||||
cacheWriteSamples += 1;
|
||||
cacheWrite += writeTokens;
|
||||
}
|
||||
const cost = record(usage.cost) ? asFiniteNumber(usage.cost.total) : undefined;
|
||||
if (cost !== undefined) {
|
||||
costSamples += 1;
|
||||
costUsd += cost;
|
||||
}
|
||||
total = addObservedNumber(total, usage.totalTokens);
|
||||
cacheRead = addObservedNumber(cacheRead, usage.cacheRead);
|
||||
cacheWrite = addObservedNumber(cacheWrite, usage.cacheWrite);
|
||||
costUsd = addObservedNumber(costUsd, record(usage.cost) ? usage.cost.total : undefined);
|
||||
}
|
||||
for (const block of Array.isArray(message.content) ? message.content : []) {
|
||||
if (
|
||||
|
|
@ -488,24 +474,18 @@ export function collectGatewayMatrixTrace(events: readonly unknown[]): GatewayMa
|
|||
usage: {
|
||||
input,
|
||||
output,
|
||||
...(totalSamples === assistantTurns ? { total } : {}),
|
||||
...(cacheReadSamples === assistantTurns ? { cacheRead } : {}),
|
||||
...(cacheWriteSamples === assistantTurns ? { cacheWrite } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
...(cacheRead !== undefined ? { cacheRead } : {}),
|
||||
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(costSamples === assistantTurns && assistantTurns > 0 ? { costUsd } : {}),
|
||||
...(usageSamples === assistantTurns && assistantTurns > 0 && costUsd !== undefined
|
||||
? { costUsd }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonAnswer(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text.trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function callOutcomes(trace: GatewayMatrixTrace, call: ToolCall): ToolOutcome[] {
|
||||
let outcome = trace.outcomes.findLast((item) => item.id === call.id);
|
||||
const outcomes = outcome ? [outcome] : [];
|
||||
|
|
@ -652,7 +632,7 @@ export function evaluateGatewayMatrixTask(params: {
|
|||
trace.calls.some((call) => call.id === item.parentId && call.name === "exec");
|
||||
const activity = trace.activities.filter((item) => !item.isError);
|
||||
const checks: BehaviorChecks = {
|
||||
answer: isDeepStrictEqual(jsonAnswer(params.final), expected),
|
||||
answer: isDeepStrictEqual(safeParseJson(params.final.trim()), expected),
|
||||
actualCodeMode:
|
||||
trace.calls.some((call) => call.name === "exec") &&
|
||||
trace.outcomes.some((outcome) =>
|
||||
|
|
@ -1097,7 +1077,7 @@ export function evaluateGatewayMatrixInterview(
|
|||
.map((reference) => reference.previewComplete),
|
||||
);
|
||||
const previewComplete = previewCoverage.size === 1 ? [...previewCoverage][0] : null;
|
||||
const answer = jsonAnswer(final);
|
||||
const answer = safeParseJson(final.trim());
|
||||
const facts = record(answer) && record(answer.facts) ? answer.facts : {};
|
||||
return {
|
||||
answered:
|
||||
|
|
@ -1362,6 +1342,8 @@ export async function runGatewayMatrixCell(
|
|||
const pluginDir = path.join(root, "fixture");
|
||||
const receiptsPath = path.join(root, "receipts.jsonl");
|
||||
const artifactDir = path.join(params.outputDir, "cells", params.cell.id);
|
||||
const artifactPath = (name: string) =>
|
||||
path.relative(params.outputDir, path.join(artifactDir, name));
|
||||
const rootSessionKey = `agent:qa:matrix:${randomUUID()}`;
|
||||
const token = `synthetic-matrix-${randomUUID()}`;
|
||||
const redact = (value: string) =>
|
||||
|
|
@ -1893,32 +1875,19 @@ export async function runGatewayMatrixCell(
|
|||
traceAvailable: ledger !== undefined && interviewStartedAt !== undefined,
|
||||
...(interviewElapsedMs !== undefined ? { elapsedMs: interviewElapsedMs } : {}),
|
||||
...(interviewAccounting ? { accounting: interviewAccounting } : {}),
|
||||
answer: jsonAnswer(interview.final) ?? interview.final,
|
||||
answer: safeParseJson(interview.final.trim()) ?? interview.final,
|
||||
rationaleReview: "required",
|
||||
checks: interviewChecks,
|
||||
...(interviewStartedAt !== undefined ? { trace: interviewTrace } : {}),
|
||||
},
|
||||
artifacts: {
|
||||
taskTrace: path.relative(params.outputDir, path.join(artifactDir, "task-transcript.json")),
|
||||
interviewTrace: path.relative(
|
||||
params.outputDir,
|
||||
path.join(artifactDir, "interview-transcript.json"),
|
||||
),
|
||||
receipts: path.relative(params.outputDir, path.join(artifactDir, "receipts.json")),
|
||||
taskReceipts: path.relative(params.outputDir, path.join(artifactDir, "task-receipts.json")),
|
||||
interviewReceipts: path.relative(
|
||||
params.outputDir,
|
||||
path.join(artifactDir, "interview-receipts.json"),
|
||||
),
|
||||
log: path.relative(params.outputDir, path.join(artifactDir, "gateway.log")),
|
||||
...(deliveredFiles
|
||||
? {
|
||||
deliveredFiles: path.relative(
|
||||
params.outputDir,
|
||||
path.join(artifactDir, "delivered-files.json"),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
taskTrace: artifactPath("task-transcript.json"),
|
||||
interviewTrace: artifactPath("interview-transcript.json"),
|
||||
receipts: artifactPath("receipts.json"),
|
||||
taskReceipts: artifactPath("task-receipts.json"),
|
||||
interviewReceipts: artifactPath("interview-receipts.json"),
|
||||
log: artifactPath("gateway.log"),
|
||||
...(deliveredFiles ? { deliveredFiles: artifactPath("delivered-files.json") } : {}),
|
||||
},
|
||||
};
|
||||
await write("evidence.json", {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { safeParseJson } from "@openclaw/normalization-core/json-coercion";
|
||||
import { isRecord as isRow } from "@openclaw/normalization-core/record-coerce";
|
||||
import { createGatewayMatrixPluginSource } from "./code-mode-matrix-gateway-fixtures.ts";
|
||||
import type { MatrixPerformanceFixture } from "./code-mode-matrix-performance-types.ts";
|
||||
|
|
@ -199,12 +200,9 @@ Write ${REPORT_PATH} with exactly these JSON fields: {batchId,sourceLineCount,se
|
|||
.filter(isRow)
|
||||
.toSorted((a, b) => String(a.operationId).localeCompare(String(b.operationId)))
|
||||
: [];
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(workspace, REPORT_PATH), "utf8"));
|
||||
} catch {
|
||||
report = undefined;
|
||||
}
|
||||
const report = safeParseJson(
|
||||
await fs.readFile(path.join(workspace, REPORT_PATH), "utf8").catch(() => ""),
|
||||
);
|
||||
return {
|
||||
completeSourceRead: rows.some((row) => row.kind === "call" && row.tool === TOOLS.batch),
|
||||
requiredFailuresObserved: allFaultsObserved && uncertain.length === 4,
|
||||
|
|
|
|||
32
scripts/lib/protocol-codegen-schema.ts
Normal file
32
scripts/lib/protocol-codegen-schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export type JsonSchema = {
|
||||
"~openclawClosedObjectIdentity"?: symbol;
|
||||
type?: string | string[];
|
||||
const?: boolean | number | string | null;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: Array<boolean | number | string | null>;
|
||||
patternProperties?: Record<string, JsonSchema>;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
};
|
||||
|
||||
function stableJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJson);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted()
|
||||
.map((key) => [key, stableJson(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function schemaSignature(schema: JsonSchema): string {
|
||||
return JSON.stringify(stableJson(schema));
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ type PublicationExit = ReliabilityReport["publicationInterruptionProof"]["before
|
|||
|
||||
type CrashPointResult = {
|
||||
exit: PublicationExit;
|
||||
sourceStatePreserved: true;
|
||||
stagingEntries: number;
|
||||
targetState: ReliabilityStateProof | null;
|
||||
targetVisibleAfterCrash: boolean;
|
||||
|
|
@ -105,11 +104,10 @@ async function runCrashPoint(params: {
|
|||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
|
||||
let crashStagingEntries: string[];
|
||||
try {
|
||||
await waitForCrashPoint({ child, markerPath, readStderr: () => stderr });
|
||||
const targetVisibleAfterCrash = fs.existsSync(targetPath);
|
||||
crashStagingEntries = listCrashStagingEntries(params.scratchPath);
|
||||
const crashStagingEntries = listCrashStagingEntries(params.scratchPath);
|
||||
if (crashStagingEntries.length === 0) {
|
||||
throw new Error(`SQLite publication worker reached ${params.crashPoint} without staging.`);
|
||||
}
|
||||
|
|
@ -136,8 +134,6 @@ async function runCrashPoint(params: {
|
|||
targetPath,
|
||||
});
|
||||
assertNoSqliteSidecars(targetPath);
|
||||
const retryState = params.verifyDatabase(targetPath);
|
||||
assertSameReliabilityState(retryState, params.expectedState, `${params.crashPoint} retry`);
|
||||
} else {
|
||||
const targetHash = hashFile(targetPath);
|
||||
let retryError: unknown;
|
||||
|
|
@ -158,13 +154,9 @@ async function runCrashPoint(params: {
|
|||
if (hashFile(targetPath) !== targetHash) {
|
||||
throw new Error("SQLite retry changed the already-published target.");
|
||||
}
|
||||
const preservedState = params.verifyDatabase(targetPath);
|
||||
assertSameReliabilityState(
|
||||
preservedState,
|
||||
params.expectedState,
|
||||
`${params.crashPoint} retry`,
|
||||
);
|
||||
}
|
||||
const retryState = params.verifyDatabase(targetPath);
|
||||
assertSameReliabilityState(retryState, params.expectedState, `${params.crashPoint} retry`);
|
||||
for (const entry of crashStagingEntries) {
|
||||
if (!fs.existsSync(path.join(params.scratchPath, entry))) {
|
||||
throw new Error(`SQLite retry removed crash staging it did not own: ${entry}`);
|
||||
|
|
@ -173,7 +165,6 @@ async function runCrashPoint(params: {
|
|||
|
||||
return {
|
||||
exit,
|
||||
sourceStatePreserved: true,
|
||||
stagingEntries: crashStagingEntries.length,
|
||||
targetState,
|
||||
targetVisibleAfterCrash,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
PROFILES,
|
||||
STRESS_TABLE_SQL,
|
||||
type CliOptions,
|
||||
type CompactionPayloadProof,
|
||||
type ReliabilityReport,
|
||||
type ReliabilityStateProof,
|
||||
} from "./sqlite-reliability-contract.js";
|
||||
|
|
@ -43,17 +44,7 @@ import {
|
|||
type WriterHandle,
|
||||
} from "./sqlite-reliability-writer.js";
|
||||
|
||||
type TargetDatabase = {
|
||||
identity: SnapshotDatabaseIdentity;
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type IterationMetric = {
|
||||
restoreMs: number;
|
||||
snapshotBytes: number;
|
||||
snapshotMs: number;
|
||||
};
|
||||
type TargetDatabase = ReturnType<typeof resolveTargetDatabase>;
|
||||
|
||||
type CompactionProof = ReliabilityReport["maintenanceProof"]["compaction"];
|
||||
|
||||
|
|
@ -100,7 +91,7 @@ function fileSize(pathname: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv): TargetDatabase {
|
||||
function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv) {
|
||||
if (options.agentId) {
|
||||
const database = openOpenClawAgentDatabase({ agentId: options.agentId, env });
|
||||
const target = {
|
||||
|
|
@ -123,14 +114,24 @@ function resolveTargetDatabase(options: CliOptions, env: NodeJS.ProcessEnv): Tar
|
|||
}
|
||||
|
||||
function setupStressTable(databasePath: string): void {
|
||||
const database = openNodeSqliteDatabase(databasePath);
|
||||
try {
|
||||
withReliabilityDatabase(databasePath, (database) => {
|
||||
database.exec("PRAGMA journal_mode = WAL;");
|
||||
database.exec("PRAGMA busy_timeout = 30000;");
|
||||
database.exec(STRESS_TABLE_SQL);
|
||||
database.exec("DROP TABLE IF EXISTS openclaw_reliability_compaction_bloat;");
|
||||
database.prepare("DELETE FROM openclaw_reliability_entries").run();
|
||||
database.prepare("DELETE FROM openclaw_reliability_sentinel").run();
|
||||
});
|
||||
}
|
||||
|
||||
function withReliabilityDatabase<T>(
|
||||
databasePath: string,
|
||||
operation: (database: DatabaseSync) => T,
|
||||
options?: Parameters<typeof openNodeSqliteDatabase>[1],
|
||||
): T {
|
||||
const database = openNodeSqliteDatabase(databasePath, options);
|
||||
try {
|
||||
return operation(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
|
@ -215,49 +216,48 @@ function verifyRestoredDatabase(params: {
|
|||
rowsPerBatch: number;
|
||||
uncommittedBatch: number | null;
|
||||
}): ReliabilityStateProof {
|
||||
const database = openNodeSqliteDatabase(params.path, {
|
||||
readOnly: params.readOnly ?? true,
|
||||
});
|
||||
try {
|
||||
database.exec("PRAGMA trusted_schema = OFF;");
|
||||
assertPragmaOk(database, "quick_check");
|
||||
assertPragmaOk(database, "integrity_check");
|
||||
const foreignKeys = database.prepare("PRAGMA foreign_key_check;").all();
|
||||
if (foreignKeys.length > 0) {
|
||||
throw new Error(`foreign_key_check failed with ${foreignKeys.length} row(s)`);
|
||||
}
|
||||
if (params.identity.role === "global") {
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname: params.path });
|
||||
} else if (params.identity.role === "agent") {
|
||||
assertOpenClawAgentDatabaseForMaintenance(database, {
|
||||
agentId: params.identity.agentId,
|
||||
pathname: params.path,
|
||||
});
|
||||
}
|
||||
const sentinel = database
|
||||
.prepare("SELECT payload FROM openclaw_reliability_sentinel WHERE id = 1")
|
||||
.get() as { payload?: unknown } | undefined;
|
||||
if (sentinel?.payload !== COMMITTED_WAL_SENTINEL) {
|
||||
throw new Error("committed WAL sentinel is missing after restore");
|
||||
}
|
||||
const state = readReliabilityState(database, params.rowsPerBatch);
|
||||
if (params.uncommittedBatch !== null) {
|
||||
const held = database
|
||||
.prepare("SELECT COUNT(*) AS rows FROM openclaw_reliability_entries WHERE batch = ?")
|
||||
.get(params.uncommittedBatch) as { rows?: unknown };
|
||||
if (Number(held.rows) !== 0) {
|
||||
throw new Error(
|
||||
`uncommitted transaction became visible after restore: batch=${params.uncommittedBatch} rows=${String(held.rows)}`,
|
||||
);
|
||||
return withReliabilityDatabase(
|
||||
params.path,
|
||||
(database) => {
|
||||
database.exec("PRAGMA trusted_schema = OFF;");
|
||||
assertPragmaOk(database, "quick_check");
|
||||
assertPragmaOk(database, "integrity_check");
|
||||
const foreignKeys = database.prepare("PRAGMA foreign_key_check;").all();
|
||||
if (foreignKeys.length > 0) {
|
||||
throw new Error(`foreign_key_check failed with ${foreignKeys.length} row(s)`);
|
||||
}
|
||||
}
|
||||
if (params.expectedState) {
|
||||
assertSameReliabilityState(state, params.expectedState, params.path);
|
||||
}
|
||||
return state;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
if (params.identity.role === "global") {
|
||||
assertOpenClawStateDatabaseForMaintenance(database, { pathname: params.path });
|
||||
} else if (params.identity.role === "agent") {
|
||||
assertOpenClawAgentDatabaseForMaintenance(database, {
|
||||
agentId: params.identity.agentId,
|
||||
pathname: params.path,
|
||||
});
|
||||
}
|
||||
const sentinel = database
|
||||
.prepare("SELECT payload FROM openclaw_reliability_sentinel WHERE id = 1")
|
||||
.get() as { payload?: unknown } | undefined;
|
||||
if (sentinel?.payload !== COMMITTED_WAL_SENTINEL) {
|
||||
throw new Error("committed WAL sentinel is missing after restore");
|
||||
}
|
||||
const state = readReliabilityState(database, params.rowsPerBatch);
|
||||
if (params.uncommittedBatch !== null) {
|
||||
const held = database
|
||||
.prepare("SELECT COUNT(*) AS rows FROM openclaw_reliability_entries WHERE batch = ?")
|
||||
.get(params.uncommittedBatch) as { rows?: unknown };
|
||||
if (Number(held.rows) !== 0) {
|
||||
throw new Error(
|
||||
`uncommitted transaction became visible after restore: batch=${params.uncommittedBatch} rows=${String(held.rows)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (params.expectedState) {
|
||||
assertSameReliabilityState(state, params.expectedState, params.path);
|
||||
}
|
||||
return state;
|
||||
},
|
||||
{ readOnly: params.readOnly ?? true },
|
||||
);
|
||||
}
|
||||
|
||||
function writeCompactionBloatRange(
|
||||
|
|
@ -266,9 +266,8 @@ function writeCompactionBloatRange(
|
|||
lastId: number,
|
||||
reset = false,
|
||||
): void {
|
||||
const database = openNodeSqliteDatabase(databasePath);
|
||||
const payload = "b".repeat(COMPACTION_BLOAT_PAYLOAD_BYTES);
|
||||
try {
|
||||
withReliabilityDatabase(databasePath, (database) => {
|
||||
const payload = "b".repeat(COMPACTION_BLOAT_PAYLOAD_BYTES);
|
||||
database.exec("PRAGMA journal_mode = WAL;");
|
||||
database.exec("PRAGMA wal_autocheckpoint = 0;");
|
||||
database.exec("PRAGMA busy_timeout = 30000;");
|
||||
|
|
@ -295,40 +294,34 @@ function writeCompactionBloatRange(
|
|||
throw error;
|
||||
}
|
||||
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readCompactionPayload(databasePath: string): {
|
||||
bytes: number;
|
||||
idSum: number;
|
||||
rows: number;
|
||||
} {
|
||||
const database = openNodeSqliteDatabase(databasePath, { readOnly: true });
|
||||
try {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
function readCompactionPayload(databasePath: string): CompactionPayloadProof {
|
||||
return withReliabilityDatabase(
|
||||
databasePath,
|
||||
(database) => {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS rows,
|
||||
COALESCE(SUM(id), 0) AS id_sum,
|
||||
COALESCE(SUM(length(payload)), 0) AS bytes
|
||||
FROM openclaw_reliability_compaction_bloat`,
|
||||
)
|
||||
.get() as { bytes?: unknown; id_sum?: unknown; rows?: unknown };
|
||||
return {
|
||||
bytes: sqliteSafeInteger(row.bytes, "compaction payload bytes"),
|
||||
idSum: sqliteSafeInteger(row.id_sum, "compaction payload id sum"),
|
||||
rows: sqliteSafeInteger(row.rows, "compaction payload rows"),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
)
|
||||
.get() as { bytes?: unknown; id_sum?: unknown; rows?: unknown };
|
||||
return {
|
||||
bytes: sqliteSafeInteger(row.bytes, "compaction payload bytes"),
|
||||
idSum: sqliteSafeInteger(row.id_sum, "compaction payload id sum"),
|
||||
rows: sqliteSafeInteger(row.rows, "compaction payload rows"),
|
||||
};
|
||||
},
|
||||
{ readOnly: true },
|
||||
);
|
||||
}
|
||||
|
||||
function deleteCompactionBloat(databasePath: string, retainThroughId?: number): void {
|
||||
const database = openNodeSqliteDatabase(databasePath);
|
||||
try {
|
||||
withReliabilityDatabase(databasePath, (database) => {
|
||||
if (retainThroughId === undefined) {
|
||||
database.exec("DELETE FROM openclaw_reliability_compaction_bloat;");
|
||||
} else {
|
||||
|
|
@ -337,29 +330,27 @@ function deleteCompactionBloat(databasePath: string, retainThroughId?: number):
|
|||
.run(retainThroughId);
|
||||
}
|
||||
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readAutoVacuum(databasePath: string): number {
|
||||
const database = openNodeSqliteDatabase(databasePath, { readOnly: true });
|
||||
try {
|
||||
const row = database.prepare("PRAGMA auto_vacuum;").get() as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return sqliteSafeInteger(
|
||||
row?.auto_vacuum ?? (row ? Object.values(row)[0] : undefined),
|
||||
"auto_vacuum",
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
return withReliabilityDatabase(
|
||||
databasePath,
|
||||
(database) => {
|
||||
const row = database.prepare("PRAGMA auto_vacuum;").get() as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return sqliteSafeInteger(
|
||||
row?.auto_vacuum ?? (row ? Object.values(row)[0] : undefined),
|
||||
"auto_vacuum",
|
||||
);
|
||||
},
|
||||
{ readOnly: true },
|
||||
);
|
||||
}
|
||||
|
||||
function prepareVacuumRollbackSentinel(databasePath: string): number {
|
||||
const database = openNodeSqliteDatabase(databasePath);
|
||||
try {
|
||||
withReliabilityDatabase(databasePath, (database) => {
|
||||
database.exec(`
|
||||
PRAGMA busy_timeout = 30000;
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
|
|
@ -369,9 +360,7 @@ function prepareVacuumRollbackSentinel(databasePath: string): number {
|
|||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
`);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
const autoVacuum = readAutoVacuum(databasePath);
|
||||
if (autoVacuum !== 0) {
|
||||
throw new Error(`failed to prepare VACUUM rollback sentinel: auto_vacuum=${autoVacuum}`);
|
||||
|
|
@ -448,9 +437,6 @@ async function compactTargetDatabase(
|
|||
walBytesBefore: report.before.walSizeBytes,
|
||||
});
|
||||
}
|
||||
if (target.identity.role !== "agent") {
|
||||
throw new Error(`unsupported reliability target role: ${target.identity.role}`);
|
||||
}
|
||||
const autoVacuumBefore = readAutoVacuum(target.path);
|
||||
const report = await compactDoctorSessionSqliteTarget(
|
||||
{ agentId: target.identity.agentId, storePath: target.path },
|
||||
|
|
@ -490,6 +476,15 @@ async function runMaintenanceRoundTrip(params: {
|
|||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
});
|
||||
const verifyState = (databasePath: string, readOnly = true) =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: databasePath,
|
||||
readOnly,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
});
|
||||
const expectedPayload = readCompactionPayload(params.target.path);
|
||||
if (
|
||||
expectedPayload.rows !== COMPACTION_BLOAT_ROWS ||
|
||||
|
|
@ -516,14 +511,7 @@ async function runMaintenanceRoundTrip(params: {
|
|||
sourcePath: params.target.path,
|
||||
validationRootPath: params.validationRoot,
|
||||
verifyPayload: readCompactionPayload,
|
||||
verifyState: (databasePath) =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: databasePath,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
}),
|
||||
verifyState,
|
||||
}),
|
||||
runRestoreInterruptionProof({
|
||||
expectedPayload,
|
||||
|
|
@ -534,14 +522,7 @@ async function runMaintenanceRoundTrip(params: {
|
|||
snapshotPath: interruptedCopiedPath,
|
||||
validationRootPath: params.validationRoot,
|
||||
verifyPayload: readCompactionPayload,
|
||||
verifyState: (databasePath) =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: databasePath,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
}),
|
||||
verifyState,
|
||||
}),
|
||||
);
|
||||
let vacuumInterruption: ReliabilityReport["maintenanceProof"]["vacuumInterruption"];
|
||||
|
|
@ -563,15 +544,7 @@ async function runMaintenanceRoundTrip(params: {
|
|||
expectedState,
|
||||
readAutoVacuum: () => readAutoVacuum(params.target.path),
|
||||
readPayload: () => readCompactionPayload(params.target.path),
|
||||
recoverAndVerifyDatabase: () =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: params.target.path,
|
||||
readOnly: false,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
}),
|
||||
recoverAndVerifyDatabase: () => verifyState(params.target.path, false),
|
||||
target: params.target,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -584,43 +557,24 @@ async function runMaintenanceRoundTrip(params: {
|
|||
}
|
||||
deleteCompactionBloat(params.target.path);
|
||||
const compaction = await compactTargetDatabase(params.target, params.env);
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: params.target.path,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
});
|
||||
verifyState(params.target.path);
|
||||
|
||||
const snapshotStarted = nowMs();
|
||||
const snapshot = await params.repositoryProvider.create({
|
||||
identity: params.target.identity,
|
||||
path: params.target.path,
|
||||
});
|
||||
const snapshotMs = nowMs() - snapshotStarted;
|
||||
const copiedPath = copySnapshotDirectory(snapshot.ref.path, params.syncedRepository);
|
||||
const copiedRef = { path: copiedPath };
|
||||
await params.syncedProvider.verify(copiedRef);
|
||||
const restorePath = path.join(params.restoreRoot, "post-compact.sqlite");
|
||||
const restoreStarted = nowMs();
|
||||
await params.syncedProvider.restoreFresh(copiedRef, restorePath);
|
||||
const restoreMs = nowMs() - restoreStarted;
|
||||
const state = verifyRestoredDatabase({
|
||||
const restored = await runSnapshotRoundTrip({
|
||||
...params,
|
||||
cleanupArtifacts: false,
|
||||
expectedState,
|
||||
identity: params.target.identity,
|
||||
path: restorePath,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
restorePath: path.join(params.restoreRoot, "post-compact.sqlite"),
|
||||
uncommittedBatch: null,
|
||||
});
|
||||
return {
|
||||
bloatBytes: vacuumInterruption.payloadBeforeKill.bytes,
|
||||
compaction,
|
||||
postCompact: {
|
||||
restoreMs: Number(restoreMs.toFixed(3)),
|
||||
restoreMs: restored.restoreMs,
|
||||
restoreVerified: true,
|
||||
snapshotBytes: snapshot.manifest.artifact.sizeBytes,
|
||||
snapshotMs: Number(snapshotMs.toFixed(3)),
|
||||
state,
|
||||
snapshotBytes: restored.snapshotBytes,
|
||||
snapshotMs: restored.snapshotMs,
|
||||
state: restored.state,
|
||||
},
|
||||
repositoryInterruption,
|
||||
restoreInterruption,
|
||||
|
|
@ -628,17 +582,17 @@ async function runMaintenanceRoundTrip(params: {
|
|||
};
|
||||
}
|
||||
|
||||
async function runSnapshotIteration(params: {
|
||||
async function runSnapshotRoundTrip(params: {
|
||||
cleanupArtifacts: boolean;
|
||||
iteration: number;
|
||||
expectedState?: ReliabilityStateProof;
|
||||
repositoryProvider: ReturnType<typeof createLocalSqliteSnapshotProvider>;
|
||||
restoreRoot: string;
|
||||
restorePath: string;
|
||||
rowsPerBatch: number;
|
||||
syncedProvider: ReturnType<typeof createLocalSqliteSnapshotProvider>;
|
||||
syncedRepository: string;
|
||||
target: TargetDatabase;
|
||||
uncommittedBatch: number | null;
|
||||
}): Promise<IterationMetric> {
|
||||
}) {
|
||||
const snapshotStarted = nowMs();
|
||||
const snapshot = await params.repositoryProvider.create({
|
||||
identity: params.target.identity,
|
||||
|
|
@ -648,25 +602,26 @@ async function runSnapshotIteration(params: {
|
|||
const copiedPath = copySnapshotDirectory(snapshot.ref.path, params.syncedRepository);
|
||||
const copiedRef = { path: copiedPath };
|
||||
await params.syncedProvider.verify(copiedRef);
|
||||
const restorePath = path.join(params.restoreRoot, `restore-${params.iteration}.sqlite`);
|
||||
const restoreStarted = nowMs();
|
||||
await params.syncedProvider.restoreFresh(copiedRef, restorePath);
|
||||
await params.syncedProvider.restoreFresh(copiedRef, params.restorePath);
|
||||
const restoreMs = nowMs() - restoreStarted;
|
||||
verifyRestoredDatabase({
|
||||
const state = verifyRestoredDatabase({
|
||||
expectedState: params.expectedState,
|
||||
identity: params.target.identity,
|
||||
path: restorePath,
|
||||
path: params.restorePath,
|
||||
rowsPerBatch: params.rowsPerBatch,
|
||||
uncommittedBatch: params.uncommittedBatch,
|
||||
});
|
||||
if (params.cleanupArtifacts) {
|
||||
fs.rmSync(snapshot.ref.path, { force: true, recursive: true });
|
||||
fs.rmSync(copiedPath, { force: true, recursive: true });
|
||||
fs.rmSync(restorePath, { force: true });
|
||||
fs.rmSync(params.restorePath, { force: true });
|
||||
}
|
||||
return {
|
||||
restoreMs: Number(restoreMs.toFixed(3)),
|
||||
snapshotBytes: snapshot.manifest.artifact.sizeBytes,
|
||||
snapshotMs: Number(snapshotMs.toFixed(3)),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -688,6 +643,18 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
fs.mkdirSync(validationRoot, { recursive: true, mode: 0o700 });
|
||||
const target = resolveTargetDatabase(options, env);
|
||||
setupStressTable(target.path);
|
||||
const verifyDatabase = (
|
||||
databasePath: string,
|
||||
expectedState?: ReliabilityStateProof,
|
||||
uncommittedBatch: number | null = null,
|
||||
) =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState,
|
||||
identity: target.identity,
|
||||
path: databasePath,
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
uncommittedBatch,
|
||||
});
|
||||
const repositoryProvider = createLocalSqliteSnapshotProvider({
|
||||
repositoryPath: repository,
|
||||
validationRootPath: validationRoot,
|
||||
|
|
@ -703,15 +670,10 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
const partial = await waitForWriterMessage(writer, "partial", () => {
|
||||
writer?.child.send?.({ kind: "hold-partial" });
|
||||
});
|
||||
const stateBeforeKill = verifyRestoredDatabase({
|
||||
identity: target.identity,
|
||||
path: target.path,
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
uncommittedBatch: partial.batch,
|
||||
});
|
||||
const stateBeforeKill = verifyDatabase(target.path, undefined, partial.batch);
|
||||
let crashExit: ReliabilityWorkerExit | undefined;
|
||||
let stateAfterRecovery: ReliabilityStateProof | undefined;
|
||||
const metrics: IterationMetric[] = [];
|
||||
const metrics: Awaited<ReturnType<typeof runSnapshotRoundTrip>>[] = [];
|
||||
for (let iteration = 0; iteration < profile.iterations; iteration += 1) {
|
||||
const iterationProof = await monitorSqliteWalDuring({
|
||||
maxWalBytes: profile.maxWalBytes,
|
||||
|
|
@ -722,12 +684,11 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
// The operation still fails on the recorded peak if the writer exited first.
|
||||
}
|
||||
},
|
||||
operation: async () =>
|
||||
await runSnapshotIteration({
|
||||
operation: () =>
|
||||
runSnapshotRoundTrip({
|
||||
cleanupArtifacts: cleanupIterationArtifacts,
|
||||
iteration,
|
||||
repositoryProvider,
|
||||
restoreRoot,
|
||||
restorePath: path.join(restoreRoot, `restore-${iteration}.sqlite`),
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
syncedProvider,
|
||||
syncedRepository,
|
||||
|
|
@ -740,13 +701,7 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
peakWalBytes = Math.max(peakWalBytes, iterationProof.peakWalBytes);
|
||||
if (iteration === 0) {
|
||||
crashExit = await crashWriter(writer);
|
||||
stateAfterRecovery = verifyRestoredDatabase({
|
||||
expectedState: stateBeforeKill,
|
||||
identity: target.identity,
|
||||
path: target.path,
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
uncommittedBatch: partial.batch,
|
||||
});
|
||||
stateAfterRecovery = verifyDatabase(target.path, stateBeforeKill, partial.batch);
|
||||
writer = startWriter(target.path, profile);
|
||||
await waitForWriterMessage(writer, "ready");
|
||||
}
|
||||
|
|
@ -755,26 +710,14 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
throw new Error("SQLite reliability stress did not execute its crash recovery proof.");
|
||||
}
|
||||
const writerResult = await stopWriter(writer);
|
||||
const stableState = verifyRestoredDatabase({
|
||||
identity: target.identity,
|
||||
path: target.path,
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
});
|
||||
const stableState = verifyDatabase(target.path);
|
||||
const [publicationInterruptionProof, indexRepairInterruptionProof] =
|
||||
await runProofsConcurrently(
|
||||
runPublicationInterruptionProof({
|
||||
expectedState: stableState,
|
||||
scratchPath: path.join(runScratch, "publication-interruptions"),
|
||||
sourcePath: target.path,
|
||||
verifyDatabase: (databasePath) =>
|
||||
verifyRestoredDatabase({
|
||||
expectedState: stableState,
|
||||
identity: target.identity,
|
||||
path: databasePath,
|
||||
rowsPerBatch: profile.rowsPerBatch,
|
||||
uncommittedBatch: null,
|
||||
}),
|
||||
verifyDatabase: (databasePath) => verifyDatabase(databasePath, stableState),
|
||||
}),
|
||||
runIndexRepairInterruptionProof(path.join(runScratch, "index-repair-interruptions")),
|
||||
);
|
||||
|
|
@ -789,6 +732,8 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
validationRoot,
|
||||
});
|
||||
const snapshotBytes = metrics.map((metric) => metric.snapshotBytes);
|
||||
const restoreTimes = metrics.map((metric) => metric.restoreMs);
|
||||
const snapshotTimes = metrics.map((metric) => metric.snapshotMs);
|
||||
return {
|
||||
arch: process.arch,
|
||||
concurrentRestoresVerified: metrics.length,
|
||||
|
|
@ -823,22 +768,10 @@ export async function runReliabilityStress(options: CliOptions): Promise<Reliabi
|
|||
},
|
||||
target: target.label,
|
||||
timingsMs: {
|
||||
restoreP50: percentile(
|
||||
metrics.map((metric) => metric.restoreMs),
|
||||
50,
|
||||
),
|
||||
restoreP95: percentile(
|
||||
metrics.map((metric) => metric.restoreMs),
|
||||
95,
|
||||
),
|
||||
snapshotP50: percentile(
|
||||
metrics.map((metric) => metric.snapshotMs),
|
||||
50,
|
||||
),
|
||||
snapshotP95: percentile(
|
||||
metrics.map((metric) => metric.snapshotMs),
|
||||
95,
|
||||
),
|
||||
restoreP50: percentile(restoreTimes, 50),
|
||||
restoreP95: percentile(restoreTimes, 95),
|
||||
snapshotP50: percentile(snapshotTimes, 50),
|
||||
snapshotP95: percentile(snapshotTimes, 95),
|
||||
total: Number((nowMs() - started).toFixed(3)),
|
||||
},
|
||||
transactionProof: {
|
||||
|
|
|
|||
|
|
@ -15,40 +15,24 @@ import {
|
|||
} from "./sqlite-reliability-process.js";
|
||||
import { resolveForwardedNodeCompilerArgs } from "./tsx-cli-shim.mjs";
|
||||
|
||||
type WriterReadyMessage = {
|
||||
kind: "ready";
|
||||
};
|
||||
|
||||
type WriterPartialMessage = {
|
||||
batch: number;
|
||||
batchesCommitted: number;
|
||||
kind: "partial";
|
||||
rows: number;
|
||||
rowsCommitted: number;
|
||||
};
|
||||
|
||||
type WriterReleasedMessage = {
|
||||
batch: number;
|
||||
kind: "released";
|
||||
};
|
||||
|
||||
type WriterResultMessage = {
|
||||
batchesCommitted: number;
|
||||
kind: "result";
|
||||
rowsCommitted: number;
|
||||
};
|
||||
|
||||
type WriterErrorMessage = {
|
||||
error: string;
|
||||
kind: "error";
|
||||
};
|
||||
|
||||
type WriterMessage =
|
||||
| WriterReadyMessage
|
||||
| WriterPartialMessage
|
||||
| WriterReleasedMessage
|
||||
| { kind: "ready" }
|
||||
| {
|
||||
batch: number;
|
||||
batchesCommitted: number;
|
||||
kind: "partial";
|
||||
rows: number;
|
||||
rowsCommitted: number;
|
||||
}
|
||||
| { batch: number; kind: "released" }
|
||||
| WriterResultMessage
|
||||
| WriterErrorMessage;
|
||||
| { error: string; kind: "error" };
|
||||
|
||||
export type WriterHandle = {
|
||||
child: ChildProcess;
|
||||
|
|
@ -80,9 +64,7 @@ export function startWriter(databasePath: string, profile: ProfileConfig): Write
|
|||
);
|
||||
const stderr: string[] = [];
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderr.push(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: string) => stderr.push(chunk));
|
||||
return { child, stderr, stopped: false };
|
||||
}
|
||||
|
||||
|
|
@ -145,15 +127,7 @@ export async function terminateWriter(writer: WriterHandle): Promise<void> {
|
|||
writer.stopped = true;
|
||||
}
|
||||
|
||||
function parseWriterChildArgs(argv: string[]): {
|
||||
databasePath: string;
|
||||
payloadBytes: number;
|
||||
retainedBatches: number;
|
||||
rowsPerBatch: number;
|
||||
walAutoCheckpointPages: number;
|
||||
walSizeLimitBytes: number;
|
||||
writerPauseMs: number;
|
||||
} {
|
||||
function parseWriterChildArgs(argv: string[]) {
|
||||
const [
|
||||
databasePath,
|
||||
rowsRaw,
|
||||
|
|
@ -173,18 +147,16 @@ function parseWriterChildArgs(argv: string[]): {
|
|||
if (
|
||||
!databasePath ||
|
||||
extra.length > 0 ||
|
||||
!Number.isSafeInteger(rowsPerBatch) ||
|
||||
rowsPerBatch < 2 ||
|
||||
!Number.isSafeInteger(payloadBytes) ||
|
||||
payloadBytes < 1 ||
|
||||
!Number.isSafeInteger(retainedBatches) ||
|
||||
retainedBatches < 1 ||
|
||||
!Number.isSafeInteger(walAutoCheckpointPages) ||
|
||||
walAutoCheckpointPages < 1 ||
|
||||
!Number.isSafeInteger(walSizeLimitBytes) ||
|
||||
walSizeLimitBytes < 1 ||
|
||||
!Number.isSafeInteger(writerPauseMs) ||
|
||||
writerPauseMs < 0
|
||||
(
|
||||
[
|
||||
[rowsPerBatch, 2],
|
||||
[payloadBytes, 1],
|
||||
[retainedBatches, 1],
|
||||
[walAutoCheckpointPages, 1],
|
||||
[walSizeLimitBytes, 1],
|
||||
[writerPauseMs, 0],
|
||||
] as const
|
||||
).some(([value, minimum]) => !Number.isSafeInteger(value) || value < minimum)
|
||||
) {
|
||||
throw new Error("invalid SQLite reliability writer arguments");
|
||||
}
|
||||
|
|
@ -202,7 +174,6 @@ function parseWriterChildArgs(argv: string[]): {
|
|||
async function runWriterChild(argv: string[]): Promise<void> {
|
||||
const options = parseWriterChildArgs(argv);
|
||||
const database = openNodeSqliteDatabase(options.databasePath);
|
||||
let nextBatch = 0;
|
||||
let batchesCommitted = 0;
|
||||
let rowsCommitted = 0;
|
||||
let stopping = false;
|
||||
|
|
@ -229,7 +200,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
|
|||
"SELECT COALESCE(MAX(batch), -1) + 1 AS next_batch FROM openclaw_reliability_entries",
|
||||
)
|
||||
.get() as { next_batch?: number | bigint };
|
||||
nextBatch = Number(next.next_batch ?? 0);
|
||||
let nextBatch = Number(next.next_batch ?? 0);
|
||||
const insert = database.prepare(
|
||||
"INSERT INTO openclaw_reliability_entries (batch, ordinal, payload) VALUES (?, ?, ?)",
|
||||
);
|
||||
|
|
@ -284,7 +255,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
|
|||
};
|
||||
|
||||
commitBatch(true);
|
||||
sendMessage({ kind: "ready" } satisfies WriterReadyMessage);
|
||||
sendMessage({ kind: "ready" });
|
||||
while (!shouldStop()) {
|
||||
if (holdPartial) {
|
||||
holdPartial = false;
|
||||
|
|
@ -301,7 +272,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
|
|||
kind: "partial",
|
||||
rows: heldRows,
|
||||
rowsCommitted,
|
||||
} satisfies WriterPartialMessage);
|
||||
});
|
||||
while (!shouldReleasePartial()) {
|
||||
await delay(1);
|
||||
}
|
||||
|
|
@ -317,7 +288,7 @@ async function runWriterChild(argv: string[]): Promise<void> {
|
|||
database.exec("ROLLBACK;");
|
||||
}
|
||||
releasePartial = undefined;
|
||||
sendMessage({ batch: heldBatch, kind: "released" } satisfies WriterReleasedMessage);
|
||||
sendMessage({ batch: heldBatch, kind: "released" });
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK;");
|
||||
throw error;
|
||||
|
|
@ -335,12 +306,12 @@ async function runWriterChild(argv: string[]): Promise<void> {
|
|||
batchesCommitted,
|
||||
kind: "result",
|
||||
rowsCommitted,
|
||||
} satisfies WriterResultMessage);
|
||||
});
|
||||
} catch (error) {
|
||||
sendMessage({
|
||||
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
kind: "error",
|
||||
} satisfies WriterErrorMessage);
|
||||
});
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
database.close();
|
||||
|
|
|
|||
|
|
@ -26,11 +26,8 @@ function normalizeStatus(value) {
|
|||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "pass") {
|
||||
return "pass";
|
||||
}
|
||||
if (normalized === "fail") {
|
||||
return "fail";
|
||||
if (normalized === "pass" || normalized === "fail") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported web UI chat proof status: ${value}`);
|
||||
}
|
||||
|
|
@ -66,7 +63,7 @@ function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status, ca
|
|||
status,
|
||||
fixed: passed,
|
||||
},
|
||||
outcome: passed ? "pass" : "fail",
|
||||
outcome: status,
|
||||
pass: passed,
|
||||
},
|
||||
artifacts: [
|
||||
|
|
@ -91,18 +88,10 @@ function buildWebUiChatEvidenceManifest({ candidateRef, candidateSha, status, ca
|
|||
required: passed && complete,
|
||||
}),
|
||||
]),
|
||||
artifactEntry({
|
||||
kind: "metadata",
|
||||
label: "Control UI web chat Vitest log",
|
||||
path: "vitest.log",
|
||||
required: false,
|
||||
}),
|
||||
artifactEntry({
|
||||
kind: "metadata",
|
||||
label: "Control UI web chat setup log",
|
||||
path: "setup.log",
|
||||
required: false,
|
||||
}),
|
||||
...[
|
||||
{ label: "Control UI web chat Vitest log", path: "vitest.log" },
|
||||
{ label: "Control UI web chat setup log", path: "setup.log" },
|
||||
].map((artifact) => artifactEntry({ ...artifact, kind: "metadata", required: false })),
|
||||
{
|
||||
kind: "report",
|
||||
lane: "run",
|
||||
|
|
|
|||
|
|
@ -18,16 +18,20 @@ function unwrapExpression(node) {
|
|||
return current;
|
||||
}
|
||||
|
||||
function repositoryPath(context) {
|
||||
const filename = context.physicalFilename.replaceAll("\\", "/");
|
||||
const cwd = context.cwd.replaceAll("\\", "/");
|
||||
return filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
||||
}
|
||||
|
||||
function restrictedCallRule({ allowedFiles = [], message, objects, property, roots }) {
|
||||
return {
|
||||
create(context) {
|
||||
const filename = context.physicalFilename.replaceAll("\\", "/");
|
||||
const cwd = context.cwd.replaceAll("\\", "/");
|
||||
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
||||
const repoPath = repositoryPath(context);
|
||||
if (
|
||||
!filename.endsWith(".ts") ||
|
||||
!repoPath.endsWith(".ts") ||
|
||||
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
||||
TYPE_ASSERTION_TEST_FILE_SUFFIXES.some((suffix) => filename.endsWith(suffix)) ||
|
||||
TYPE_ASSERTION_TEST_FILE_SUFFIXES.some((suffix) => repoPath.endsWith(suffix)) ||
|
||||
allowedFiles.includes(repoPath)
|
||||
) {
|
||||
return {};
|
||||
|
|
@ -108,9 +112,7 @@ function noChainedTypeAssertionsRule({ excludedRoots = [], roots }) {
|
|||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.physicalFilename.replaceAll("\\", "/");
|
||||
const cwd = context.cwd.replaceAll("\\", "/");
|
||||
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
||||
const repoPath = repositoryPath(context);
|
||||
if (
|
||||
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
||||
excludedRoots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
||||
|
|
@ -237,7 +239,7 @@ function assertedExpression(node) {
|
|||
|
||||
function assertedIdentifier(node) {
|
||||
let expression = assertedExpression(node);
|
||||
while (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
|
||||
while (isTypeAssertionExpression(expression)) {
|
||||
expression = assertedExpression(expression);
|
||||
}
|
||||
return expression.type === "Identifier" ? expression : null;
|
||||
|
|
@ -248,17 +250,12 @@ function isNestedAssertion(node) {
|
|||
while (parent?.type === "ParenthesizedExpression") {
|
||||
parent = parent.parent;
|
||||
}
|
||||
return (
|
||||
(parent?.type === "TSAsExpression" || parent?.type === "TSTypeAssertion") &&
|
||||
assertedExpression(parent) === node
|
||||
);
|
||||
return parent && isTypeAssertionExpression(parent) && assertedExpression(parent) === node;
|
||||
}
|
||||
|
||||
function assertionFromExpression(expression) {
|
||||
const unwrapped = unwrapExpressionParentheses(expression);
|
||||
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
|
||||
? unwrapped
|
||||
: null;
|
||||
return isTypeAssertionExpression(unwrapped) ? unwrapped : null;
|
||||
}
|
||||
|
||||
function normalizedTypeText(sourceText, type) {
|
||||
|
|
@ -354,7 +351,7 @@ function variableDeclarator(variable) {
|
|||
function knownValueEvidence(expression, scopes, boundary, visitedVariables) {
|
||||
const unwrapped = unwrapExpressionParentheses(expression);
|
||||
|
||||
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
||||
if (isTypeAssertionExpression(unwrapped)) {
|
||||
if (broadTypeKind(unwrapped.typeAnnotation) !== null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -511,9 +508,7 @@ function noWidenThenAssertRule({ roots }) {
|
|||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.physicalFilename.replaceAll("\\", "/");
|
||||
const cwd = context.cwd.replaceAll("\\", "/");
|
||||
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
||||
const repoPath = repositoryPath(context);
|
||||
if (!roots.some((root) => repoPath === root || repoPath.startsWith(`${root}/`))) {
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,18 +121,18 @@ resolve_user_home() {
|
|||
generate_token_hex_32() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
return 0
|
||||
return $?
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - <<'PY'
|
||||
import secrets
|
||||
print(secrets.token_hex(32))
|
||||
PY
|
||||
return 0
|
||||
return $?
|
||||
fi
|
||||
if command -v od >/dev/null 2>&1; then
|
||||
od -An -N32 -tx1 /dev/urandom | tr -d " \n"
|
||||
return 0
|
||||
return $?
|
||||
fi
|
||||
echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -140,21 +140,19 @@ export function parseArgs(argv: string[]): {
|
|||
break;
|
||||
}
|
||||
case "--concurrency":
|
||||
options.concurrency = parsePositiveInt(args[index + 1] ?? "", arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = parsePositiveInt(args[index + 1] ?? "", arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--combined-timeout-ms":
|
||||
options.combinedTimeoutMs = parsePositiveInt(args[index + 1] ?? "", arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--top":
|
||||
options.top = parsePositiveInt(args[index + 1] ?? "", arg);
|
||||
case "--top": {
|
||||
const key = {
|
||||
"--concurrency": "concurrency",
|
||||
"--timeout-ms": "timeoutMs",
|
||||
"--combined-timeout-ms": "combinedTimeoutMs",
|
||||
"--top": "top",
|
||||
} as const;
|
||||
options[key[arg]] = parsePositiveInt(args[index + 1] ?? "", arg);
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--json": {
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith("-")) {
|
||||
|
|
@ -680,10 +678,7 @@ async function main(): Promise<void> {
|
|||
hookPath,
|
||||
name: "combined",
|
||||
completionKind: "imports",
|
||||
body: buildImportBody(
|
||||
selectedEntries.map((entry) => entry.file),
|
||||
"IMPORTED_ALL",
|
||||
),
|
||||
body: buildImportBody(entryFiles, "IMPORTED_ALL"),
|
||||
timeoutMs: options.combinedTimeoutMs,
|
||||
});
|
||||
|
||||
|
|
@ -841,26 +836,19 @@ async function main(): Promise<void> {
|
|||
};
|
||||
|
||||
const failures = [];
|
||||
if (report.baseline.status !== "ok") {
|
||||
failures.push(`baseline import ${report.baseline.status}: ${report.baseline.error}`);
|
||||
}
|
||||
if (report.baseline.maxRssMb === null) {
|
||||
failures.push("baseline import did not report RSS");
|
||||
}
|
||||
if (report.combined !== null) {
|
||||
if (report.combined.status !== "ok") {
|
||||
failures.push(`combined import ${report.combined.status}: ${report.combined.error}`);
|
||||
for (const [name, result] of [
|
||||
["baseline", report.baseline],
|
||||
["combined", report.combined],
|
||||
...report.results.map((entry) => [entry.dir, entry] as const),
|
||||
] as const) {
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
if (report.combined.maxRssMb === null) {
|
||||
failures.push("combined import did not report RSS");
|
||||
}
|
||||
}
|
||||
for (const result of report.results) {
|
||||
if (result.status !== "ok") {
|
||||
failures.push(`${result.dir} import ${result.status}: ${result.error}`);
|
||||
failures.push(`${name} import ${result.status}: ${result.error}`);
|
||||
}
|
||||
if (result.maxRssMb === null) {
|
||||
failures.push(`${result.dir} import did not report RSS`);
|
||||
failures.push(`${name} import did not report RSS`);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
|
|
|
|||
|
|
@ -234,20 +234,8 @@ function classifyFile(relativePath: string): string {
|
|||
const nodeModulesIndex = parts.indexOf("node_modules");
|
||||
return `node_modules/${packageNameFromNodeModule(parts, nodeModulesIndex)}`;
|
||||
}
|
||||
if (first === "extensions") {
|
||||
return `extensions/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "packages") {
|
||||
return `packages/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "src") {
|
||||
return `src/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "ui") {
|
||||
return `ui/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "test") {
|
||||
return `test/${parts[1] ?? "(root)"}`;
|
||||
if (first && ["extensions", "packages", "src", "ui", "test"].includes(first)) {
|
||||
return `${first}/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first?.startsWith("/") || (first !== undefined && /^[A-Za-z]:/u.test(first))) {
|
||||
return "(external)";
|
||||
|
|
@ -474,19 +462,13 @@ async function main(argv: string[]): Promise<void> {
|
|||
text: path.relative(repoRoot, textPath),
|
||||
};
|
||||
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.writeFileSync(textPath, renderTextReport(report));
|
||||
fs.writeFileSync(
|
||||
path.join(options.outDir, "latest.json"),
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
fs.writeFileSync(path.join(options.outDir, "latest.md"), renderTextReport(report));
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(renderTextReport(report));
|
||||
}
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`;
|
||||
const text = renderTextReport(report);
|
||||
fs.writeFileSync(jsonPath, json);
|
||||
fs.writeFileSync(textPath, text);
|
||||
fs.writeFileSync(path.join(options.outDir, "latest.json"), json);
|
||||
fs.writeFileSync(path.join(options.outDir, "latest.md"), text);
|
||||
process.stdout.write(options.json ? json : text);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Protocol Gen Kotlin script supports OpenClaw repository automation.
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -9,18 +8,7 @@ import {
|
|||
} from "../packages/gateway-protocol/src/version.js";
|
||||
import { listCoreGatewayMethodNames } from "../src/gateway/methods/core-method-policy.js";
|
||||
import { extractGatewayEventNames } from "./check-protocol-event-coverage.mts";
|
||||
|
||||
type JsonSchema = {
|
||||
type?: string | string[];
|
||||
const?: boolean | number | string | null;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: Array<boolean | number | string | null>;
|
||||
patternProperties?: Record<string, JsonSchema>;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
};
|
||||
import { type JsonSchema, schemaSignature } from "./lib/protocol-codegen-schema.js";
|
||||
|
||||
type EnumSpec = {
|
||||
name: string;
|
||||
|
|
@ -38,7 +26,7 @@ const constantsOutputPath = path.join(
|
|||
repoRoot,
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
|
||||
);
|
||||
const protocolSchemas = ProtocolSchemas as unknown as Record<string, JsonSchema>;
|
||||
const protocolSchemas = ProtocolSchemas as Record<string, JsonSchema>;
|
||||
|
||||
const schemaNames = new Map<string, string>([
|
||||
["ErrorShape", "GatewayProtocolError"],
|
||||
|
|
@ -192,25 +180,6 @@ function lowerCamel(value: string): string {
|
|||
return name[0]!.toLowerCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJson);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted()
|
||||
.map((key) => [key, stableJson(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function schemaSignature(schema: JsonSchema): string {
|
||||
return JSON.stringify(stableJson(schema));
|
||||
}
|
||||
|
||||
function literalValue(schema: JsonSchema): boolean | number | string | null | undefined {
|
||||
if ("const" in schema) {
|
||||
return schema.const;
|
||||
|
|
@ -222,9 +191,6 @@ function kotlinLiteral(value: boolean | number | string | null): string {
|
|||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
|
|
@ -331,28 +297,26 @@ function emitWireModels(): string[] {
|
|||
}
|
||||
const required = new Set(schema.required ?? []);
|
||||
const variant = unionVariants.get(schemaSignature(schema));
|
||||
const properties = Object.entries(schema.properties)
|
||||
const fields = Object.entries(schema.properties)
|
||||
.filter(([wireName]) => wireName !== variant?.discriminator)
|
||||
.map(([wireName, propertySchema]) => {
|
||||
.flatMap(([wireName, propertySchema]) => {
|
||||
const propertyName = lowerCamel(wireName);
|
||||
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
|
||||
const literal = literalValue(propertySchema);
|
||||
const optional = !required.has(wireName);
|
||||
const useLiteralDefault =
|
||||
literal !== undefined && (optional || typeof literal !== "boolean");
|
||||
return {
|
||||
annotation:
|
||||
propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
|
||||
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
|
||||
const lines = [
|
||||
` val ${propertyName}: ${type}${optional ? "?" : ""}${
|
||||
useLiteralDefault ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
|
||||
},`,
|
||||
};
|
||||
];
|
||||
if (propertyName !== wireName) {
|
||||
lines.unshift(` @SerialName(${JSON.stringify(wireName)})`);
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
const fields: string[] = [];
|
||||
for (const property of properties) {
|
||||
fields.push(...property.annotation, property.declaration);
|
||||
}
|
||||
if (properties.length === 0 && variant) {
|
||||
if (fields.length === 0 && variant) {
|
||||
return [
|
||||
`@SerialName(${JSON.stringify(variant.literal)})`,
|
||||
"@Serializable",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Protocol Gen Swift script supports OpenClaw repository automation.
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -10,20 +9,7 @@ import {
|
|||
PROTOCOL_VERSION,
|
||||
} from "../packages/gateway-protocol/src/version.js";
|
||||
import { writeGeneratedOutput } from "./lib/generated-output-utils.mts";
|
||||
|
||||
type JsonSchema = {
|
||||
"~openclawClosedObjectIdentity"?: symbol;
|
||||
type?: string | string[];
|
||||
const?: boolean | number | string | null;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: Array<string | null>;
|
||||
patternProperties?: Record<string, JsonSchema>;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
};
|
||||
import { type JsonSchema, schemaSignature } from "./lib/protocol-codegen-schema.js";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
|
|
@ -172,27 +158,8 @@ function resolveSchemaObjectAliases(
|
|||
return result;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stableJson);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(record)
|
||||
.toSorted()
|
||||
.map((key) => [key, stableJson(record[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function schemaSignature(schema: JsonSchema): string {
|
||||
return JSON.stringify(stableJson(schema));
|
||||
}
|
||||
|
||||
function registerNamedSchema(name: string, schema: JsonSchema, objectName: string): void {
|
||||
schemaNameByObject.set(schema as object, objectName);
|
||||
schemaNameByObject.set(schema, objectName);
|
||||
const signature = schemaSignature(schema);
|
||||
registerUniqueName(schemaNameBySignature, signature, name);
|
||||
const identity = schema["~openclawClosedObjectIdentity"];
|
||||
|
|
@ -217,7 +184,7 @@ function namedSchema(
|
|||
identity?: symbol,
|
||||
): string | undefined {
|
||||
return (
|
||||
schemaNameByObject.get(schema as object) ??
|
||||
schemaNameByObject.get(schema) ??
|
||||
(identity
|
||||
? schemaNamesByIdentity.get(identity)?.get(schemaSignature(schema))
|
||||
: allowStructuralFallback
|
||||
|
|
@ -236,7 +203,7 @@ function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed =
|
|||
? {
|
||||
...schema,
|
||||
type: schemaTypes.find((type) => type !== "null"),
|
||||
enum: schema.enum?.filter((value): value is string => value !== null),
|
||||
enum: schema.enum?.filter((value) => value !== null),
|
||||
anyOf: schema.anyOf?.filter((branch) => branch.type !== "null"),
|
||||
oneOf: schema.oneOf?.filter((branch) => branch.type !== "null"),
|
||||
}
|
||||
|
|
@ -333,60 +300,49 @@ function emitStruct(
|
|||
): string {
|
||||
const props = schema.properties ?? {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
const literalProps = Object.entries(props)
|
||||
.map(([key, propSchema]) => ({
|
||||
key,
|
||||
propSchema,
|
||||
literal: literalSchemaValue(propSchema),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
key: string;
|
||||
propSchema: JsonSchema;
|
||||
literal: boolean | number | string | null;
|
||||
} => entry.literal !== undefined,
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const literalPropByKey = new Map(
|
||||
Object.entries(props).flatMap(([key, propSchema]) => {
|
||||
const literal = literalSchemaValue(propSchema);
|
||||
return literal === undefined ? [] : [[key, literal] as const];
|
||||
}),
|
||||
);
|
||||
if (Object.keys(props).length === 0) {
|
||||
return `public struct ${name}: Codable, Sendable {}\n`;
|
||||
}
|
||||
if (strictLiterals && literalProps.length > 0) {
|
||||
const literalPropByKey = new Map(literalProps.map((entry) => [entry.key, entry.literal]));
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
const codingKeys: string[] = [];
|
||||
for (const [key, propSchema] of Object.entries(props)) {
|
||||
const propName = safeName(key);
|
||||
const propType = swiftType(propSchema, required.has(key), true);
|
||||
lines.push(` public let ${propName}: ${propType}`);
|
||||
if (propName !== key) {
|
||||
codingKeys.push(` case ${propName} = "${key}"`);
|
||||
} else {
|
||||
codingKeys.push(` case ${propName}`);
|
||||
}
|
||||
const strict = strictLiterals && literalPropByKey.size > 0;
|
||||
const properties = Object.entries(props).map(([key, propertySchema]) => ({
|
||||
key,
|
||||
schema: propertySchema,
|
||||
name: strict ? safeName(key) : swiftStoredPropertyName(name, key),
|
||||
required: required.has(key),
|
||||
}));
|
||||
const lines = [`public struct ${name}: Codable, Sendable {`];
|
||||
const codingKeys = properties.map((property) =>
|
||||
property.name === property.key
|
||||
? ` case ${property.name}`
|
||||
: ` case ${property.name} = "${property.key}"`,
|
||||
);
|
||||
for (const property of properties) {
|
||||
lines.push(
|
||||
` public let ${property.name}: ${swiftType(property.schema, property.required, true)}`,
|
||||
);
|
||||
if (!strict) {
|
||||
lines.push(...swiftCompatibilityPropertyLines(name, property.key));
|
||||
}
|
||||
const initializerParams = Object.entries(props)
|
||||
.filter(([key]) => !literalPropByKey.has(key) || !required.has(key))
|
||||
.map(([key, prop]) => {
|
||||
const propName = safeName(key);
|
||||
const req = required.has(key);
|
||||
return ` ${swiftInitializerParam({
|
||||
name: propName,
|
||||
schema: prop,
|
||||
required: req,
|
||||
})}`;
|
||||
});
|
||||
}
|
||||
if (strict) {
|
||||
const initializerParams = properties
|
||||
.filter((property) => !literalPropByKey.has(property.key) || !property.required)
|
||||
.map((property) => ` ${swiftInitializerParam(property)}`);
|
||||
const initializerDeclaration =
|
||||
initializerParams.length > 0
|
||||
? `\n public init(\n${initializerParams.join(",\n")}\n )\n {\n`
|
||||
: "\n public init()\n {\n";
|
||||
lines.push(
|
||||
initializerDeclaration +
|
||||
Object.entries(props)
|
||||
.map(([key]) => {
|
||||
const propName = safeName(key);
|
||||
if (literalPropByKey.has(key) && required.has(key)) {
|
||||
properties
|
||||
.map(({ key, name: propName, required: isRequired }) => {
|
||||
if (literalPropByKey.has(key) && isRequired) {
|
||||
return ` self.${propName} = ${swiftLiteralSource(literalPropByKey.get(key)!)}`;
|
||||
}
|
||||
return ` self.${propName} = ${propName}`;
|
||||
|
|
@ -423,10 +379,8 @@ function emitStruct(
|
|||
const absent = required.has(key) ? "" : `${decodedName} == nil || `;
|
||||
return ` let ${decodedName} = ${decodedValue}\n guard ${absent}${decodedName} == ${swiftLiteralSource(literal)} else {\n throw DecodingError.dataCorruptedError(\n forKey: .${propName},\n in: container,\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n }\n self.${propName} = ${required.has(key) ? swiftLiteralSource(literal) : decodedName}`;
|
||||
}
|
||||
if (required.has(key)) {
|
||||
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
}
|
||||
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
const decode = required.has(key) ? "decode" : "decodeIfPresent";
|
||||
return ` self.${propName} = try container.${decode}(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
})
|
||||
.join("\n") +
|
||||
"\n }\n\n" +
|
||||
|
|
@ -454,48 +408,23 @@ function emitStruct(
|
|||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
const codingKeys: string[] = [];
|
||||
let needsCodingKeys = false;
|
||||
for (const [key, propSchema] of Object.entries(props)) {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
const propType = swiftType(propSchema, required.has(key), true);
|
||||
lines.push(` public let ${propName}: ${propType}`);
|
||||
lines.push(...swiftCompatibilityPropertyLines(name, key));
|
||||
if (propName !== key) {
|
||||
needsCodingKeys = true;
|
||||
codingKeys.push(` case ${propName} = "${key}"`);
|
||||
} else {
|
||||
codingKeys.push(` case ${propName}`);
|
||||
}
|
||||
}
|
||||
const needsCodingKeys = properties.some((property) => property.name !== property.key);
|
||||
const customCodable = emitStructCustomCodable(name, props, required);
|
||||
lines.push(
|
||||
"\n public init(\n" +
|
||||
Object.entries(props)
|
||||
.map(([key, prop]) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
const req = required.has(key);
|
||||
if (name === "AgentsUpdateParams" && key === "model") {
|
||||
properties
|
||||
.map((property) => {
|
||||
if (name === "AgentsUpdateParams" && property.key === "model") {
|
||||
// Keep the raw nullable value explicit so the source-compatible initializer stays
|
||||
// unambiguous when callers omit model.
|
||||
return " modelvalue: AnyCodable?";
|
||||
}
|
||||
return ` ${swiftInitializerParam({
|
||||
name: propName,
|
||||
schema: prop,
|
||||
required: req,
|
||||
})}`;
|
||||
return ` ${swiftInitializerParam(property)}`;
|
||||
})
|
||||
.join(",\n") +
|
||||
")\n" +
|
||||
" {\n" +
|
||||
Object.entries(props)
|
||||
.map(([key]) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
return ` self.${propName} = ${propName}`;
|
||||
})
|
||||
.join("\n") +
|
||||
properties.map((property) => ` self.${property.name} = ${property.name}`).join("\n") +
|
||||
"\n }" +
|
||||
emitStructCompatibilityInitializer(name, props, required) +
|
||||
(needsCodingKeys || customCodable.length > 0
|
||||
|
|
@ -546,17 +475,13 @@ function emitStructCustomCodable(
|
|||
// preserves the Gateway patch distinction between clearing and omitting the model.
|
||||
return ` self.${propName} = container.contains(.${propName})\n ? try container.decode(AnyCodable.self, forKey: .${propName})\n : nil`;
|
||||
}
|
||||
if (required.has(key)) {
|
||||
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
}
|
||||
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
const decode = required.has(key) ? "decode" : "decodeIfPresent";
|
||||
return ` self.${propName} = try container.${decode}(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
|
||||
});
|
||||
const encodedProperties = Object.keys(props).map((key) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
if (required.has(key)) {
|
||||
return ` try container.encode(${propName}, forKey: .${propName})`;
|
||||
}
|
||||
return ` try container.encodeIfPresent(${propName}, forKey: .${propName})`;
|
||||
const encode = required.has(key) ? "encode" : "encodeIfPresent";
|
||||
return ` try container.${encode}(${propName}, forKey: .${propName})`;
|
||||
});
|
||||
return (
|
||||
"\n\n public init(from decoder: Decoder) throws {\n" +
|
||||
|
|
@ -575,66 +500,43 @@ function emitStructCompatibilityInitializer(
|
|||
props: Record<string, JsonSchema>,
|
||||
required: Set<string>,
|
||||
): string {
|
||||
if (name === "AgentsUpdateParams" && props.model) {
|
||||
const initializerParams = Object.entries(props).map(([key, prop]) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
if (key === "model") {
|
||||
return " model: String? = nil";
|
||||
}
|
||||
return ` ${swiftInitializerParam({
|
||||
name: propName,
|
||||
schema: prop,
|
||||
required: required.has(key),
|
||||
})}`;
|
||||
});
|
||||
const delegatedArgs = Object.keys(props).map((key) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
if (key === "model") {
|
||||
return " modelvalue: model.map { AnyCodable($0) }";
|
||||
}
|
||||
return ` ${propName}: ${propName}`;
|
||||
});
|
||||
return (
|
||||
"\n\n public init(\n" +
|
||||
initializerParams.join(",\n") +
|
||||
")\n" +
|
||||
" {\n" +
|
||||
" self.init(\n" +
|
||||
delegatedArgs.join(",\n") +
|
||||
")\n" +
|
||||
" }"
|
||||
);
|
||||
}
|
||||
if (name !== "ChatSendParams" || !props.fastMode) {
|
||||
const compatibility =
|
||||
name === "AgentsUpdateParams" && props.model
|
||||
? { key: "model", parameter: "model: String? = nil", value: "model", omitted: [] }
|
||||
: name === "ChatSendParams" && props.fastMode
|
||||
? {
|
||||
key: "fastMode",
|
||||
parameter: "fastmode: Bool?",
|
||||
value: "fastmode",
|
||||
omitted: ["fastAutoOnSeconds", "fast_seconds"],
|
||||
}
|
||||
: undefined;
|
||||
if (!compatibility) {
|
||||
return "";
|
||||
}
|
||||
const legacyKeys = Object.keys(props).filter(
|
||||
(key) => key !== "fastAutoOnSeconds" && key !== "fast_seconds",
|
||||
);
|
||||
const initializerParams = legacyKeys.map((key) => {
|
||||
const prop = props[key];
|
||||
if (!prop) {
|
||||
throw new Error(`missing ${name}.${key} schema`);
|
||||
}
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
if (key === "fastMode") {
|
||||
return " fastmode: Bool?";
|
||||
}
|
||||
return ` ${swiftInitializerParam({
|
||||
name: propName,
|
||||
schema: prop,
|
||||
required: required.has(key),
|
||||
})}`;
|
||||
});
|
||||
const initializerParams = Object.entries(props)
|
||||
.filter(([key]) => !compatibility.omitted.includes(key))
|
||||
.map(
|
||||
([key, schema]) =>
|
||||
` ${
|
||||
key === compatibility.key
|
||||
? compatibility.parameter
|
||||
: swiftInitializerParam({
|
||||
name: swiftStoredPropertyName(name, key),
|
||||
schema,
|
||||
required: required.has(key),
|
||||
})
|
||||
}`,
|
||||
);
|
||||
const delegatedArgs = Object.keys(props).map((key) => {
|
||||
const propName = swiftStoredPropertyName(name, key);
|
||||
if (key === "fastMode") {
|
||||
return " fastmodevalue: fastmode.map { AnyCodable($0) }";
|
||||
}
|
||||
if (key === "fastAutoOnSeconds" || key === "fast_seconds") {
|
||||
return ` ${propName}: nil`;
|
||||
}
|
||||
return ` ${propName}: ${propName}`;
|
||||
const value =
|
||||
key === compatibility.key
|
||||
? `${compatibility.value}.map { AnyCodable($0) }`
|
||||
: compatibility.omitted.includes(key)
|
||||
? "nil"
|
||||
: propName;
|
||||
return ` ${propName}: ${value}`;
|
||||
});
|
||||
return (
|
||||
"\n\n public init(\n" +
|
||||
|
|
|
|||
|
|
@ -125,8 +125,6 @@ if [[ -f "$ENV_FILE" ]]; then
|
|||
load_podman_env_file "$ENV_FILE"
|
||||
fi
|
||||
|
||||
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$EFFECTIVE_HOME/.openclaw}"
|
||||
ENV_FILE="${OPENCLAW_PODMAN_ENV:-$CONFIG_DIR/.env}"
|
||||
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$CONFIG_DIR/workspace}"
|
||||
CONTAINER_NAME="${OPENCLAW_PODMAN_CONTAINER:-openclaw}"
|
||||
OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-${OPENCLAW_IMAGE:-openclaw:local}}"
|
||||
|
|
@ -302,7 +300,7 @@ if not isinstance(allowed, list):
|
|||
allowed = []
|
||||
cleaned = []
|
||||
seen = set()
|
||||
for origin in allowed:
|
||||
for origin in allowed + desired:
|
||||
if not isinstance(origin, str):
|
||||
continue
|
||||
normalized = origin.strip()
|
||||
|
|
@ -310,10 +308,6 @@ for origin in allowed:
|
|||
continue
|
||||
cleaned.append(normalized)
|
||||
seen.add(normalized)
|
||||
for origin in desired:
|
||||
if origin not in seen:
|
||||
cleaned.append(origin)
|
||||
seen.add(origin)
|
||||
if not inherits_public_origin:
|
||||
control_ui["allowedOrigins"] = cleaned
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
|
|
@ -342,7 +336,8 @@ cleanup_token_env_file() {
|
|||
trap cleanup_token_env_file EXIT
|
||||
|
||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
||||
export OPENCLAW_GATEWAY_TOKEN="$(generate_token_hex_32)"
|
||||
OPENCLAW_GATEWAY_TOKEN="$(generate_token_hex_32)"
|
||||
export OPENCLAW_GATEWAY_TOKEN
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
ensure_safe_existing_dir "env file directory" "$(dirname "$ENV_FILE")"
|
||||
upsert_env_var "$ENV_FILE" "OPENCLAW_GATEWAY_TOKEN" "$OPENCLAW_GATEWAY_TOKEN"
|
||||
|
|
@ -395,8 +390,8 @@ else
|
|||
[[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS"
|
||||
fi
|
||||
|
||||
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
|
||||
if [[ "$RUN_SETUP" == true ]]; then
|
||||
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
|
||||
podman run --pull="$PODMAN_PULL" --rm -it \
|
||||
--init \
|
||||
${USERNS_ARGS[@]+"${USERNS_ARGS[@]}"} ${RUN_USER_ARGS[@]+"${RUN_USER_ARGS[@]}"} \
|
||||
|
|
@ -411,7 +406,6 @@ if [[ "$RUN_SETUP" == true ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")"
|
||||
run_podman_detached --pull="$PODMAN_PULL" -d --replace \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--init \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
const NOW_SECONDS = 1_800_000_000;
|
||||
const AUTH_MONITOR_PATH = "scripts/auth-monitor.sh";
|
||||
const MOBILE_REAUTH_PATH = "scripts/mobile-reauth.sh";
|
||||
const SETUP_AUTH_SYSTEM_PATH = "scripts/setup-auth-system.sh";
|
||||
|
|
@ -29,6 +30,11 @@ function createAuthMonitorHarness() {
|
|||
const openclawLog = join(home, "openclaw.log");
|
||||
const stateFile = join(home, ".openclaw", "auth-monitor-state");
|
||||
mkdirSync(binDir);
|
||||
writeFileSync(
|
||||
join(binDir, "date"),
|
||||
`#!/bin/sh\nif [ "$1" = "+%s" ]; then printf '%s\\n' '${NOW_SECONDS}'; else /bin/date "$@"; fi\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(binDir, "curl"),
|
||||
'#!/bin/sh\nprintf "called\\n" >> "$FAKE_CURL_LOG"\nexit "$FAKE_CURL_EXIT_CODE"\n',
|
||||
|
|
@ -38,10 +44,9 @@ function createAuthMonitorHarness() {
|
|||
join(binDir, "openclaw"),
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'if [ "$1" = "models" ]; then',
|
||||
" exit 1",
|
||||
"fi",
|
||||
'printf "called\\n" >> "$FAKE_OPENCLAW_LOG"',
|
||||
'if [ "$1" = "models" ]; then exit 1; fi',
|
||||
'if [ "$#" -ne 6 ] || [ "$1" != "message" ] || [ "$2" != "send" ] || [ "$3" != "--target" ] || [ "$5" != "--message" ]; then exit 64; fi',
|
||||
`jq -cn --arg target "$4" --arg message "$6" '{target:$target,message:$message}' >> "$FAKE_OPENCLAW_LOG"`,
|
||||
'exit "$FAKE_OPENCLAW_EXIT_CODE"',
|
||||
"",
|
||||
].join("\n"),
|
||||
|
|
@ -53,8 +58,8 @@ function createAuthMonitorHarness() {
|
|||
home,
|
||||
openclawLog,
|
||||
stateFile,
|
||||
enablePhoneAuth: () => {
|
||||
const expiresAt = Date.now() + 90 * 60 * 1000;
|
||||
enablePhoneAuth: (minutes = 90) => {
|
||||
const expiresAt = (NOW_SECONDS + minutes * 60) * 1000;
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
mkdirSync(join(home, ".openclaw", "agents", "main", "agent"), { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -163,25 +168,41 @@ describe("auth monitoring scripts", () => {
|
|||
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rate-limits after any configured notification channel succeeds", () => {
|
||||
const harness = createAuthMonitorHarness();
|
||||
harness.enablePhoneAuth();
|
||||
it.each([
|
||||
{
|
||||
minutes: 90,
|
||||
message: "Claude Code auth expires in 1h 30m. Consider re-auth soon.",
|
||||
},
|
||||
{
|
||||
minutes: 30,
|
||||
message: "Claude Code auth expires in 0h 30m. Consider re-auth soon.",
|
||||
},
|
||||
])(
|
||||
"delivers a phone alert and rate-limits with $minutes minutes left",
|
||||
({ minutes, message }) => {
|
||||
const harness = createAuthMonitorHarness();
|
||||
harness.enablePhoneAuth(minutes);
|
||||
|
||||
const delivered = harness.run({
|
||||
curlExitCode: 22,
|
||||
notifyPhone: "+15550000000",
|
||||
});
|
||||
expect(delivered.status).toBe(0);
|
||||
expect(existsSync(harness.stateFile)).toBe(true);
|
||||
const delivered = harness.run({
|
||||
curlExitCode: 22,
|
||||
notifyPhone: "+15550000000",
|
||||
});
|
||||
expect(delivered.status).toBe(0);
|
||||
expect(existsSync(harness.stateFile)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(harness.openclawLog, "utf8"))).toEqual({
|
||||
target: "+15550000000",
|
||||
message,
|
||||
});
|
||||
|
||||
const throttled = harness.run({
|
||||
curlExitCode: 22,
|
||||
notifyPhone: "+15550000000",
|
||||
});
|
||||
expect(throttled.stdout).toContain("Skipping notification (sent recently)");
|
||||
expect(readFileSync(harness.openclawLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
});
|
||||
const throttled = harness.run({
|
||||
curlExitCode: 22,
|
||||
notifyPhone: "+15550000000",
|
||||
});
|
||||
expect(throttled.stdout).toContain("Skipping notification (sent recently)");
|
||||
expect(readFileSync(harness.openclawLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(readFileSync(harness.curlLog, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("retries when all configured notification channels fail", () => {
|
||||
const harness = createAuthMonitorHarness();
|
||||
|
|
|
|||
|
|
@ -46,6 +46,32 @@ describe("check-composite-action-input-interpolation", () => {
|
|||
expect(result.stdout).toContain("Use env: and reference shell variables instead.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "first step key", using: "composite", firstKey: true },
|
||||
{ name: "double-quoted using", using: '"composite"', firstKey: false },
|
||||
{ name: "single-quoted using", using: "'composite'", firstKey: false },
|
||||
{ name: "quoted using with a comment", using: '"composite" # runtime', firstKey: true },
|
||||
])("rejects direct interpolation with $name", ({ using, firstKey }) => {
|
||||
const rootDir = createTempDir("openclaw-composite-action-inputs-");
|
||||
writeAction(
|
||||
rootDir,
|
||||
"unsafe",
|
||||
[
|
||||
"name: unsafe",
|
||||
"runs:",
|
||||
` using: ${using}`,
|
||||
" steps:",
|
||||
...(firstKey
|
||||
? [" - run: |", ' echo "${{ inputs.value }}"', " shell: bash"]
|
||||
: [" - shell: bash", ' run: echo "${{ inputs.value }}"']),
|
||||
].join("\n"),
|
||||
);
|
||||
const result = runCheck(rootDir);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain(".github/actions/unsafe/action.yml:6");
|
||||
expect(result.stdout).toContain("Disallowed direct inputs interpolation");
|
||||
});
|
||||
|
||||
it("allows env indirection and ignores non-composite actions", () => {
|
||||
const rootDir = createTempDir("openclaw-composite-action-inputs-");
|
||||
writeAction(
|
||||
|
|
@ -56,11 +82,11 @@ describe("check-composite-action-input-interpolation", () => {
|
|||
"runs:",
|
||||
" using: composite",
|
||||
" steps:",
|
||||
" - shell: bash",
|
||||
" - run: |",
|
||||
' echo "$TOKEN"',
|
||||
" shell: bash",
|
||||
" env:",
|
||||
" TOKEN: ${{ inputs.token }}",
|
||||
" run: |",
|
||||
' echo "$TOKEN"',
|
||||
].join("\n"),
|
||||
);
|
||||
writeAction(
|
||||
|
|
|
|||
|
|
@ -465,6 +465,56 @@ Delete the files or model their real entrypoints in Knip.`,
|
|||
});
|
||||
});
|
||||
|
||||
it.each([1, 2, 3])(
|
||||
"preserves split UTF-8 for interleaved Knip streams at byte %i",
|
||||
async (split) => {
|
||||
const child = new FakeKnipProcess();
|
||||
child.pid = 0;
|
||||
const resultPromise = runKnip(KNIP_UNUSED_FILE_ARGS, {
|
||||
maxBufferBytes: 8,
|
||||
spawnCommand: () => child,
|
||||
writeStatus: () => {},
|
||||
});
|
||||
const stdout = Buffer.from("🦞");
|
||||
const stderr = Buffer.from("📦");
|
||||
child.stdout.emit("data", stdout.subarray(0, split));
|
||||
child.stderr.emit("data", stderr.subarray(0, split));
|
||||
child.stdout.emit("data", stdout.subarray(split));
|
||||
child.stderr.emit("data", stderr.subarray(split));
|
||||
finishFakeProcess(child, 0, null);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
errorCode: undefined,
|
||||
output: "🦞📦",
|
||||
signal: null,
|
||||
status: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not flush a UTF-8 character cut by the Knip output byte cap", async () => {
|
||||
const child = new FakeKnipProcess();
|
||||
// This output-only child owns no OS process; the existing cap test covers signal delivery.
|
||||
child.pid = 0;
|
||||
const resultPromise = runKnip(KNIP_UNUSED_FILE_ARGS, {
|
||||
maxBufferBytes: 4,
|
||||
spawnCommand: () => child,
|
||||
writeStatus: () => {},
|
||||
});
|
||||
const character = Buffer.from("🦞");
|
||||
child.stdout.emit("data", "ab");
|
||||
child.stdout.emit("data", character.subarray(0, 2));
|
||||
child.stdout.emit("data", character.subarray(2));
|
||||
finishFakeProcess(child, null, "SIGTERM");
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
errorCode: "ENOBUFS",
|
||||
output: "ab",
|
||||
signal: "SIGTERM",
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds captured Knip output", async () => {
|
||||
const child = new FakeKnipProcess();
|
||||
await withFakeProcessSignals(child, async (kills) => {
|
||||
|
|
|
|||
|
|
@ -674,10 +674,10 @@ ensure_vm_running`,
|
|||
|
||||
it("resets Linux product state before both install lanes", () => {
|
||||
for (const lane of ["fresh", "upgrade"]) {
|
||||
const restoreIndex = linux.indexOf(`this.phase("${lane}.restore-snapshot"`);
|
||||
const resetIndex = linux.indexOf(`this.phase("${lane}.reset-state"`);
|
||||
const restoreIndex = linux.indexOf(`"${lane}.restore-snapshot"`);
|
||||
const resetIndex = linux.indexOf(`"${lane}.reset-state"`);
|
||||
const installIndex = linux.indexOf(
|
||||
`this.phase("${lane}.${lane === "fresh" ? "install-main" : "install-latest"}"`,
|
||||
`"${lane}.${lane === "fresh" ? "install-main" : "install-latest"}"`,
|
||||
);
|
||||
expect(restoreIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resetIndex).toBeGreaterThan(restoreIndex);
|
||||
|
|
@ -963,9 +963,7 @@ ensure_vm_running`,
|
|||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
expect(script, scriptPath).toContain("resolveSnapshot");
|
||||
expect(script, scriptPath).toContain(
|
||||
scriptPath === TS_PATHS.macos ? "runSmokeLane" : "SmokeRunController",
|
||||
);
|
||||
expect(script, scriptPath).toContain("SmokeRunController");
|
||||
expect(script, scriptPath).not.toContain("def aliases(name: str)");
|
||||
}
|
||||
});
|
||||
|
|
@ -1692,7 +1690,7 @@ if (commandArgs[0] === "list") {
|
|||
|
||||
expect(script, scriptPath).toContain("PhaseRunner");
|
||||
expect(script, scriptPath).toContain("validateSnapshotRestoreMode(this.options.mode");
|
||||
expect(script, scriptPath).toContain("remainingPhaseTimeoutMs");
|
||||
expect(script, scriptPath).toContain("this.phases.remainingTimeoutMs");
|
||||
expect(script, scriptPath).toContain("timeoutMs:");
|
||||
}
|
||||
|
||||
|
|
@ -1700,10 +1698,10 @@ if (commandArgs[0] === "list") {
|
|||
expect(macos).toContain("shouldSkipSnapshotRestore()");
|
||||
expect(macos).toContain("Skip snapshot restore; using current running VM");
|
||||
|
||||
expect(linux).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
|
||||
expect(windows).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
|
||||
expect(macos).toContain("probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000)");
|
||||
expect(macos).toContain("timeoutMs: this.remainingPhaseTimeoutMs(360_000)");
|
||||
expect(linux).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
|
||||
expect(windows).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
|
||||
expect(macos).toContain("probeTimeoutMs: () => this.phases.remainingTimeoutMs(30_000)");
|
||||
expect(macos).toContain("timeoutMs: this.phases.remainingTimeoutMs(360_000)");
|
||||
});
|
||||
|
||||
it("cleans POSIX guest scripts after the phase deadline is exhausted", () => {
|
||||
|
|
|
|||
116
test/scripts/podman-token-generation.test.ts
Normal file
116
test/scripts/podman-token-generation.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createScriptTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createScriptTestHarness();
|
||||
// Synthetic fixture bytes only; never used by a real Gateway.
|
||||
const fixtureToken = "ab".repeat(32);
|
||||
const entrypoints = ["scripts/podman/setup.sh", "scripts/run-openclaw-podman.sh"] as const;
|
||||
const backends = ["openssl", "python3", "od"] as const;
|
||||
|
||||
function createFixture(backend: (typeof backends)[number], generatorExit: number) {
|
||||
const root = createTempDir("openclaw-podman-token-");
|
||||
const bin = path.join(root, "bin");
|
||||
const home = path.join(root, "home");
|
||||
const config = path.join(home, ".openclaw");
|
||||
const calls = path.join(root, "podman-calls");
|
||||
fs.mkdirSync(bin);
|
||||
fs.mkdirSync(home);
|
||||
for (const relative of [
|
||||
...entrypoints,
|
||||
"scripts/podman/common.sh",
|
||||
"scripts/lib/host-timeout.sh",
|
||||
"scripts/lib/build-metadata.sh",
|
||||
]) {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.resolve(relative), target);
|
||||
}
|
||||
for (const name of [
|
||||
"awk",
|
||||
"cat",
|
||||
"chmod",
|
||||
"dirname",
|
||||
"install",
|
||||
"mkdir",
|
||||
"mktemp",
|
||||
"mv",
|
||||
"rm",
|
||||
"tr",
|
||||
]) {
|
||||
const executable = ["/usr/bin", "/bin"].map((dir) => path.join(dir, name)).find(fs.existsSync);
|
||||
if (!executable) {
|
||||
throw new Error(`Missing shell fixture tool: ${name}`);
|
||||
}
|
||||
fs.symlinkSync(executable, path.join(bin, name));
|
||||
}
|
||||
const command = (name: string, body: string) =>
|
||||
fs.writeFileSync(path.join(bin, name), `#!/bin/bash\n${body}\n`, { mode: 0o755 });
|
||||
// Fixed rootless identity also works when CI itself runs as root. Files and
|
||||
// persistence remain real; no service, container, or host credentials are used.
|
||||
command("id", 'case "$1" in -un) printf "fixture\\n";; *) printf "1000\\n";; esac');
|
||||
command(
|
||||
"stat",
|
||||
'case "$2" in %u) printf "1000\\n";; %Lp|%a) printf "700\\n";; *) exit 97;; esac',
|
||||
);
|
||||
command("uname", 'printf "Linux\\n"');
|
||||
command("podman", 'printf "%s\\n" "$*" >> "$PODMAN_CALLS"');
|
||||
const emitted = backend === "od" ? `${" ab".repeat(32)}\n` : `${fixtureToken}\n`;
|
||||
command(
|
||||
backend,
|
||||
[
|
||||
// Python is also an optional config-normalization helper after success;
|
||||
// that separate operation may decline without preventing startup.
|
||||
...(backend === "python3" ? ['[[ "$#" -eq 1 ]] || exit 0'] : []),
|
||||
'if [[ "$GENERATOR_EXIT" -ne 0 ]]; then',
|
||||
' printf "fixture random source failed\\n" >&2',
|
||||
' exit "$GENERATOR_EXIT"',
|
||||
"fi",
|
||||
`printf '%s' '${emitted}'`,
|
||||
].join("\n"),
|
||||
);
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
HOME: home,
|
||||
PATH: bin,
|
||||
OPENCLAW_REPO_PATH: root,
|
||||
OPENCLAW_CONFIG_DIR: config,
|
||||
OPENCLAW_IMAGE: "fixture:local",
|
||||
OPENCLAW_BUILD_TIMESTAMP: "2026-09-28T12:00:00Z",
|
||||
GENERATOR_EXIT: String(generatorExit),
|
||||
PODMAN_CALLS: calls,
|
||||
};
|
||||
return { root, config, calls, env };
|
||||
}
|
||||
|
||||
describe("Podman generated gateway tokens", () => {
|
||||
for (const entrypoint of entrypoints) {
|
||||
it.each(backends)(`${entrypoint} persists a successful %s token`, (backend) => {
|
||||
const fixture = createFixture(backend, 0);
|
||||
const result = spawnSync("/bin/bash", [path.join(fixture.root, entrypoint)], {
|
||||
env: fixture.env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(fs.readFileSync(path.join(fixture.config, ".env"), "utf8")).toContain(
|
||||
`OPENCLAW_GATEWAY_TOKEN=${fixtureToken}\n`,
|
||||
);
|
||||
expect(`${result.stdout}${result.stderr}`).not.toContain(fixtureToken);
|
||||
});
|
||||
|
||||
it.each(backends)(`${entrypoint} stops when %s token generation fails`, (backend) => {
|
||||
const fixture = createFixture(backend, 7);
|
||||
const result = spawnSync("/bin/bash", [path.join(fixture.root, entrypoint)], {
|
||||
env: fixture.env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.stderr).toContain("fixture random source failed");
|
||||
expect(result.status).toBe(7);
|
||||
expect(fs.existsSync(path.join(fixture.config, ".env"))).toBe(false);
|
||||
expect(`${result.stdout}${result.stderr}`).not.toContain("Generated OPENCLAW_GATEWAY_TOKEN");
|
||||
const calls = fs.existsSync(fixture.calls) ? fs.readFileSync(fixture.calls, "utf8") : "";
|
||||
expect(calls).not.toMatch(/^run /m);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue