mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
90 Commits
fix/plugin
...
fix/tui-go
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cfdbf166 | ||
|
|
ec7e3eaf64 | ||
|
|
8bcdab8933 | ||
|
|
c2f0d811e7 | ||
|
|
8f3d3a549d | ||
|
|
d389a52494 | ||
|
|
346b14a51a | ||
|
|
ffa2da8478 | ||
|
|
61a768be75 | ||
|
|
3d8a77a113 | ||
|
|
a6a358f1a6 | ||
|
|
131dc4eaeb | ||
|
|
022fd55bad | ||
|
|
d9820e4098 | ||
|
|
a4ebdc9aa1 | ||
|
|
cf2461f7f6 | ||
|
|
f5f829db79 | ||
|
|
a06daab97e | ||
|
|
09f094057a | ||
|
|
9def042fab | ||
|
|
f6adea5757 | ||
|
|
78f4a5c05f | ||
|
|
731a7af9c5 | ||
|
|
ffa4342a6a | ||
|
|
550a134cf9 | ||
|
|
1b43e84d0d | ||
|
|
31f0635f4f | ||
|
|
1c65e2e7c1 | ||
|
|
b6f3fe7938 | ||
|
|
d65b3a68aa | ||
|
|
e2b54fecd8 | ||
|
|
b8067d073a | ||
|
|
e420c001d0 | ||
|
|
44b6b79a66 | ||
|
|
3ef2935ac9 | ||
|
|
fced29de17 | ||
|
|
4f074c3235 | ||
|
|
5df00520cb | ||
|
|
b2c85bc0a2 | ||
|
|
5e2e78a75a | ||
|
|
2196f107da | ||
|
|
ff56a2d7b3 | ||
|
|
24cff8a3bc | ||
|
|
b495ac2abb | ||
|
|
3f2585424d | ||
|
|
9d1a3007d9 | ||
|
|
b5c163dffa | ||
|
|
ee0cf9e5bb | ||
|
|
37fdfa0e0b | ||
|
|
d550b804b8 | ||
|
|
05988500bc | ||
|
|
b01290cf64 | ||
|
|
117f6fb254 | ||
|
|
c363816fea | ||
|
|
aeed31cdb1 | ||
|
|
58c8c022c5 | ||
|
|
2cfae61743 | ||
|
|
c6b4daf426 | ||
|
|
348fabe04d | ||
|
|
6c83e8e7e4 | ||
|
|
817b6259c4 | ||
|
|
959af0fa5b | ||
|
|
669b26a3dc | ||
|
|
67c139fc36 | ||
|
|
8b6829e1bc | ||
|
|
86e6fbcf52 | ||
|
|
9b4b3aa348 | ||
|
|
51ab2c0d79 | ||
|
|
bdd9c70787 | ||
|
|
1ff95ff3e6 | ||
|
|
7c5b55c5ff | ||
|
|
b0d6076208 | ||
|
|
4385e57dce | ||
|
|
eb45c1c623 | ||
|
|
adf981de89 | ||
|
|
023a101b91 | ||
|
|
8b92aca27f | ||
|
|
b13fb788b5 | ||
|
|
87c0ee7685 | ||
|
|
eef32e94c7 | ||
|
|
1350efcfd8 | ||
|
|
e7ef051149 | ||
|
|
2b5ddf8f2a | ||
|
|
6f655573d3 | ||
|
|
8aabf45ddb | ||
|
|
4d4748e807 | ||
|
|
439c09668e | ||
|
|
54bbe87cd5 | ||
|
|
6804b7cb71 | ||
|
|
63470e99f0 |
@@ -6,18 +6,10 @@ class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
- eu-central-1
|
||||
- us-east-1
|
||||
- us-west-2
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
# Default AWS hydration uses local Actions replay. Use
|
||||
@@ -37,6 +29,8 @@ blacksmith:
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
# AWS-specific overrides still pin direct `--provider aws` runs without
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
sync:
|
||||
|
||||
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
@@ -33,6 +34,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
|
||||
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
|
||||
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
|
||||
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
|
||||
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
|
||||
|
||||
## 2026.5.28
|
||||
|
||||
@@ -17,6 +17,13 @@ export {
|
||||
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
import { hasAnthropicVertexAvailableAuth } from "./region.js";
|
||||
|
||||
let streamRuntimeModulePromise: Promise<typeof import("./stream-runtime.js")> | null = null;
|
||||
|
||||
const loadStreamRuntimeModule = async () => {
|
||||
streamRuntimeModulePromise ??= import("./stream-runtime.js");
|
||||
return await streamRuntimeModulePromise;
|
||||
};
|
||||
|
||||
export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
@@ -50,7 +57,7 @@ export function createAnthropicVertexStreamFn(
|
||||
baseURL?: string,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
|
||||
);
|
||||
return async (model, context, options) => {
|
||||
@@ -64,7 +71,7 @@ export function createAnthropicVertexStreamFnForModel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
|
||||
);
|
||||
return async (...args) => {
|
||||
|
||||
@@ -15,6 +15,15 @@ import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
|
||||
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
|
||||
|
||||
let browserRegistrationRuntimeModulePromise: Promise<
|
||||
typeof import("./register.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
const loadBrowserRegistrationRuntimeModule = async () => {
|
||||
browserRegistrationRuntimeModulePromise ??= import("./register.runtime.js");
|
||||
return await browserRegistrationRuntimeModulePromise;
|
||||
};
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
|
||||
}
|
||||
@@ -51,7 +60,7 @@ function createLazyBrowserTool(opts?: {
|
||||
].join(" "),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const { createBrowserTool } = await import("./register.runtime.js");
|
||||
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
|
||||
const tool = createBrowserTool(opts);
|
||||
return await tool.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
@@ -65,7 +74,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
handle: async (paramsJSON) => {
|
||||
const { runBrowserProxyCommand } = await import("./register.runtime.js");
|
||||
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
|
||||
return await runBrowserProxyCommand(paramsJSON);
|
||||
},
|
||||
},
|
||||
@@ -73,7 +82,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
|
||||
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
|
||||
async (ctx) => {
|
||||
const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js");
|
||||
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
|
||||
return collectBrowserSecurityAuditFindings(ctx);
|
||||
},
|
||||
];
|
||||
@@ -82,7 +91,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
let service: OpenClawPluginService | null = null;
|
||||
const loadService = async () => {
|
||||
if (!service) {
|
||||
const { createBrowserPluginService } = await import("./register.runtime.js");
|
||||
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
|
||||
service = createBrowserPluginService();
|
||||
}
|
||||
return service;
|
||||
@@ -124,7 +133,7 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod(
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
async (opts) => {
|
||||
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
|
||||
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
|
||||
return await handleBrowserGatewayRequest(opts);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redactCdpUrl } from "../cdp.helpers.js";
|
||||
import { snapshotAria } from "../cdp.js";
|
||||
import { getChromeMcpPid } from "../chrome-mcp.js";
|
||||
import { getChromeMcpPid, takeChromeMcpSnapshot } from "../chrome-mcp.js";
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { resolveManagedBrowserHeadlessMode } from "../config.js";
|
||||
import { buildBrowserDoctorReport } from "../doctor.js";
|
||||
@@ -227,7 +227,6 @@ async function runBrowserLiveProbe(req: BrowserRequest, ctx: BrowserRouteContext
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const { takeChromeMcpSnapshot } = await import("../chrome-mcp.js");
|
||||
await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
|
||||
@@ -13,13 +13,20 @@ export type CodexAppServerClientFactory = (
|
||||
config?: AuthProfileOrderConfig,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
|
||||
const loadSharedClientModule = async () => {
|
||||
sharedClientModulePromise ??= import("./shared-client.js");
|
||||
return await sharedClientModulePromise;
|
||||
};
|
||||
|
||||
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -29,6 +36,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -251,6 +251,13 @@ function applyNewAppSecurityPolicy(
|
||||
// Scan-to-create flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let appRegistrationModulePromise: Promise<typeof import("./app-registration.js")> | null = null;
|
||||
|
||||
const loadAppRegistrationModule = async () => {
|
||||
appRegistrationModulePromise ??= import("./app-registration.js");
|
||||
return await appRegistrationModulePromise;
|
||||
};
|
||||
|
||||
async function promptFeishuDomain(params: {
|
||||
prompter: WizardPrompter;
|
||||
initialValue?: FeishuDomain;
|
||||
@@ -281,7 +288,7 @@ async function runScanToCreate(
|
||||
domain: FeishuDomain,
|
||||
): Promise<AppRegistrationResult | null> {
|
||||
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
|
||||
await import("./app-registration.js");
|
||||
await loadAppRegistrationModule();
|
||||
try {
|
||||
await initAppRegistration(domain);
|
||||
} catch {
|
||||
@@ -392,7 +399,7 @@ async function runNewAppFlow(params: {
|
||||
|
||||
// Fetch openId via API for manual flow.
|
||||
if (appId && appSecretProbeValue) {
|
||||
const { getAppOwnerOpenId } = await import("./app-registration.js");
|
||||
const { getAppOwnerOpenId } = await loadAppRegistrationModule();
|
||||
scanOpenId = await getAppOwnerOpenId({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue,
|
||||
|
||||
@@ -23,9 +23,3 @@ export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
|
||||
handleFeishuSubagentEnded(event);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
handleFeishuSubagentDeliveryTarget,
|
||||
handleFeishuSubagentEnded,
|
||||
handleFeishuSubagentSpawning,
|
||||
} from "./src/subagent-hooks.js";
|
||||
|
||||
@@ -37,6 +37,19 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
||||
import { GoogleMeetRuntime } from "./src/runtime.js";
|
||||
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
|
||||
|
||||
let googleMeetCreateModulePromise: Promise<typeof import("./src/create.js")> | null = null;
|
||||
let googleMeetCliModulePromise: Promise<typeof import("./src/cli.js")> | null = null;
|
||||
|
||||
const loadGoogleMeetCreateModule = async () => {
|
||||
googleMeetCreateModulePromise ??= import("./src/create.js");
|
||||
return await googleMeetCreateModulePromise;
|
||||
};
|
||||
|
||||
const loadGoogleMeetCliModule = async () => {
|
||||
googleMeetCliModulePromise ??= import("./src/cli.js");
|
||||
return await googleMeetCliModulePromise;
|
||||
};
|
||||
|
||||
const googleMeetConfigSchema = {
|
||||
parse(value: unknown) {
|
||||
return resolveGoogleMeetConfig(value);
|
||||
@@ -493,7 +506,7 @@ async function createMeetFromParams(params: {
|
||||
runtime: OpenClawPluginApi["runtime"];
|
||||
raw: Record<string, unknown>;
|
||||
}) {
|
||||
const create = await import("./src/create.js");
|
||||
const create = await loadGoogleMeetCreateModule();
|
||||
return create.createMeetFromParams(params);
|
||||
}
|
||||
|
||||
@@ -503,7 +516,7 @@ async function createAndJoinMeetFromParams(params: {
|
||||
raw: Record<string, unknown>;
|
||||
ensureRuntime: () => Promise<GoogleMeetRuntime>;
|
||||
}) {
|
||||
const create = await import("./src/create.js");
|
||||
const create = await loadGoogleMeetCreateModule();
|
||||
return create.createAndJoinMeetFromParams(params);
|
||||
}
|
||||
|
||||
@@ -615,7 +628,7 @@ async function exportGoogleMeetBundleFromParams(
|
||||
}),
|
||||
]);
|
||||
const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } =
|
||||
await import("./src/cli.js");
|
||||
await loadGoogleMeetCliModule();
|
||||
const calendarId = normalizeOptionalString(raw.calendarId);
|
||||
const request = {
|
||||
...(resolved.meeting ? { meeting: resolved.meeting } : {}),
|
||||
@@ -1189,7 +1202,7 @@ export default definePluginEntry({
|
||||
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerGoogleMeetCli } = await import("./src/cli.js");
|
||||
const { registerGoogleMeetCli } = await loadGoogleMeetCliModule();
|
||||
registerGoogleMeetCli({
|
||||
program,
|
||||
config,
|
||||
|
||||
@@ -20,6 +20,13 @@ const ENV_VARS = [
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
] as const;
|
||||
|
||||
let oauthRuntimeModulePromise: Promise<typeof import("./oauth.runtime.js")> | null = null;
|
||||
|
||||
const loadOauthRuntimeModule = async () => {
|
||||
oauthRuntimeModulePromise ??= import("./oauth.runtime.js");
|
||||
return await oauthRuntimeModulePromise;
|
||||
};
|
||||
|
||||
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
|
||||
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
|
||||
}
|
||||
@@ -58,7 +65,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
|
||||
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
||||
try {
|
||||
const { loginGeminiCliOAuth } = await import("./oauth.runtime.js");
|
||||
const { loginGeminiCliOAuth } = await loadOauthRuntimeModule();
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
@@ -122,7 +129,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
|
||||
refreshOAuth: async (cred) => {
|
||||
const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js");
|
||||
const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule();
|
||||
return await refreshGeminiCliOAuthToken(cred);
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
rememberIMessageReplyCache,
|
||||
type IMessageChatContext,
|
||||
} from "./monitor-reply-cache.js";
|
||||
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
|
||||
import { getCachedIMessagePrivateApiStatus, probeIMessagePrivateApi } from "./probe.js";
|
||||
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
|
||||
|
||||
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
|
||||
@@ -417,7 +417,6 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
|
||||
// status adapter, which doesn't fire eagerly on first dispatch. Run
|
||||
// an inline probe so the first react/send-rich attempt after `imsg
|
||||
// launch` succeeds without requiring a manual `channels status`.
|
||||
const { probeIMessagePrivateApi } = await import("./probe.js");
|
||||
privateApiStatus = await probeIMessagePrivateApi(
|
||||
cliPathForProbe,
|
||||
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runIMessageCatchup } from "./catchup-bridge.js";
|
||||
import { resolveCatchupConfig } from "./catchup.js";
|
||||
import { resolveCatchupConfig, saveIMessageCatchupCursor } from "./catchup.js";
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
type RpcCall = {
|
||||
@@ -157,6 +157,32 @@ describe("runIMessageCatchup", () => {
|
||||
expect(summary.replayed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not crash on Date-invalid persisted cursor timestamps", async () => {
|
||||
const log = vi.fn();
|
||||
await saveIMessageCatchupCursor("default", {
|
||||
lastSeenMs: 8_700_000_000_000_000,
|
||||
lastSeenRowid: 10,
|
||||
});
|
||||
const { client, calls } = makeFakeClient(() => {
|
||||
throw new Error("unexpected rpc");
|
||||
});
|
||||
|
||||
const summary = await runIMessageCatchup({
|
||||
client: client as never,
|
||||
accountId: "default",
|
||||
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
|
||||
includeAttachments: false,
|
||||
dispatchPayload: async () => {},
|
||||
runtime: { log },
|
||||
});
|
||||
|
||||
expect(summary.querySucceeded).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("imessage catchup: invalid since timestamp"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns querySucceeded=false when chats.list throws", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { IMessageRpcClient } from "../client.js";
|
||||
import {
|
||||
@@ -88,6 +89,11 @@ export async function runIMessageCatchup(
|
||||
const payloadByGuid = new Map<string, IMessagePayload>();
|
||||
|
||||
const fetchFn: CatchupFetchFn = async ({ sinceMs, sinceRowid, limit }) => {
|
||||
const sinceISO = timestampMsToIsoString(sinceMs);
|
||||
if (!sinceISO) {
|
||||
warnLog(`imessage catchup: invalid since timestamp ${sinceMs}`);
|
||||
return { resolved: false, rows: [] };
|
||||
}
|
||||
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
|
||||
try {
|
||||
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
|
||||
@@ -100,7 +106,6 @@ export async function runIMessageCatchup(
|
||||
return { resolved: false, rows: [] };
|
||||
}
|
||||
const chats = chatsResult?.chats ?? [];
|
||||
const sinceISO = new Date(sinceMs).toISOString();
|
||||
const collected: IMessageCatchupRow[] = [];
|
||||
const perChatLimit = Math.min(limit, PER_CHAT_HISTORY_LIMIT_CAP);
|
||||
let historyFetchFailed = false;
|
||||
|
||||
@@ -85,6 +85,12 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"matrixChannelRuntime",
|
||||
);
|
||||
let matrixDoctorModulePromise: Promise<typeof import("./doctor.js")> | null = null;
|
||||
|
||||
const loadMatrixDoctorModule = async () => {
|
||||
matrixDoctorModulePromise ??= import("./doctor.js");
|
||||
return await matrixDoctorModulePromise;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
@@ -118,9 +124,9 @@ const matrixDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
|
||||
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
|
||||
await (await import("./doctor.js")).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
cleanStaleConfig: async ({ cfg }) =>
|
||||
await (await import("./doctor.js")).cleanStaleMatrixPluginConfig(cfg),
|
||||
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
|
||||
};
|
||||
|
||||
const listMatrixDirectoryPeersFromConfig =
|
||||
|
||||
@@ -950,6 +950,28 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(console.log).toHaveBeenCalledWith("- BritdXC6iL (OpenClaw Gateway)");
|
||||
});
|
||||
|
||||
it("omits invalid matrix device last seen timestamps", async () => {
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([
|
||||
{
|
||||
deviceId: "DEVICE123",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: "127.0.0.1",
|
||||
lastSeenTs: 8_700_000_000_000_000,
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("Account: poe");
|
||||
expect(console.log).toHaveBeenCalledWith("- DEVICE123 (current, OpenClaw Gateway)");
|
||||
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
|
||||
expect(
|
||||
consoleLogMock.mock.calls.some(([message]) => String(message).startsWith(" Last seen:")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prunes stale matrix gateway devices", async () => {
|
||||
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
|
||||
before: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictInteger, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
|
||||
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
|
||||
@@ -216,8 +216,9 @@ function printMatrixOwnDevices(
|
||||
console.log(
|
||||
`- ${formatMatrixCliText(device.deviceId)}${labels.length ? ` (${labels.join(", ")})` : ""}`,
|
||||
);
|
||||
if (device.lastSeenTs) {
|
||||
printTimestamp(" Last seen", new Date(device.lastSeenTs).toISOString());
|
||||
const lastSeenAt = timestampMsToIsoString(device.lastSeenTs);
|
||||
if (lastSeenAt) {
|
||||
printTimestamp(" Last seen", lastSeenAt);
|
||||
}
|
||||
if (device.lastSeenIp) {
|
||||
console.log(` Last IP: ${formatMatrixCliText(device.lastSeenIp)}`);
|
||||
|
||||
@@ -23,9 +23,3 @@ export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
|
||||
return handleMatrixSubagentDeliveryTarget(event);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
handleMatrixSubagentDeliveryTarget,
|
||||
handleMatrixSubagentEnded,
|
||||
handleMatrixSubagentSpawning,
|
||||
} from "./src/matrix/subagent-hooks.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
replaceManagedMarkdownBlock,
|
||||
withTrailingNewline,
|
||||
} from "openclaw/plugin-sdk/memory-host-markdown";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { compileMemoryWikiVault } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
@@ -203,7 +204,7 @@ function isoFromUnix(raw: unknown): string | undefined {
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return undefined;
|
||||
}
|
||||
return new Date(numeric * 1000).toISOString();
|
||||
return timestampMsToIsoString(numeric * 1000);
|
||||
}
|
||||
|
||||
function cleanMessageText(value: string): string {
|
||||
|
||||
@@ -605,4 +605,30 @@ cli note
|
||||
.then((entries) => entries.filter((entry) => entry !== "index.md")),
|
||||
).resolves.toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("imports ChatGPT exports with out-of-range Unix timestamps", async () => {
|
||||
const { rootDir, config } = await createCliVault({ initialize: true });
|
||||
const exportDir = await createChatGptExport(rootDir);
|
||||
const conversationsPath = path.join(exportDir, "conversations.json");
|
||||
const conversations = JSON.parse(await fs.readFile(conversationsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
conversations[0].update_time = 9_000_000_000_000;
|
||||
await fs.writeFile(conversationsPath, `${JSON.stringify(conversations, null, 2)}\n`, "utf8");
|
||||
|
||||
const result = await runWikiChatGptImport({
|
||||
config,
|
||||
exportPath: exportDir,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(result.createdCount).toBe(1);
|
||||
const sourceFile = (await fs.readdir(path.join(rootDir, "sources"))).find(
|
||||
(entry) => entry !== "index.md",
|
||||
);
|
||||
expect(sourceFile).toBeDefined();
|
||||
const pageContent = await fs.readFile(path.join(rootDir, "sources", sourceFile ?? ""), "utf8");
|
||||
expect(pageContent).toContain("- Created: 2024-04-06T00:26:40.000Z");
|
||||
expect(pageContent).toContain("- Updated: 2024-04-06T00:26:40.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
48
extensions/memory-wiki/src/source-page-shared.test.ts
Normal file
48
extensions/memory-wiki/src/source-page-shared.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeImportedSourcePage } from "./source-page-shared.js";
|
||||
|
||||
describe("writeImportedSourcePage", () => {
|
||||
let suiteRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(suiteRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("falls back when the source mtime is outside the Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z"));
|
||||
const sourcePath = path.join(suiteRoot, "source.txt");
|
||||
await fs.writeFile(sourcePath, "source body", "utf8");
|
||||
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
|
||||
entries: {},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const result = await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "unsafe:source",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: 8_700_000_000_000_000,
|
||||
sourceSize: 11,
|
||||
renderFingerprint: "fingerprint",
|
||||
pagePath: "pages/source.md",
|
||||
group: "unsafe-local",
|
||||
state,
|
||||
buildRendered: (raw, updatedAt) => `updatedAt: ${updatedAt}\n${raw}`,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(suiteRoot, "pages/source.md"), "utf8")).resolves.toBe(
|
||||
"updatedAt: 2026-05-01T12:00:00.000Z\nsource body",
|
||||
);
|
||||
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
|
||||
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
setImportedSourceEntry,
|
||||
@@ -48,7 +49,7 @@ export async function writeImportedSourcePage(params: {
|
||||
throw error;
|
||||
});
|
||||
const created = !pageStat;
|
||||
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
|
||||
const updatedAt = timestampMsToIsoString(params.sourceUpdatedAtMs) ?? new Date().toISOString();
|
||||
const shouldSkip = await shouldSkipImportedSourceWrite({
|
||||
vaultRoot: params.vaultRoot,
|
||||
syncKey: params.syncKey,
|
||||
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
} from "../policy-state.js";
|
||||
import { POLICY_TOOL_GROUPS } from "../tool-policy-conformance.js";
|
||||
|
||||
let fsPromisesModulePromise: Promise<typeof import("node:fs/promises")> | null = null;
|
||||
|
||||
const loadFsPromisesModule = async () => {
|
||||
fsPromisesModulePromise ??= import("node:fs/promises");
|
||||
return await fsPromisesModulePromise;
|
||||
};
|
||||
|
||||
const CHECK_IDS = {
|
||||
policyAttestationMismatch: "policy/attestation-hash-mismatch",
|
||||
policyDeniedChannelProvider: "policy/channels-denied-provider",
|
||||
@@ -5268,7 +5275,7 @@ async function readPolicyFile(
|
||||
const displayName = policyDisplayName(ctx);
|
||||
const path = resolveWorkspacePath(ctx, policyPathSetting(ctx));
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const fs = await loadFsPromisesModule();
|
||||
return {
|
||||
raw: await fs.readFile(path, "utf-8"),
|
||||
path,
|
||||
@@ -5289,7 +5296,7 @@ async function readWorkspaceFile(
|
||||
): Promise<{ raw: string; path: string } | null> {
|
||||
const path = resolveWorkspacePath(ctx, fileName);
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const fs = await loadFsPromisesModule();
|
||||
return { raw: await fs.readFile(path, "utf-8"), path };
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
|
||||
@@ -38,6 +38,14 @@ import {
|
||||
import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js";
|
||||
import { getBridgeLogger } from "./logger.js";
|
||||
|
||||
let mediaRuntimeModulePromise: Promise<typeof import("openclaw/plugin-sdk/media-runtime")> | null =
|
||||
null;
|
||||
|
||||
const loadMediaRuntimeModule = async () => {
|
||||
mediaRuntimeModulePromise ??= import("openclaw/plugin-sdk/media-runtime");
|
||||
return await mediaRuntimeModulePromise;
|
||||
};
|
||||
|
||||
function createBuiltinAdapter(): PlatformAdapter {
|
||||
return {
|
||||
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
|
||||
@@ -52,7 +60,7 @@ function createBuiltinAdapter(): PlatformAdapter {
|
||||
},
|
||||
|
||||
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
|
||||
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
|
||||
const { readRemoteMediaBuffer } = await loadMediaRuntimeModule();
|
||||
const result = await readRemoteMediaBuffer({ url, filePathHint: filename });
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
@@ -65,7 +73,7 @@ function createBuiltinAdapter(): PlatformAdapter {
|
||||
},
|
||||
|
||||
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
||||
const { readRemoteMediaBuffer } = await import("openclaw/plugin-sdk/media-runtime");
|
||||
const { readRemoteMediaBuffer } = await loadMediaRuntimeModule();
|
||||
const result = await readRemoteMediaBuffer({
|
||||
url: options.url,
|
||||
filePathHint: options.filePathHint,
|
||||
|
||||
@@ -37,6 +37,14 @@ function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
|
||||
return gatewayModulePromise;
|
||||
}
|
||||
|
||||
let outboundMessagingModulePromise:
|
||||
| Promise<typeof import("./engine/messaging/outbound.js")>
|
||||
| undefined;
|
||||
function loadOutboundMessagingModule(): Promise<typeof import("./engine/messaging/outbound.js")> {
|
||||
outboundMessagingModulePromise ??= import("./engine/messaging/outbound.js");
|
||||
return outboundMessagingModulePromise;
|
||||
}
|
||||
|
||||
function createQQBotSendReceipt(params: {
|
||||
messageId?: string;
|
||||
target: string;
|
||||
@@ -69,7 +77,7 @@ async function sendQQBotText(params: {
|
||||
// platform adapter, etc.) have executed before engine code runs.
|
||||
await loadGatewayModule();
|
||||
const account = resolveQQBotAccount(params.cfg, params.accountId);
|
||||
const { sendText } = await import("./engine/messaging/outbound.js");
|
||||
const { sendText } = await loadOutboundMessagingModule();
|
||||
const result = await sendText({
|
||||
to: params.to,
|
||||
text: params.text,
|
||||
@@ -100,7 +108,7 @@ async function sendQQBotMedia(params: {
|
||||
// Same guard as sendText — ensure adapters are registered.
|
||||
await loadGatewayModule();
|
||||
const account = resolveQQBotAccount(params.cfg, params.accountId);
|
||||
const { sendMedia } = await import("./engine/messaging/outbound.js");
|
||||
const { sendMedia } = await loadOutboundMessagingModule();
|
||||
const result = await sendMedia({
|
||||
to: params.to,
|
||||
text: params.text ?? "",
|
||||
|
||||
@@ -4,7 +4,10 @@ import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway
|
||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import { resolveCommandAuthorization } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime";
|
||||
import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
parseStrictFiniteNumber,
|
||||
timestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeUniqueTrimmedStringList,
|
||||
@@ -319,7 +322,10 @@ function formatInteractionSelectionLabel(params: {
|
||||
return params.summary.selectedTime;
|
||||
}
|
||||
if (typeof params.summary.selectedDateTime === "number") {
|
||||
return new Date(params.summary.selectedDateTime * 1000).toISOString();
|
||||
const selectedDateTime = timestampMsToIsoString(params.summary.selectedDateTime * 1000);
|
||||
if (selectedDateTime) {
|
||||
return selectedDateTime;
|
||||
}
|
||||
}
|
||||
if (params.summary.richTextPreview) {
|
||||
return params.summary.richTextPreview;
|
||||
|
||||
@@ -2075,6 +2075,54 @@ describe("registerSlackInteractionEvents", () => {
|
||||
expect(payload.selectedDateTime).toBe(1_771_700_200);
|
||||
});
|
||||
|
||||
it("falls back when Slack datetime selection is outside Date range", async () => {
|
||||
const { ctx, app, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U333" },
|
||||
channel: { id: "C3" },
|
||||
message: {
|
||||
ts: "555.669",
|
||||
text: "fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "datetime_block",
|
||||
elements: [{ type: "datetimepicker", action_id: "openclaw:datetime" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "datetimepicker",
|
||||
action_id: "openclaw:datetime",
|
||||
block_id: "datetime_block",
|
||||
selected_date_time: 9_000_000_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
expectRecordFields(chatUpdateCall(app), {
|
||||
channel: "C3",
|
||||
ts: "555.669",
|
||||
blocks: [
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: ":white_check_mark: *openclaw:datetime* selected by <@U333>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("captures workflow button trigger metadata", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getHandler } = createContext();
|
||||
|
||||
@@ -583,6 +583,37 @@ describe("voice-call plugin", () => {
|
||||
expect(runtimeStub.manager.speak).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports stale call history with invalid ended timestamps", async () => {
|
||||
runtimeStub.manager.getCall = vi.fn(() => undefined);
|
||||
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
|
||||
runtimeStub.manager.getCallHistory = vi.fn(async () => [
|
||||
createCallRecord({
|
||||
callId: "call-1",
|
||||
providerCallId: "CA123",
|
||||
state: "completed",
|
||||
endReason: "completed",
|
||||
endedAt: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
]);
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
const handler = methods.get("voicecall.speak") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
|
||||
await handler?.({ params: { callId: "CA123", message: "hello" }, respond });
|
||||
|
||||
const [ok, , error] = firstRespondCall(respond);
|
||||
expect(ok).toBe(false);
|
||||
expect(error?.message).toContain("call is not active");
|
||||
expect(error?.message).toContain("last state=completed");
|
||||
expect(error?.message).toContain("endReason=completed");
|
||||
expect(error?.message).not.toContain("endedAt=");
|
||||
});
|
||||
|
||||
it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
|
||||
const { methods } = setup({
|
||||
enabled: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
@@ -347,10 +348,11 @@ export default definePluginEntry({
|
||||
if (!call) {
|
||||
return undefined;
|
||||
}
|
||||
const endedAt = timestampMsToIsoString(call.endedAt);
|
||||
const details = [
|
||||
`last state=${call.state}`,
|
||||
call.endReason ? `endReason=${call.endReason}` : undefined,
|
||||
call.endedAt ? `endedAt=${new Date(call.endedAt).toISOString()}` : undefined,
|
||||
endedAt ? `endedAt=${endedAt}` : undefined,
|
||||
].filter(Boolean);
|
||||
return `call is not active (${details.join(", ")})`;
|
||||
};
|
||||
|
||||
7
packages/media-understanding-common/dist/active-model.d.mts
vendored
Normal file
7
packages/media-understanding-common/dist/active-model.d.mts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
//#region packages/media-understanding-common/src/active-model.d.ts
|
||||
type ActiveMediaModel = {
|
||||
provider: string;
|
||||
model?: string;
|
||||
};
|
||||
//#endregion
|
||||
export { ActiveMediaModel };
|
||||
1
packages/media-understanding-common/dist/active-model.mjs
vendored
Normal file
1
packages/media-understanding-common/dist/active-model.mjs
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
14
packages/media-understanding-common/dist/defaults.d.mts
vendored
Normal file
14
packages/media-understanding-common/dist/defaults.d.mts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MediaUnderstandingCapability } from "./types.mjs";
|
||||
|
||||
//#region packages/media-understanding-common/src/defaults.d.ts
|
||||
declare const DEFAULT_MAX_CHARS = 500;
|
||||
declare const DEFAULT_MAX_CHARS_BY_CAPABILITY: Record<MediaUnderstandingCapability, number | undefined>;
|
||||
declare const DEFAULT_MAX_BYTES: Record<MediaUnderstandingCapability, number>;
|
||||
declare const DEFAULT_TIMEOUT_SECONDS: Record<MediaUnderstandingCapability, number>;
|
||||
declare const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string>;
|
||||
declare const DEFAULT_VIDEO_MAX_BASE64_BYTES: number;
|
||||
declare const CLI_OUTPUT_MAX_BUFFER: number;
|
||||
declare const DEFAULT_MEDIA_CONCURRENCY = 2;
|
||||
declare const MIN_AUDIO_FILE_BYTES = 1024;
|
||||
//#endregion
|
||||
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES };
|
||||
29
packages/media-understanding-common/dist/defaults.mjs
vendored
Normal file
29
packages/media-understanding-common/dist/defaults.mjs
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
//#region packages/media-understanding-common/src/defaults.ts
|
||||
const MB = 1024 * 1024;
|
||||
const DEFAULT_MAX_CHARS = 500;
|
||||
const DEFAULT_MAX_CHARS_BY_CAPABILITY = {
|
||||
image: 500,
|
||||
audio: void 0,
|
||||
video: 500
|
||||
};
|
||||
const DEFAULT_MAX_BYTES = {
|
||||
image: 10 * MB,
|
||||
audio: 20 * MB,
|
||||
video: 50 * MB
|
||||
};
|
||||
const DEFAULT_TIMEOUT_SECONDS = {
|
||||
image: 60,
|
||||
audio: 60,
|
||||
video: 120
|
||||
};
|
||||
const DEFAULT_PROMPT = {
|
||||
image: "Describe the image.",
|
||||
audio: "Transcribe the audio.",
|
||||
video: "Describe the video."
|
||||
};
|
||||
const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
|
||||
const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
|
||||
const DEFAULT_MEDIA_CONCURRENCY = 2;
|
||||
const MIN_AUDIO_FILE_BYTES = 1024;
|
||||
//#endregion
|
||||
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES };
|
||||
9
packages/media-understanding-common/dist/errors.d.mts
vendored
Normal file
9
packages/media-understanding-common/dist/errors.d.mts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
//#region packages/media-understanding-common/src/errors.d.ts
|
||||
type MediaUnderstandingSkipReason = "maxBytes" | "timeout" | "unsupported" | "empty" | "blocked" | "tooSmall";
|
||||
declare class MediaUnderstandingSkipError extends Error {
|
||||
readonly reason: MediaUnderstandingSkipReason;
|
||||
constructor(reason: MediaUnderstandingSkipReason, message: string);
|
||||
}
|
||||
declare function isMediaUnderstandingSkipError(err: unknown): err is MediaUnderstandingSkipError;
|
||||
//#endregion
|
||||
export { MediaUnderstandingSkipError, isMediaUnderstandingSkipError };
|
||||
13
packages/media-understanding-common/dist/errors.mjs
vendored
Normal file
13
packages/media-understanding-common/dist/errors.mjs
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
//#region packages/media-understanding-common/src/errors.ts
|
||||
var MediaUnderstandingSkipError = class extends Error {
|
||||
constructor(reason, message) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
this.name = "MediaUnderstandingSkipError";
|
||||
}
|
||||
};
|
||||
function isMediaUnderstandingSkipError(err) {
|
||||
return err instanceof MediaUnderstandingSkipError;
|
||||
}
|
||||
//#endregion
|
||||
export { MediaUnderstandingSkipError, isMediaUnderstandingSkipError };
|
||||
11
packages/media-understanding-common/dist/format.d.mts
vendored
Normal file
11
packages/media-understanding-common/dist/format.d.mts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MediaUnderstandingOutput } from "./types.mjs";
|
||||
|
||||
//#region packages/media-understanding-common/src/format.d.ts
|
||||
declare function extractMediaUserText(body?: string): string | undefined;
|
||||
declare function formatMediaUnderstandingBody(params: {
|
||||
body?: string;
|
||||
outputs: MediaUnderstandingOutput[];
|
||||
}): string;
|
||||
declare function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string;
|
||||
//#endregion
|
||||
export { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody };
|
||||
47
packages/media-understanding-common/dist/format.mjs
vendored
Normal file
47
packages/media-understanding-common/dist/format.mjs
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
//#region packages/media-understanding-common/src/format.ts
|
||||
const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i;
|
||||
const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i;
|
||||
function extractMediaUserText(body) {
|
||||
const trimmed = body?.trim() ?? "";
|
||||
if (!trimmed) return;
|
||||
if (MEDIA_PLACEHOLDER_RE.test(trimmed)) return;
|
||||
return trimmed.replace(MEDIA_PLACEHOLDER_TOKEN_RE, "").trim() || void 0;
|
||||
}
|
||||
function formatSection(title, kind, text, userText) {
|
||||
const lines = [`[${title}]`];
|
||||
if (userText) lines.push(`User text:\n${userText}`);
|
||||
lines.push(`${kind}:\n${text}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
function formatMediaUnderstandingBody(params) {
|
||||
const outputs = params.outputs.filter((output) => output.text.trim());
|
||||
if (outputs.length === 0) return params.body ?? "";
|
||||
const userText = extractMediaUserText(params.body);
|
||||
const sections = [];
|
||||
if (userText && outputs.length > 1) sections.push(`User text:\n${userText}`);
|
||||
const counts = /* @__PURE__ */ new Map();
|
||||
for (const output of outputs) counts.set(output.kind, (counts.get(output.kind) ?? 0) + 1);
|
||||
const seen = /* @__PURE__ */ new Map();
|
||||
for (const output of outputs) {
|
||||
const count = counts.get(output.kind) ?? 1;
|
||||
const next = (seen.get(output.kind) ?? 0) + 1;
|
||||
seen.set(output.kind, next);
|
||||
const suffix = count > 1 ? ` ${next}/${count}` : "";
|
||||
if (output.kind === "audio.transcription") {
|
||||
sections.push(formatSection(`Audio${suffix}`, "Transcript", output.text, outputs.length === 1 ? userText : void 0));
|
||||
continue;
|
||||
}
|
||||
if (output.kind === "image.description") {
|
||||
sections.push(formatSection(`Image${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0));
|
||||
continue;
|
||||
}
|
||||
sections.push(formatSection(`Video${suffix}`, "Description", output.text, outputs.length === 1 ? userText : void 0));
|
||||
}
|
||||
return sections.join("\n\n").trim();
|
||||
}
|
||||
function formatAudioTranscripts(outputs) {
|
||||
if (outputs.length === 1) return outputs[0].text;
|
||||
return outputs.map((output, index) => `Audio ${index + 1}:\n${output.text}`).join("\n\n");
|
||||
}
|
||||
//#endregion
|
||||
export { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody };
|
||||
11
packages/media-understanding-common/dist/index.d.mts
vendored
Normal file
11
packages/media-understanding-common/dist/index.d.mts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ActiveMediaModel } from "./active-model.mjs";
|
||||
import { MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider } from "./types.mjs";
|
||||
import { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES } from "./defaults.mjs";
|
||||
import { MediaUnderstandingSkipError, isMediaUnderstandingSkipError } from "./errors.mjs";
|
||||
import { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody } from "./format.mjs";
|
||||
import { OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString } from "./openai-compatible-video.mjs";
|
||||
import { extractGeminiResponse } from "./output-extract.mjs";
|
||||
import { normalizeMediaExecutionProviderId, normalizeMediaProviderId } from "./provider-id.mjs";
|
||||
import { providerSupportsCapability } from "./provider-supports.mjs";
|
||||
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.mjs";
|
||||
export { ActiveMediaModel, CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES, MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider, MediaUnderstandingSkipError, OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, estimateBase64Size, extractGeminiResponse, extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody, isMediaUnderstandingSkipError, normalizeMediaExecutionProviderId, normalizeMediaProviderId, providerSupportsCapability, resolveMediaUnderstandingString, resolveVideoMaxBase64Bytes };
|
||||
11
packages/media-understanding-common/dist/index.mjs
vendored
Normal file
11
packages/media-understanding-common/dist/index.mjs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "./active-model.mjs";
|
||||
import { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES } from "./defaults.mjs";
|
||||
import { MediaUnderstandingSkipError, isMediaUnderstandingSkipError } from "./errors.mjs";
|
||||
import { extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody } from "./format.mjs";
|
||||
import { buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString } from "./openai-compatible-video.mjs";
|
||||
import { extractGeminiResponse } from "./output-extract.mjs";
|
||||
import { normalizeMediaExecutionProviderId, normalizeMediaProviderId } from "./provider-id.mjs";
|
||||
import { providerSupportsCapability } from "./provider-supports.mjs";
|
||||
import "./types.mjs";
|
||||
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.mjs";
|
||||
export { CLI_OUTPUT_MAX_BUFFER, DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_CHARS_BY_CAPABILITY, DEFAULT_MEDIA_CONCURRENCY, DEFAULT_PROMPT, DEFAULT_TIMEOUT_SECONDS, DEFAULT_VIDEO_MAX_BASE64_BYTES, MIN_AUDIO_FILE_BYTES, MediaUnderstandingSkipError, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, estimateBase64Size, extractGeminiResponse, extractMediaUserText, formatAudioTranscripts, formatMediaUnderstandingBody, isMediaUnderstandingSkipError, normalizeMediaExecutionProviderId, normalizeMediaProviderId, providerSupportsCapability, resolveMediaUnderstandingString, resolveVideoMaxBase64Bytes };
|
||||
37
packages/media-understanding-common/dist/openai-compatible-video.d.mts
vendored
Normal file
37
packages/media-understanding-common/dist/openai-compatible-video.d.mts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
//#region packages/media-understanding-common/src/openai-compatible-video.d.ts
|
||||
type OpenAiCompatibleVideoPayload = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
reasoning_content?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
declare function resolveMediaUnderstandingString(value: string | undefined, fallback: string): string;
|
||||
declare function coerceOpenAiCompatibleVideoText(payload: OpenAiCompatibleVideoPayload): string | null;
|
||||
declare function buildOpenAiCompatibleVideoRequestBody(params: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
mime: string;
|
||||
buffer: Buffer;
|
||||
}): {
|
||||
model: string;
|
||||
messages: {
|
||||
role: string;
|
||||
content: ({
|
||||
type: string;
|
||||
text: string;
|
||||
video_url?: undefined;
|
||||
} | {
|
||||
type: string;
|
||||
video_url: {
|
||||
url: string;
|
||||
};
|
||||
text?: undefined;
|
||||
})[];
|
||||
}[];
|
||||
};
|
||||
//#endregion
|
||||
export { OpenAiCompatibleVideoPayload, buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString };
|
||||
32
packages/media-understanding-common/dist/openai-compatible-video.mjs
vendored
Normal file
32
packages/media-understanding-common/dist/openai-compatible-video.mjs
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
//#region packages/media-understanding-common/src/openai-compatible-video.ts
|
||||
function resolveMediaUnderstandingString(value, fallback) {
|
||||
return value?.trim() || fallback;
|
||||
}
|
||||
function coerceOpenAiCompatibleVideoText(payload) {
|
||||
const message = payload.choices?.[0]?.message;
|
||||
if (!message) return null;
|
||||
if (typeof message.content === "string" && message.content.trim()) return message.content.trim();
|
||||
if (Array.isArray(message.content)) {
|
||||
const text = message.content.map((part) => part.text?.trim() ?? "").filter(Boolean).join("\n");
|
||||
if (text) return text;
|
||||
}
|
||||
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) return message.reasoning_content.trim();
|
||||
return null;
|
||||
}
|
||||
function buildOpenAiCompatibleVideoRequestBody(params) {
|
||||
return {
|
||||
model: params.model,
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: params.prompt
|
||||
}, {
|
||||
type: "video_url",
|
||||
video_url: { url: `data:${params.mime};base64,${params.buffer.toString("base64")}` }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
export { buildOpenAiCompatibleVideoRequestBody, coerceOpenAiCompatibleVideoText, resolveMediaUnderstandingString };
|
||||
4
packages/media-understanding-common/dist/output-extract.d.mts
vendored
Normal file
4
packages/media-understanding-common/dist/output-extract.d.mts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
//#region packages/media-understanding-common/src/output-extract.d.ts
|
||||
declare function extractGeminiResponse(raw: string): string | null;
|
||||
//#endregion
|
||||
export { extractGeminiResponse };
|
||||
21
packages/media-understanding-common/dist/output-extract.mjs
vendored
Normal file
21
packages/media-understanding-common/dist/output-extract.mjs
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
//#region packages/media-understanding-common/src/output-extract.ts
|
||||
function extractLastJsonObject(raw) {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) return null;
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function extractGeminiResponse(raw) {
|
||||
const payload = extractLastJsonObject(raw);
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const response = payload.response;
|
||||
if (typeof response !== "string") return null;
|
||||
return response.trim() || null;
|
||||
}
|
||||
//#endregion
|
||||
export { extractGeminiResponse };
|
||||
5
packages/media-understanding-common/dist/provider-id.d.mts
vendored
Normal file
5
packages/media-understanding-common/dist/provider-id.d.mts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
//#region packages/media-understanding-common/src/provider-id.d.ts
|
||||
declare function normalizeMediaProviderId(id: string): string;
|
||||
declare function normalizeMediaExecutionProviderId(id: string): string;
|
||||
//#endregion
|
||||
export { normalizeMediaExecutionProviderId, normalizeMediaProviderId };
|
||||
18
packages/media-understanding-common/dist/provider-id.mjs
vendored
Normal file
18
packages/media-understanding-common/dist/provider-id.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
//#region packages/media-understanding-common/src/provider-id.ts
|
||||
function normalizeProviderId(provider) {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
function normalizeMediaProviderId(id) {
|
||||
const normalized = normalizeProviderId(id);
|
||||
if (normalized === "gemini") return "google";
|
||||
if (normalized === "minimax-cn") return "minimax";
|
||||
if (normalized === "minimax-portal-cn") return "minimax-portal";
|
||||
return normalized;
|
||||
}
|
||||
function normalizeMediaExecutionProviderId(id) {
|
||||
const normalized = normalizeProviderId(id);
|
||||
if (normalized === "minimax-cn" || normalized === "minimax-portal-cn") return normalized;
|
||||
return normalizeMediaProviderId(normalized);
|
||||
}
|
||||
//#endregion
|
||||
export { normalizeMediaExecutionProviderId, normalizeMediaProviderId };
|
||||
6
packages/media-understanding-common/dist/provider-supports.d.mts
vendored
Normal file
6
packages/media-understanding-common/dist/provider-supports.d.mts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { MediaUnderstandingCapability, MediaUnderstandingProvider } from "./types.mjs";
|
||||
|
||||
//#region packages/media-understanding-common/src/provider-supports.d.ts
|
||||
declare function providerSupportsCapability(provider: MediaUnderstandingProvider | undefined, capability: MediaUnderstandingCapability): boolean;
|
||||
//#endregion
|
||||
export { providerSupportsCapability };
|
||||
9
packages/media-understanding-common/dist/provider-supports.mjs
vendored
Normal file
9
packages/media-understanding-common/dist/provider-supports.mjs
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
//#region packages/media-understanding-common/src/provider-supports.ts
|
||||
function providerSupportsCapability(provider, capability) {
|
||||
if (!provider) return false;
|
||||
if (capability === "audio") return Boolean(provider.transcribeAudio);
|
||||
if (capability === "image") return Boolean(provider.describeImage);
|
||||
return Boolean(provider.describeVideo);
|
||||
}
|
||||
//#endregion
|
||||
export { providerSupportsCapability };
|
||||
31
packages/media-understanding-common/dist/types.d.mts
vendored
Normal file
31
packages/media-understanding-common/dist/types.d.mts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
//#region packages/media-understanding-common/src/types.d.ts
|
||||
type MediaUnderstandingKind = "audio.transcription" | "video.description" | "image.description";
|
||||
type MediaUnderstandingCapability = "image" | "audio" | "video";
|
||||
type MediaUnderstandingCapabilityRegistry = Map<string, {
|
||||
capabilities?: MediaUnderstandingCapability[];
|
||||
}>;
|
||||
type MediaAttachment = {
|
||||
path?: string;
|
||||
url?: string;
|
||||
mime?: string;
|
||||
index: number;
|
||||
alreadyTranscribed?: boolean;
|
||||
};
|
||||
type MediaUnderstandingOutput = {
|
||||
kind: MediaUnderstandingKind;
|
||||
attachmentIndex: number;
|
||||
text: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
};
|
||||
type MediaUnderstandingProvider = {
|
||||
id: string;
|
||||
capabilities?: MediaUnderstandingCapability[];
|
||||
transcribeAudio?: unknown;
|
||||
describeVideo?: unknown;
|
||||
describeImage?: unknown;
|
||||
describeImages?: unknown;
|
||||
extractStructured?: unknown;
|
||||
};
|
||||
//#endregion
|
||||
export { MediaAttachment, MediaUnderstandingCapability, MediaUnderstandingCapabilityRegistry, MediaUnderstandingKind, MediaUnderstandingOutput, MediaUnderstandingProvider };
|
||||
1
packages/media-understanding-common/dist/types.mjs
vendored
Normal file
1
packages/media-understanding-common/dist/types.mjs
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
5
packages/media-understanding-common/dist/video.d.mts
vendored
Normal file
5
packages/media-understanding-common/dist/video.d.mts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
//#region packages/media-understanding-common/src/video.d.ts
|
||||
declare function estimateBase64Size(bytes: number): number;
|
||||
declare function resolveVideoMaxBase64Bytes(maxBytes: number): number;
|
||||
//#endregion
|
||||
export { estimateBase64Size, resolveVideoMaxBase64Bytes };
|
||||
11
packages/media-understanding-common/dist/video.mjs
vendored
Normal file
11
packages/media-understanding-common/dist/video.mjs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_VIDEO_MAX_BASE64_BYTES } from "./defaults.mjs";
|
||||
//#region packages/media-understanding-common/src/video.ts
|
||||
function estimateBase64Size(bytes) {
|
||||
return Math.ceil(bytes / 3) * 4;
|
||||
}
|
||||
function resolveVideoMaxBase64Bytes(maxBytes) {
|
||||
const expanded = Math.floor(maxBytes * (4 / 3));
|
||||
return Math.min(expanded, DEFAULT_VIDEO_MAX_BASE64_BYTES);
|
||||
}
|
||||
//#endregion
|
||||
export { estimateBase64Size, resolveVideoMaxBase64Bytes };
|
||||
71
packages/media-understanding-common/package.json
Normal file
71
packages/media-understanding-common/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@openclaw/media-understanding-common",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./active-model": {
|
||||
"types": "./dist/active-model.d.mts",
|
||||
"import": "./dist/active-model.mjs",
|
||||
"default": "./dist/active-model.mjs"
|
||||
},
|
||||
"./defaults": {
|
||||
"types": "./dist/defaults.d.mts",
|
||||
"import": "./dist/defaults.mjs",
|
||||
"default": "./dist/defaults.mjs"
|
||||
},
|
||||
"./errors": {
|
||||
"types": "./dist/errors.d.mts",
|
||||
"import": "./dist/errors.mjs",
|
||||
"default": "./dist/errors.mjs"
|
||||
},
|
||||
"./format": {
|
||||
"types": "./dist/format.d.mts",
|
||||
"import": "./dist/format.mjs",
|
||||
"default": "./dist/format.mjs"
|
||||
},
|
||||
"./openai-compatible-video": {
|
||||
"types": "./dist/openai-compatible-video.d.mts",
|
||||
"import": "./dist/openai-compatible-video.mjs",
|
||||
"default": "./dist/openai-compatible-video.mjs"
|
||||
},
|
||||
"./output-extract": {
|
||||
"types": "./dist/output-extract.d.mts",
|
||||
"import": "./dist/output-extract.mjs",
|
||||
"default": "./dist/output-extract.mjs"
|
||||
},
|
||||
"./provider-id": {
|
||||
"types": "./dist/provider-id.d.mts",
|
||||
"import": "./dist/provider-id.mjs",
|
||||
"default": "./dist/provider-id.mjs"
|
||||
},
|
||||
"./provider-supports": {
|
||||
"types": "./dist/provider-supports.d.mts",
|
||||
"import": "./dist/provider-supports.mjs",
|
||||
"default": "./dist/provider-supports.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.mts",
|
||||
"import": "./dist/types.mjs",
|
||||
"default": "./dist/types.mjs"
|
||||
},
|
||||
"./video": {
|
||||
"types": "./dist/video.d.mts",
|
||||
"import": "./dist/video.mjs",
|
||||
"default": "./dist/video.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/active-model.ts src/defaults.ts src/errors.ts src/format.ts src/openai-compatible-video.ts src/output-extract.ts src/provider-id.ts src/provider-supports.ts src/types.ts src/video.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
}
|
||||
}
|
||||
4
packages/media-understanding-common/src/active-model.ts
Normal file
4
packages/media-understanding-common/src/active-model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type ActiveMediaModel = {
|
||||
provider: string;
|
||||
model?: string;
|
||||
};
|
||||
32
packages/media-understanding-common/src/defaults.ts
Normal file
32
packages/media-understanding-common/src/defaults.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { MediaUnderstandingCapability } from "./types.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export const DEFAULT_MAX_CHARS = 500;
|
||||
export const DEFAULT_MAX_CHARS_BY_CAPABILITY: Record<
|
||||
MediaUnderstandingCapability,
|
||||
number | undefined
|
||||
> = {
|
||||
image: DEFAULT_MAX_CHARS,
|
||||
audio: undefined,
|
||||
video: DEFAULT_MAX_CHARS,
|
||||
};
|
||||
export const DEFAULT_MAX_BYTES: Record<MediaUnderstandingCapability, number> = {
|
||||
image: 10 * MB,
|
||||
audio: 20 * MB,
|
||||
video: 50 * MB,
|
||||
};
|
||||
export const DEFAULT_TIMEOUT_SECONDS: Record<MediaUnderstandingCapability, number> = {
|
||||
image: 60,
|
||||
audio: 60,
|
||||
video: 120,
|
||||
};
|
||||
export const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string> = {
|
||||
image: "Describe the image.",
|
||||
audio: "Transcribe the audio.",
|
||||
video: "Describe the video.",
|
||||
};
|
||||
export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
|
||||
export const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
|
||||
export const DEFAULT_MEDIA_CONCURRENCY = 2;
|
||||
export const MIN_AUDIO_FILE_BYTES = 1024;
|
||||
21
packages/media-understanding-common/src/errors.ts
Normal file
21
packages/media-understanding-common/src/errors.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type MediaUnderstandingSkipReason =
|
||||
| "maxBytes"
|
||||
| "timeout"
|
||||
| "unsupported"
|
||||
| "empty"
|
||||
| "blocked"
|
||||
| "tooSmall";
|
||||
|
||||
export class MediaUnderstandingSkipError extends Error {
|
||||
readonly reason: MediaUnderstandingSkipReason;
|
||||
|
||||
constructor(reason: MediaUnderstandingSkipReason, message: string) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
this.name = "MediaUnderstandingSkipError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isMediaUnderstandingSkipError(err: unknown): err is MediaUnderstandingSkipError {
|
||||
return err instanceof MediaUnderstandingSkipError;
|
||||
}
|
||||
98
packages/media-understanding-common/src/format.ts
Normal file
98
packages/media-understanding-common/src/format.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { MediaUnderstandingOutput } from "./types.js";
|
||||
|
||||
const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i;
|
||||
const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i;
|
||||
|
||||
export function extractMediaUserText(body?: string): string | undefined {
|
||||
const trimmed = body?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (MEDIA_PLACEHOLDER_RE.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = trimmed.replace(MEDIA_PLACEHOLDER_TOKEN_RE, "").trim();
|
||||
return cleaned || undefined;
|
||||
}
|
||||
|
||||
function formatSection(
|
||||
title: string,
|
||||
kind: "Transcript" | "Description",
|
||||
text: string,
|
||||
userText?: string,
|
||||
): string {
|
||||
const lines = [`[${title}]`];
|
||||
if (userText) {
|
||||
lines.push(`User text:\n${userText}`);
|
||||
}
|
||||
lines.push(`${kind}:\n${text}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatMediaUnderstandingBody(params: {
|
||||
body?: string;
|
||||
outputs: MediaUnderstandingOutput[];
|
||||
}): string {
|
||||
const outputs = params.outputs.filter((output) => output.text.trim());
|
||||
if (outputs.length === 0) {
|
||||
return params.body ?? "";
|
||||
}
|
||||
|
||||
const userText = extractMediaUserText(params.body);
|
||||
const sections: string[] = [];
|
||||
if (userText && outputs.length > 1) {
|
||||
sections.push(`User text:\n${userText}`);
|
||||
}
|
||||
|
||||
const counts = new Map<MediaUnderstandingOutput["kind"], number>();
|
||||
for (const output of outputs) {
|
||||
counts.set(output.kind, (counts.get(output.kind) ?? 0) + 1);
|
||||
}
|
||||
const seen = new Map<MediaUnderstandingOutput["kind"], number>();
|
||||
|
||||
for (const output of outputs) {
|
||||
const count = counts.get(output.kind) ?? 1;
|
||||
const next = (seen.get(output.kind) ?? 0) + 1;
|
||||
seen.set(output.kind, next);
|
||||
const suffix = count > 1 ? ` ${next}/${count}` : "";
|
||||
if (output.kind === "audio.transcription") {
|
||||
sections.push(
|
||||
formatSection(
|
||||
`Audio${suffix}`,
|
||||
"Transcript",
|
||||
output.text,
|
||||
outputs.length === 1 ? userText : undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (output.kind === "image.description") {
|
||||
sections.push(
|
||||
formatSection(
|
||||
`Image${suffix}`,
|
||||
"Description",
|
||||
output.text,
|
||||
outputs.length === 1 ? userText : undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
sections.push(
|
||||
formatSection(
|
||||
`Video${suffix}`,
|
||||
"Description",
|
||||
output.text,
|
||||
outputs.length === 1 ? userText : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join("\n\n").trim();
|
||||
}
|
||||
|
||||
export function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string {
|
||||
if (outputs.length === 1) {
|
||||
return outputs[0].text;
|
||||
}
|
||||
return outputs.map((output, index) => `Audio ${index + 1}:\n${output.text}`).join("\n\n");
|
||||
}
|
||||
10
packages/media-understanding-common/src/index.ts
Normal file
10
packages/media-understanding-common/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from "./active-model.js";
|
||||
export * from "./defaults.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./format.js";
|
||||
export * from "./openai-compatible-video.js";
|
||||
export * from "./output-extract.js";
|
||||
export * from "./provider-id.js";
|
||||
export * from "./provider-supports.js";
|
||||
export * from "./types.js";
|
||||
export * from "./video.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
export type OpenAiCompatibleVideoPayload = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | Array<{ text?: string }>;
|
||||
reasoning_content?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export function resolveMediaUnderstandingString(
|
||||
value: string | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
export function coerceOpenAiCompatibleVideoText(
|
||||
payload: OpenAiCompatibleVideoPayload,
|
||||
): string | null {
|
||||
const message = payload.choices?.[0]?.message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof message.content === "string" && message.content.trim()) {
|
||||
return message.content.trim();
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const text = message.content
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) {
|
||||
return message.reasoning_content.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildOpenAiCompatibleVideoRequestBody(params: {
|
||||
model: string;
|
||||
prompt: string;
|
||||
mime: string;
|
||||
buffer: Buffer;
|
||||
}) {
|
||||
return {
|
||||
model: params.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: params.prompt },
|
||||
{
|
||||
type: "video_url",
|
||||
video_url: {
|
||||
url: `data:${params.mime};base64,${params.buffer.toString("base64")}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
26
packages/media-understanding-common/src/output-extract.ts
Normal file
26
packages/media-understanding-common/src/output-extract.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
function extractLastJsonObject(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractGeminiResponse(raw: string): string | null {
|
||||
const payload = extractLastJsonObject(raw);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const response = (payload as { response?: unknown }).response;
|
||||
if (typeof response !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = response.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
25
packages/media-understanding-common/src/provider-id.ts
Normal file
25
packages/media-understanding-common/src/provider-id.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
function normalizeProviderId(provider: string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeMediaProviderId(id: string): string {
|
||||
const normalized = normalizeProviderId(id);
|
||||
if (normalized === "gemini") {
|
||||
return "google";
|
||||
}
|
||||
if (normalized === "minimax-cn") {
|
||||
return "minimax";
|
||||
}
|
||||
if (normalized === "minimax-portal-cn") {
|
||||
return "minimax-portal";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeMediaExecutionProviderId(id: string): string {
|
||||
const normalized = normalizeProviderId(id);
|
||||
if (normalized === "minimax-cn" || normalized === "minimax-portal-cn") {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeMediaProviderId(normalized);
|
||||
}
|
||||
17
packages/media-understanding-common/src/provider-supports.ts
Normal file
17
packages/media-understanding-common/src/provider-supports.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { MediaUnderstandingCapability, MediaUnderstandingProvider } from "./types.js";
|
||||
|
||||
export function providerSupportsCapability(
|
||||
provider: MediaUnderstandingProvider | undefined,
|
||||
capability: MediaUnderstandingCapability,
|
||||
): boolean {
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
if (capability === "audio") {
|
||||
return Boolean(provider.transcribeAudio);
|
||||
}
|
||||
if (capability === "image") {
|
||||
return Boolean(provider.describeImage);
|
||||
}
|
||||
return Boolean(provider.describeVideo);
|
||||
}
|
||||
39
packages/media-understanding-common/src/types.ts
Normal file
39
packages/media-understanding-common/src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type MediaUnderstandingKind =
|
||||
| "audio.transcription"
|
||||
| "video.description"
|
||||
| "image.description";
|
||||
|
||||
export type MediaUnderstandingCapability = "image" | "audio" | "video";
|
||||
|
||||
export type MediaUnderstandingCapabilityRegistry = Map<
|
||||
string,
|
||||
{
|
||||
capabilities?: MediaUnderstandingCapability[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type MediaAttachment = {
|
||||
path?: string;
|
||||
url?: string;
|
||||
mime?: string;
|
||||
index: number;
|
||||
alreadyTranscribed?: boolean;
|
||||
};
|
||||
|
||||
export type MediaUnderstandingOutput = {
|
||||
kind: MediaUnderstandingKind;
|
||||
attachmentIndex: number;
|
||||
text: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type MediaUnderstandingProvider = {
|
||||
id: string;
|
||||
capabilities?: MediaUnderstandingCapability[];
|
||||
transcribeAudio?: unknown;
|
||||
describeVideo?: unknown;
|
||||
describeImage?: unknown;
|
||||
describeImages?: unknown;
|
||||
extractStructured?: unknown;
|
||||
};
|
||||
10
packages/media-understanding-common/src/video.ts
Normal file
10
packages/media-understanding-common/src/video.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_VIDEO_MAX_BASE64_BYTES } from "./defaults.js";
|
||||
|
||||
export function estimateBase64Size(bytes: number): number {
|
||||
return Math.ceil(bytes / 3) * 4;
|
||||
}
|
||||
|
||||
export function resolveVideoMaxBase64Bytes(maxBytes: number): number {
|
||||
const expanded = Math.floor(maxBytes * (4 / 3));
|
||||
return Math.min(expanded, DEFAULT_VIDEO_MAX_BASE64_BYTES);
|
||||
}
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -1832,6 +1832,8 @@ importers:
|
||||
|
||||
packages/media-generation-core: {}
|
||||
|
||||
packages/media-understanding-common: {}
|
||||
|
||||
packages/memory-host-sdk: {}
|
||||
|
||||
packages/net-policy:
|
||||
|
||||
@@ -48,6 +48,7 @@ export const BUILD_ALL_STEPS = [
|
||||
"packages/plugin-sdk/package.json",
|
||||
"packages/llm-core/package.json",
|
||||
"packages/markdown-core/package.json",
|
||||
"packages/media-understanding-common/package.json",
|
||||
"packages/terminal-core/package.json",
|
||||
"packages/memory-host-sdk/package.json",
|
||||
"tsconfig.json",
|
||||
@@ -57,6 +58,7 @@ export const BUILD_ALL_STEPS = [
|
||||
"packages/markdown-core/src",
|
||||
"packages/memory-host-sdk/src",
|
||||
"packages/media-generation-core/src",
|
||||
"packages/media-understanding-common/src",
|
||||
"packages/terminal-core/src",
|
||||
"src/types",
|
||||
"src/video-generation/dashscope-compatible.ts",
|
||||
@@ -171,6 +173,44 @@ export const BUILD_ALL_PROFILE_STEP_ENV = {
|
||||
},
|
||||
};
|
||||
|
||||
export function buildAllUsage() {
|
||||
return [
|
||||
"Usage: node scripts/build-all.mjs [profile]",
|
||||
"",
|
||||
"Builds OpenClaw artifacts for the selected profile.",
|
||||
"",
|
||||
"Profiles:",
|
||||
...Object.keys(BUILD_ALL_PROFILES).map((profile) => ` ${profile}`),
|
||||
"",
|
||||
"Options:",
|
||||
" -h, --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseBuildAllArgs(argv) {
|
||||
const args = {
|
||||
help: false,
|
||||
profile: "full",
|
||||
};
|
||||
let sawProfile = false;
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else if (arg.startsWith("-")) {
|
||||
throw new Error(`unknown argument: ${arg}\n\n${buildAllUsage()}`);
|
||||
} else if (sawProfile) {
|
||||
throw new Error(`unexpected argument: ${arg}\n\n${buildAllUsage()}`);
|
||||
} else {
|
||||
args.profile = arg;
|
||||
sawProfile = true;
|
||||
}
|
||||
}
|
||||
if (!args.help && !BUILD_ALL_PROFILES[args.profile]) {
|
||||
throw new Error(`Unknown build profile: ${args.profile}\n\n${buildAllUsage()}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function resolveBuildAllSteps(profile = "full") {
|
||||
const labels = BUILD_ALL_PROFILES[profile];
|
||||
if (!labels) {
|
||||
@@ -463,44 +503,54 @@ function isMainModule() {
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const profile = process.argv[2] ?? "full";
|
||||
const timings = [];
|
||||
let exitCode = 0;
|
||||
for (const step of resolveBuildAllSteps(profile)) {
|
||||
const startedAt = performance.now();
|
||||
const cacheState = resolveBuildAllStepCacheState(step);
|
||||
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
|
||||
restoreBuildAllStepCacheOutputs(cacheState);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
timings.push({ label: step.label, status: "cached", durationMs });
|
||||
console.error(`[build-all] ${step.label} (cached) ${formatBuildAllDuration(durationMs)}`);
|
||||
continue;
|
||||
}
|
||||
console.error(`[build-all] ${step.label}`);
|
||||
const invocation = resolveBuildAllStep(step);
|
||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
if (typeof result.status === "number") {
|
||||
if (result.status !== 0) {
|
||||
timings.push({ label: step.label, status: "failed", durationMs });
|
||||
console.error(
|
||||
`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`,
|
||||
);
|
||||
exitCode = result.status;
|
||||
break;
|
||||
}
|
||||
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheState(step));
|
||||
timings.push({ label: step.label, status: "ran", durationMs });
|
||||
console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`);
|
||||
continue;
|
||||
}
|
||||
timings.push({ label: step.label, status: "failed", durationMs });
|
||||
console.error(`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`);
|
||||
exitCode = 1;
|
||||
break;
|
||||
let args;
|
||||
try {
|
||||
args = parseBuildAllArgs(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(formatBuildAllTimingSummary(timings));
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
if (args?.help) {
|
||||
console.log(buildAllUsage());
|
||||
} else {
|
||||
const timings = [];
|
||||
let exitCode = 0;
|
||||
for (const step of resolveBuildAllSteps(args.profile)) {
|
||||
const startedAt = performance.now();
|
||||
const cacheState = resolveBuildAllStepCacheState(step);
|
||||
if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) {
|
||||
restoreBuildAllStepCacheOutputs(cacheState);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
timings.push({ label: step.label, status: "cached", durationMs });
|
||||
console.error(`[build-all] ${step.label} (cached) ${formatBuildAllDuration(durationMs)}`);
|
||||
continue;
|
||||
}
|
||||
console.error(`[build-all] ${step.label}`);
|
||||
const invocation = resolveBuildAllStep(step);
|
||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
if (typeof result.status === "number") {
|
||||
if (result.status !== 0) {
|
||||
timings.push({ label: step.label, status: "failed", durationMs });
|
||||
console.error(
|
||||
`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`,
|
||||
);
|
||||
exitCode = result.status;
|
||||
break;
|
||||
}
|
||||
writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheState(step));
|
||||
timings.push({ label: step.label, status: "ran", durationMs });
|
||||
console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`);
|
||||
continue;
|
||||
}
|
||||
timings.push({ label: step.label, status: "failed", durationMs });
|
||||
console.error(`[build-all] ${step.label} failed after ${formatBuildAllDuration(durationMs)}`);
|
||||
exitCode = 1;
|
||||
break;
|
||||
}
|
||||
console.error(formatBuildAllTimingSummary(timings));
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,19 @@ function isTypeOnlyImportDeclaration(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function isTypeOnlyExportDeclaration(node) {
|
||||
if (node.isTypeOnly === true) {
|
||||
return true;
|
||||
}
|
||||
const clause = node.exportClause;
|
||||
return (
|
||||
Boolean(clause) &&
|
||||
ts.isNamedExports(clause) &&
|
||||
clause.elements.length > 0 &&
|
||||
clause.elements.every((element) => element.isTypeOnly)
|
||||
);
|
||||
}
|
||||
|
||||
function readDeclarationName(node) {
|
||||
if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
@@ -104,6 +117,15 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
|
||||
addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node));
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
!isTypeOnlyExportDeclaration(node)
|
||||
) {
|
||||
addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node));
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
|
||||
@@ -6,12 +6,17 @@ import { spawnSync } from "node:child_process";
|
||||
|
||||
const ACTIONLINT_VERSION = "1.7.11";
|
||||
|
||||
function commandExists(command) {
|
||||
return spawnSync("bash", ["-lc", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
||||
function commandExists(command, args = ["--version"]) {
|
||||
const result = spawnSync(command, args, { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" });
|
||||
if (result.error) {
|
||||
console.error(`[check-workflows] failed to run ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -19,8 +24,13 @@ function run(command, args) {
|
||||
|
||||
if (commandExists("actionlint")) {
|
||||
run("actionlint", []);
|
||||
} else {
|
||||
} else if (commandExists("go", ["version"])) {
|
||||
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
|
||||
} else {
|
||||
console.error(
|
||||
`[check-workflows] missing workflow linter: install actionlint or Go ${ACTIONLINT_VERSION} fallback support.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run("python3", ["scripts/check-composite-action-input-interpolation.py"]);
|
||||
|
||||
@@ -2,15 +2,62 @@ import { performance } from "node:perf_hooks";
|
||||
import { printTimingSummary } from "./lib/check-timing-summary.mjs";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mjs";
|
||||
|
||||
export function usage() {
|
||||
return [
|
||||
"Usage: node scripts/check.mjs [--timed] [--include-architecture] [--include-test-types]",
|
||||
"",
|
||||
"Runs the local check graph: guard preflights, typecheck, lint, and policy guards.",
|
||||
"",
|
||||
"Options:",
|
||||
" --timed Print timing summary even when checks pass.",
|
||||
" --include-architecture Run architecture import-cycle checks instead of runtime cycles.",
|
||||
" --include-test-types Typecheck production and test sources.",
|
||||
" -h, --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseCheckArgs(argv) {
|
||||
const args = {
|
||||
help: false,
|
||||
includeArchitecture: false,
|
||||
includeTestTypes: false,
|
||||
timed: false,
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (arg === "--timed") {
|
||||
args.timed = true;
|
||||
} else if (arg === "--include-architecture") {
|
||||
args.includeArchitecture = true;
|
||||
} else if (arg === "--include-test-types") {
|
||||
args.includeTestTypes = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const timed = argv.includes("--timed");
|
||||
const includeArchitecture = argv.includes("--include-architecture");
|
||||
const includeTestTypes = argv.includes("--include-test-types");
|
||||
let args;
|
||||
try {
|
||||
args = parseCheckArgs(argv);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const tailChecks = [
|
||||
{ name: "webhook body guard", args: ["lint:webhook:no-low-level-body-read"] },
|
||||
{ name: "runtime action config guard", args: ["check:no-runtime-action-load-config"] },
|
||||
!includeArchitecture
|
||||
!args.includeArchitecture
|
||||
? {
|
||||
name: "deprecated API usage guard",
|
||||
args: ["check:deprecated-api-usage"],
|
||||
@@ -19,7 +66,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
{ name: "temp path guard", args: ["check:temp-path-guardrails"] },
|
||||
{ name: "pairing store guard", args: ["lint:auth:no-pairing-store-group"] },
|
||||
{ name: "pairing account guard", args: ["lint:auth:pairing-account-scope"] },
|
||||
includeArchitecture
|
||||
args.includeArchitecture
|
||||
? { name: "architecture import cycles", args: ["check:architecture"] }
|
||||
: { name: "runtime import cycles", args: ["check:import-cycles"] },
|
||||
].filter(Boolean);
|
||||
@@ -58,8 +105,8 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
parallel: false,
|
||||
commands: [
|
||||
{
|
||||
name: includeTestTypes ? "typecheck all" : "typecheck prod",
|
||||
args: [includeTestTypes ? "tsgo:all" : "tsgo:prod"],
|
||||
name: args.includeTestTypes ? "typecheck all" : "typecheck prod",
|
||||
args: [args.includeTestTypes ? "tsgo:all" : "tsgo:prod"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -92,7 +139,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (timed || exitCode !== 0) {
|
||||
if (args.timed || exitCode !== 0) {
|
||||
printSummary(timings);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { die, run, say, sh, warn } from "./host-command.ts";
|
||||
import type { HostServer } from "./types.ts";
|
||||
|
||||
const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024;
|
||||
const HOST_SERVER_STDERR_DRAIN_MS = 5_000;
|
||||
|
||||
export function resolveHostIp(explicit = ""): string {
|
||||
if (explicit) {
|
||||
@@ -113,7 +114,7 @@ async function waitForHostServer(
|
||||
while (Date.now() - startedAt < 10_000) {
|
||||
if (child.exitCode != null) {
|
||||
if (!childClosed) {
|
||||
await Promise.race([childClose, delay(1_000)]);
|
||||
await Promise.race([childClose, delay(HOST_SERVER_STDERR_DRAIN_MS)]);
|
||||
}
|
||||
die(`host artifact server exited early: ${stderr.trim() || `exit ${child.exitCode}`}`);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
makeTempDir,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
packOpenClaw,
|
||||
parseBoolEnv,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
@@ -16,19 +13,15 @@ import {
|
||||
posixProviderOnlyPluginIsolationScript,
|
||||
repoRoot,
|
||||
resolveParallelsModelTimeoutSeconds,
|
||||
resolveHostIp,
|
||||
resolveHostPort,
|
||||
resolveLatestVersion,
|
||||
resolveProviderAuth,
|
||||
resolveSnapshot,
|
||||
run,
|
||||
say,
|
||||
shellQuote,
|
||||
startHostServer,
|
||||
warn,
|
||||
writeJson,
|
||||
writeSummaryMarkdown,
|
||||
type HostServer,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
@@ -36,9 +29,19 @@ import {
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
import { LinuxGuest } from "./guest-transports.ts";
|
||||
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
|
||||
import { resolveUbuntuVmName, waitForVmStatus } from "./parallels-vm.ts";
|
||||
import { PhaseRunner } from "./phase-runner.ts";
|
||||
import {
|
||||
buildCommonSmokeSummary,
|
||||
expectedPackageBuildCommit,
|
||||
expectedPackageTargetVersion,
|
||||
extractLastOpenClawVersion,
|
||||
packAndServeSmokeArtifact,
|
||||
printSmokeTargetSummary,
|
||||
SmokeRunController,
|
||||
type SmokeHostOptions,
|
||||
type SmokeRunOptions,
|
||||
} from "./smoke-common.ts";
|
||||
|
||||
// Older published baselines predate this warning, but still need update coverage.
|
||||
const BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION = "2026.5.7";
|
||||
@@ -66,23 +69,13 @@ function compareOpenClawPackageVersions(left: string, right: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface LinuxOptions {
|
||||
interface LinuxOptions extends SmokeHostOptions, SmokeRunOptions {
|
||||
vmName: string;
|
||||
vmNameExplicit: boolean;
|
||||
snapshotHint: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
installUrl: string;
|
||||
hostPort: number;
|
||||
hostPortExplicit: boolean;
|
||||
hostIp?: string;
|
||||
latestVersion?: string;
|
||||
installVersion?: string;
|
||||
targetPackageSpec?: string;
|
||||
keepServer: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface LinuxSummary {
|
||||
@@ -232,21 +225,16 @@ function parseArgs(argv: string[]): LinuxOptions {
|
||||
return options;
|
||||
}
|
||||
|
||||
class LinuxSmoke {
|
||||
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
||||
private auth: ProviderAuth;
|
||||
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
|
||||
private hostIp = "";
|
||||
private hostPort = 0;
|
||||
private server: HostServer | null = null;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private latestVersion = "";
|
||||
private snapshot!: SnapshotInfo;
|
||||
private phases!: PhaseRunner;
|
||||
private guest!: LinuxGuest;
|
||||
|
||||
private status = {
|
||||
protected status = {
|
||||
daemon: "systemd-user-unavailable",
|
||||
freshAgent: "skip",
|
||||
freshGateway: "skip",
|
||||
@@ -259,7 +247,8 @@ class LinuxSmoke {
|
||||
upgradeVersion: "skip",
|
||||
};
|
||||
|
||||
constructor(private options: LinuxOptions) {
|
||||
constructor(options: LinuxOptions) {
|
||||
super(options);
|
||||
this.auth = resolveProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
@@ -276,72 +265,24 @@ class LinuxSmoke {
|
||||
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
|
||||
this.guest = new LinuxGuest(this.options.vmName, this.phases);
|
||||
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
|
||||
this.hostIp = resolveHostIp(this.options.hostIp);
|
||||
this.hostPort = await resolveHostPort(
|
||||
this.options.hostPort,
|
||||
this.options.hostPortExplicit,
|
||||
await this.prepareHost(
|
||||
defaultOptions().hostPort,
|
||||
this.latestVersion,
|
||||
this.snapshot,
|
||||
this.options.vmName,
|
||||
);
|
||||
|
||||
say(`VM: ${this.options.vmName}`);
|
||||
say(`Snapshot hint: ${this.options.snapshotHint}`);
|
||||
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
|
||||
say(`Latest npm version: ${this.latestVersion}`);
|
||||
say(
|
||||
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
|
||||
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
|
||||
this.tgzDir,
|
||||
this.options.targetPackageSpec,
|
||||
this.hostIp,
|
||||
this.hostPort,
|
||||
this.artifactLabel(),
|
||||
);
|
||||
say(`Run logs: ${this.runDir}`);
|
||||
|
||||
this.artifact = await packOpenClaw({
|
||||
destination: this.tgzDir,
|
||||
packageSpec: this.options.targetPackageSpec,
|
||||
requireControlUi: false,
|
||||
});
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.artifact.path,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: this.artifactLabel(),
|
||||
port: this.hostPort,
|
||||
});
|
||||
this.hostPort = this.server.port;
|
||||
|
||||
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);
|
||||
}
|
||||
if (!this.options.keepServer) {
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
await this.cleanupArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +294,7 @@ class LinuxSmoke {
|
||||
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
|
||||
}
|
||||
|
||||
private async runFreshLane(): Promise<void> {
|
||||
protected async runFreshLane(): Promise<void> {
|
||||
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("fresh.bootstrap-guest", 600, () => this.bootstrapGuest());
|
||||
await this.phase("fresh.preflight", 90, () => this.logGuestPreflight());
|
||||
@@ -381,7 +322,7 @@ class LinuxSmoke {
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
private async runUpgradeLane(): Promise<void> {
|
||||
protected async runUpgradeLane(): Promise<void> {
|
||||
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
|
||||
await this.phase("upgrade.bootstrap-guest", 600, () => this.bootstrapGuest());
|
||||
await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
|
||||
@@ -413,17 +354,10 @@ class LinuxSmoke {
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private async phase(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(): number | undefined {
|
||||
return this.phases.remainingTimeoutMs();
|
||||
}
|
||||
private remainingPhaseTimeoutMs = (): number | undefined => this.phases.remainingTimeoutMs();
|
||||
|
||||
private logGuestPreflight(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
@@ -434,13 +368,12 @@ 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 log = (text: string): void => this.phases.append(text);
|
||||
|
||||
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
|
||||
return this.guest.exec(args, options);
|
||||
}
|
||||
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);
|
||||
@@ -540,14 +473,10 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
||||
die("package artifact missing");
|
||||
}
|
||||
if (this.options.targetPackageSpec) {
|
||||
const version = this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
|
||||
this.verifyVersionContains(version);
|
||||
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
|
||||
return;
|
||||
}
|
||||
const commit =
|
||||
this.artifact.buildCommitShort ||
|
||||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
|
||||
this.verifyVersionContains(commit);
|
||||
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
|
||||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
@@ -831,39 +760,26 @@ fi`,
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseId: string): Promise<string> {
|
||||
const text = await readFile(path.join(this.runDir, `${phaseId}.log`), "utf8").catch(() => "");
|
||||
return [...text.matchAll(/OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)/g)].at(-1)?.[0] ?? "";
|
||||
return await extractLastOpenClawVersion(
|
||||
this.runDir,
|
||||
phaseId,
|
||||
/(OpenClaw [^\r\n]+ \([0-9a-f]{7,}\))/g,
|
||||
);
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
protected async writeSummary(): Promise<string> {
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
const summary: LinuxSummary = {
|
||||
currentHead:
|
||||
this.artifact?.buildCommitShort ||
|
||||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
|
||||
daemon: this.status.daemon,
|
||||
freshMain: {
|
||||
agent: this.status.freshAgent,
|
||||
gateway: this.status.freshGateway,
|
||||
status: this.status.freshMain,
|
||||
version: this.status.freshVersion,
|
||||
},
|
||||
installVersion: this.options.installVersion || "",
|
||||
latestVersion: this.latestVersion,
|
||||
mode: this.options.mode,
|
||||
provider: this.options.provider,
|
||||
runDir: this.runDir,
|
||||
snapshotHint: this.options.snapshotHint,
|
||||
snapshotId: this.snapshot.id,
|
||||
targetPackageSpec: this.options.targetPackageSpec || "",
|
||||
upgrade: {
|
||||
agent: this.status.upgradeAgent,
|
||||
gateway: this.status.upgradeGateway,
|
||||
latestVersionInstalled: this.status.latestInstalledVersion,
|
||||
mainVersion: this.status.upgradeVersion,
|
||||
status: this.status.upgrade,
|
||||
},
|
||||
vm: this.options.vmName,
|
||||
...buildCommonSmokeSummary({
|
||||
artifact: this.artifact,
|
||||
latestVersion: this.latestVersion,
|
||||
options: this.options,
|
||||
runDir: this.runDir,
|
||||
snapshot: this.snapshot,
|
||||
status: this.status,
|
||||
vmName: this.options.vmName,
|
||||
}),
|
||||
};
|
||||
await writeJson(summaryPath, summary);
|
||||
await writeSummaryMarkdown({
|
||||
@@ -882,14 +798,9 @@ fi`,
|
||||
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.options.installVersion) {
|
||||
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
|
||||
}
|
||||
printSmokeTargetSummary(this.options);
|
||||
process.stdout.write(` daemon: ${this.status.daemon}\n`);
|
||||
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
|
||||
process.stdout.write(
|
||||
|
||||
359
scripts/e2e/parallels/smoke-common.ts
Normal file
359
scripts/e2e/parallels/smoke-common.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { run, say } from "./host-command.ts";
|
||||
import { resolveHostIp, resolveHostPort } from "./host-server.ts";
|
||||
import { startHostServer } from "./host-server.ts";
|
||||
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
|
||||
import {
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
packOpenClaw,
|
||||
} from "./package-artifact.ts";
|
||||
import type { HostServer, Mode, PackageArtifact, Provider, SnapshotInfo } from "./types.ts";
|
||||
|
||||
export interface SmokeHostOptions {
|
||||
hostIp?: string;
|
||||
hostPort: number;
|
||||
hostPortExplicit: boolean;
|
||||
}
|
||||
|
||||
export interface SmokeRunOptions {
|
||||
installVersion?: string;
|
||||
json: boolean;
|
||||
keepServer: boolean;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
snapshotHint: string;
|
||||
targetPackageSpec?: string;
|
||||
}
|
||||
|
||||
export interface SmokeLaneStatuses {
|
||||
freshAgent: string;
|
||||
freshGateway: string;
|
||||
freshMain: string;
|
||||
freshVersion: string;
|
||||
latestInstalledVersion: string;
|
||||
upgrade: string;
|
||||
upgradeAgent: string;
|
||||
upgradeGateway: string;
|
||||
upgradeVersion: string;
|
||||
}
|
||||
|
||||
export interface CommonSmokeSummary {
|
||||
currentHead: string;
|
||||
freshMain: {
|
||||
agent: string;
|
||||
gateway: string;
|
||||
status: string;
|
||||
version: string;
|
||||
};
|
||||
installVersion: string;
|
||||
latestVersion: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
runDir: string;
|
||||
snapshotHint: string;
|
||||
snapshotId: string;
|
||||
targetPackageSpec: string;
|
||||
upgrade: {
|
||||
agent: string;
|
||||
gateway: string;
|
||||
latestVersionInstalled: string;
|
||||
mainVersion: string;
|
||||
status: string;
|
||||
};
|
||||
vm: string;
|
||||
}
|
||||
|
||||
export abstract class SmokeRunController<TOptions extends SmokeRunOptions & SmokeHostOptions> {
|
||||
protected hostIp = "";
|
||||
protected hostPort = 0;
|
||||
protected runDir = "";
|
||||
protected server: HostServer | null = null;
|
||||
protected tgzDir = "";
|
||||
|
||||
protected constructor(protected options: TOptions) {}
|
||||
|
||||
protected abstract runFreshLane(): Promise<void>;
|
||||
protected abstract runUpgradeLane(): Promise<void>;
|
||||
protected abstract writeSummary(): Promise<string>;
|
||||
protected abstract printSummary(summaryPath: string): void;
|
||||
protected abstract status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
|
||||
|
||||
protected async prepareHost(
|
||||
defaultPort: number,
|
||||
latestVersion: string,
|
||||
snapshot: SnapshotInfo,
|
||||
vmName: string,
|
||||
): Promise<void> {
|
||||
[this.hostIp, this.hostPort] = await prepareSmokeRunHost(
|
||||
this.options,
|
||||
defaultPort,
|
||||
latestVersion,
|
||||
this.runDir,
|
||||
snapshot,
|
||||
this.options.snapshotHint,
|
||||
vmName,
|
||||
);
|
||||
}
|
||||
|
||||
protected async runLanesAndFinish(): Promise<void> {
|
||||
await runSmokeLanesAndFinish(
|
||||
this.options.mode,
|
||||
this.options.json,
|
||||
this.status,
|
||||
async () => this.runFreshLane(),
|
||||
async () => this.runUpgradeLane(),
|
||||
async () => this.writeSummary(),
|
||||
(path) => this.printSummary(path),
|
||||
);
|
||||
}
|
||||
|
||||
protected async cleanupArtifacts(): Promise<void> {
|
||||
await cleanupSmokeArtifacts({
|
||||
keepServer: this.options.keepServer,
|
||||
server: this.server,
|
||||
tgzDir: this.tgzDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSmokeHostConfig(
|
||||
options: SmokeHostOptions,
|
||||
defaultPort: number,
|
||||
): Promise<{ hostIp: string; hostPort: number }> {
|
||||
return {
|
||||
hostIp: resolveHostIp(options.hostIp),
|
||||
hostPort: await resolveHostPort(options.hostPort, options.hostPortExplicit, defaultPort),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareSmokeRunHost(
|
||||
options: SmokeHostOptions,
|
||||
defaultPort: number,
|
||||
latestVersion: string,
|
||||
runDir: string,
|
||||
snapshot: SnapshotInfo,
|
||||
snapshotHint: string,
|
||||
vmName: string,
|
||||
): Promise<readonly [hostIp: string, hostPort: number]> {
|
||||
const host = await resolveSmokeHostConfig(options, defaultPort);
|
||||
logSmokeRunStart({
|
||||
latestVersion,
|
||||
runDir,
|
||||
snapshot,
|
||||
snapshotHint,
|
||||
vmName,
|
||||
});
|
||||
return [host.hostIp, host.hostPort];
|
||||
}
|
||||
|
||||
export function logSmokeRunStart(input: {
|
||||
latestVersion: string;
|
||||
runDir: string;
|
||||
snapshot: SnapshotInfo;
|
||||
snapshotHint: string;
|
||||
vmName: string;
|
||||
}): void {
|
||||
say(`VM: ${input.vmName}`);
|
||||
say(`Snapshot hint: ${input.snapshotHint}`);
|
||||
say(`Resolved snapshot: ${input.snapshot.name} [${input.snapshot.state}]`);
|
||||
say(`Latest npm version: ${input.latestVersion}`);
|
||||
say(`Current head: ${currentGitHeadShort()}`);
|
||||
say(`Run logs: ${input.runDir}`);
|
||||
}
|
||||
|
||||
export async function startSmokeArtifactServer(input: {
|
||||
artifact: PackageArtifact;
|
||||
dir: string;
|
||||
hostIp: string;
|
||||
label: string;
|
||||
port: number;
|
||||
}): Promise<{ hostPort: number; server: HostServer }> {
|
||||
const server = await startHostServer({
|
||||
artifactPath: input.artifact.path,
|
||||
dir: input.dir,
|
||||
hostIp: input.hostIp,
|
||||
label: input.label,
|
||||
port: input.port,
|
||||
});
|
||||
return { hostPort: server.port, server };
|
||||
}
|
||||
|
||||
export async function packAndServeSmokeArtifact(
|
||||
tgzDir: string,
|
||||
packageSpec: string | undefined,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
label: string,
|
||||
requireControlUi = false,
|
||||
): Promise<readonly [artifact: PackageArtifact, server: HostServer, hostPort: number]> {
|
||||
const artifact = await packOpenClaw({
|
||||
destination: tgzDir,
|
||||
packageSpec,
|
||||
requireControlUi,
|
||||
});
|
||||
const server = await startSmokeArtifactServer({
|
||||
artifact,
|
||||
dir: tgzDir,
|
||||
hostIp,
|
||||
label,
|
||||
port: hostPort,
|
||||
});
|
||||
return [artifact, server.server, server.hostPort];
|
||||
}
|
||||
|
||||
export async function runRequestedSmokeLanes(input: {
|
||||
mode: Mode;
|
||||
runFresh: () => Promise<void>;
|
||||
runLane: (name: "fresh" | "upgrade", fn: () => Promise<void>) => Promise<void>;
|
||||
runUpgrade: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (input.mode === "fresh" || input.mode === "both") {
|
||||
await input.runLane("fresh", input.runFresh);
|
||||
}
|
||||
if (input.mode === "upgrade" || input.mode === "both") {
|
||||
await input.runLane("upgrade", input.runUpgrade);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSmokeLaneWithStatus(
|
||||
name: "fresh" | "upgrade",
|
||||
fn: () => Promise<void>,
|
||||
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
|
||||
): Promise<void> {
|
||||
await runSmokeLane(name, fn, (lane, status) => setSmokeLaneStatus(statuses, lane, status));
|
||||
}
|
||||
|
||||
export function setSmokeLaneStatus(
|
||||
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
|
||||
name: SmokeLane,
|
||||
status: SmokeLaneStatus,
|
||||
): void {
|
||||
if (name === "fresh") {
|
||||
statuses.freshMain = status;
|
||||
} else {
|
||||
statuses.upgrade = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishSmokeRun(input: {
|
||||
json: boolean;
|
||||
printSummary: (summaryPath: string) => void;
|
||||
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
|
||||
summaryPath: string;
|
||||
}): Promise<void> {
|
||||
if (input.json) {
|
||||
process.stdout.write(await readFile(input.summaryPath, "utf8"));
|
||||
} else {
|
||||
input.printSummary(input.summaryPath);
|
||||
}
|
||||
if (input.status.freshMain === "fail" || input.status.upgrade === "fail") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSmokeLanesAndFinish(
|
||||
mode: Mode,
|
||||
json: boolean,
|
||||
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
|
||||
runFresh: () => Promise<void>,
|
||||
runUpgrade: () => Promise<void>,
|
||||
writeSummary: () => Promise<string>,
|
||||
printSummary: (summaryPath: string) => void,
|
||||
): Promise<void> {
|
||||
await runRequestedSmokeLanes({
|
||||
mode,
|
||||
runFresh,
|
||||
runLane: async (name, fn) => runSmokeLaneWithStatus(name, fn, status),
|
||||
runUpgrade,
|
||||
});
|
||||
await finishSmokeRun({
|
||||
json,
|
||||
printSummary,
|
||||
status,
|
||||
summaryPath: await writeSummary(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupSmokeArtifacts(input: {
|
||||
keepServer: boolean;
|
||||
server: HostServer | null;
|
||||
tgzDir: string;
|
||||
}): Promise<void> {
|
||||
if (input.keepServer) {
|
||||
return;
|
||||
}
|
||||
await input.server?.stop().catch(() => undefined);
|
||||
await rm(input.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function expectedPackageTargetVersion(artifact: PackageArtifact): Promise<string> {
|
||||
return artifact.version || (await packageVersionFromTgz(artifact.path));
|
||||
}
|
||||
|
||||
export async function expectedPackageBuildCommit(artifact: PackageArtifact): Promise<string> {
|
||||
return artifact.buildCommitShort || (await packageBuildCommitFromTgz(artifact.path)).slice(0, 7);
|
||||
}
|
||||
|
||||
export async function extractLastOpenClawVersion(
|
||||
runDir: string,
|
||||
phaseName: string,
|
||||
pattern: RegExp,
|
||||
): Promise<string> {
|
||||
const text = await readFile(path.join(runDir, `${phaseName}.log`), "utf8").catch(() => "");
|
||||
return [...text.matchAll(pattern)].at(-1)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function buildCommonSmokeSummary(input: {
|
||||
artifact: PackageArtifact | null;
|
||||
latestVersion: string;
|
||||
options: SmokeRunOptions;
|
||||
runDir: string;
|
||||
snapshot: SnapshotInfo;
|
||||
status: SmokeLaneStatuses;
|
||||
vmName: string;
|
||||
}): CommonSmokeSummary {
|
||||
return {
|
||||
currentHead: input.artifact?.buildCommitShort || currentGitHeadShort(),
|
||||
freshMain: {
|
||||
agent: input.status.freshAgent,
|
||||
gateway: input.status.freshGateway,
|
||||
status: input.status.freshMain,
|
||||
version: input.status.freshVersion,
|
||||
},
|
||||
installVersion: input.options.installVersion || "",
|
||||
latestVersion: input.latestVersion,
|
||||
mode: input.options.mode,
|
||||
provider: input.options.provider,
|
||||
runDir: input.runDir,
|
||||
snapshotHint: input.options.snapshotHint,
|
||||
snapshotId: input.snapshot.id,
|
||||
targetPackageSpec: input.options.targetPackageSpec || "",
|
||||
upgrade: {
|
||||
agent: input.status.upgradeAgent,
|
||||
gateway: input.status.upgradeGateway,
|
||||
latestVersionInstalled: input.status.latestInstalledVersion,
|
||||
mainVersion: input.status.upgradeVersion,
|
||||
status: input.status.upgrade,
|
||||
},
|
||||
vm: input.vmName,
|
||||
};
|
||||
}
|
||||
|
||||
export function printSmokeTargetSummary(input: {
|
||||
includeInstallVersion?: boolean;
|
||||
installVersion?: string;
|
||||
targetPackageSpec?: string;
|
||||
}): void {
|
||||
if (input.targetPackageSpec) {
|
||||
process.stdout.write(` target-package: ${input.targetPackageSpec}\n`);
|
||||
}
|
||||
if (input.includeInstallVersion !== false && input.installVersion) {
|
||||
process.stdout.write(` baseline-install-version: ${input.installVersion}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function currentGitHeadShort(): string {
|
||||
return run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim();
|
||||
}
|
||||
@@ -1,29 +1,21 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
makeTempDir,
|
||||
packageBuildCommitFromTgz,
|
||||
packageVersionFromTgz,
|
||||
packOpenClaw,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
resolveHostIp,
|
||||
resolveHostPort,
|
||||
resolveLatestVersion,
|
||||
resolveParallelsModelTimeoutSeconds,
|
||||
resolveWindowsProviderAuth,
|
||||
resolveSnapshot,
|
||||
run,
|
||||
say,
|
||||
startHostServer,
|
||||
warn,
|
||||
writeSummaryMarkdown,
|
||||
writeJson,
|
||||
type HostServer,
|
||||
type Mode,
|
||||
type PackageArtifact,
|
||||
type Provider,
|
||||
@@ -31,7 +23,7 @@ import {
|
||||
type SnapshotInfo,
|
||||
} from "./common.ts";
|
||||
import { runWindowsBackgroundPowerShell, WindowsGuest } from "./guest-transports.ts";
|
||||
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
|
||||
import { startHostServer } from "./host-server.ts";
|
||||
import { waitForVmStatus } from "./parallels-vm.ts";
|
||||
import { PhaseRunner } from "./phase-runner.ts";
|
||||
import { windowsProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
|
||||
@@ -41,26 +33,27 @@ import {
|
||||
windowsOpenClawResolver,
|
||||
windowsScopedEnvFunction,
|
||||
} from "./powershell.ts";
|
||||
import {
|
||||
buildCommonSmokeSummary,
|
||||
expectedPackageBuildCommit,
|
||||
expectedPackageTargetVersion,
|
||||
extractLastOpenClawVersion,
|
||||
packAndServeSmokeArtifact,
|
||||
printSmokeTargetSummary,
|
||||
SmokeRunController,
|
||||
type SmokeHostOptions,
|
||||
type SmokeRunOptions,
|
||||
} from "./smoke-common.ts";
|
||||
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts";
|
||||
|
||||
interface WindowsOptions {
|
||||
interface WindowsOptions extends SmokeHostOptions, SmokeRunOptions {
|
||||
vmName: string;
|
||||
snapshotHint: string;
|
||||
mode: Mode;
|
||||
provider: Provider;
|
||||
apiKeyEnv?: string;
|
||||
modelId?: string;
|
||||
installUrl: string;
|
||||
hostPort: number;
|
||||
hostPortExplicit: boolean;
|
||||
hostIp?: string;
|
||||
latestVersion?: string;
|
||||
installVersion?: string;
|
||||
targetPackageSpec?: string;
|
||||
upgradeFromPackedMain: boolean;
|
||||
skipLatestRefCheck: boolean;
|
||||
keepServer: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface WindowsSummary {
|
||||
@@ -224,23 +217,17 @@ function parseArgs(argv: string[]): WindowsOptions {
|
||||
return options;
|
||||
}
|
||||
|
||||
class WindowsSmoke {
|
||||
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
private auth: ProviderAuth;
|
||||
private hostIp = "";
|
||||
private hostPort = 0;
|
||||
private runDir = "";
|
||||
private tgzDir = "";
|
||||
private server: HostServer | null = null;
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private minGitZipPath = "";
|
||||
private latestVersion = "";
|
||||
private installVersion = "";
|
||||
private targetExpectVersion = "";
|
||||
private snapshot!: SnapshotInfo;
|
||||
private phases!: PhaseRunner;
|
||||
private guest!: WindowsGuest;
|
||||
|
||||
private status = {
|
||||
protected status = {
|
||||
freshAgent: "skip",
|
||||
freshGateway: "skip",
|
||||
freshMain: "skip",
|
||||
@@ -253,7 +240,8 @@ class WindowsSmoke {
|
||||
upgradeVersion: "skip",
|
||||
};
|
||||
|
||||
constructor(private options: WindowsOptions) {
|
||||
constructor(options: WindowsOptions) {
|
||||
super(options);
|
||||
this.auth = resolveWindowsProviderAuth({
|
||||
apiKeyEnv: options.apiKeyEnv,
|
||||
modelId: options.modelId,
|
||||
@@ -270,41 +258,22 @@ class WindowsSmoke {
|
||||
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
|
||||
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
|
||||
this.installVersion = this.options.installVersion || this.latestVersion;
|
||||
this.hostIp = resolveHostIp(this.options.hostIp);
|
||||
this.hostPort = await resolveHostPort(
|
||||
this.options.hostPort,
|
||||
this.options.hostPortExplicit,
|
||||
await this.prepareHost(
|
||||
defaultOptions().hostPort,
|
||||
this.latestVersion,
|
||||
this.snapshot,
|
||||
this.options.vmName,
|
||||
);
|
||||
|
||||
say(`VM: ${this.options.vmName}`);
|
||||
say(`Snapshot hint: ${this.options.snapshotHint}`);
|
||||
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
|
||||
say(`Latest npm version: ${this.latestVersion}`);
|
||||
say(
|
||||
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
|
||||
);
|
||||
say(`Run logs: ${this.runDir}`);
|
||||
|
||||
this.minGitZipPath = await prepareMinGitZip(this.tgzDir);
|
||||
if (this.needsHostTgz()) {
|
||||
this.artifact = await packOpenClaw({
|
||||
destination: this.tgzDir,
|
||||
packageSpec: this.options.targetPackageSpec,
|
||||
requireControlUi: false,
|
||||
});
|
||||
if (this.options.targetPackageSpec) {
|
||||
this.targetExpectVersion =
|
||||
this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
|
||||
}
|
||||
this.server = await startHostServer({
|
||||
artifactPath: this.artifact.path,
|
||||
dir: this.tgzDir,
|
||||
hostIp: this.hostIp,
|
||||
label: this.artifactLabel(),
|
||||
port: this.hostPort,
|
||||
});
|
||||
this.hostPort = this.server.port;
|
||||
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
|
||||
this.tgzDir,
|
||||
this.options.targetPackageSpec,
|
||||
this.hostIp,
|
||||
this.hostPort,
|
||||
this.artifactLabel(),
|
||||
);
|
||||
}
|
||||
if (!this.server) {
|
||||
this.server = await startHostServer({
|
||||
@@ -317,27 +286,9 @@ class WindowsSmoke {
|
||||
this.hostPort = this.server.port;
|
||||
}
|
||||
|
||||
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.cleanupArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,19 +325,7 @@ class WindowsSmoke {
|
||||
return this.options.upgradeFromPackedMain ? "packed-main->dev" : "latest->dev";
|
||||
}
|
||||
|
||||
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> {
|
||||
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, () =>
|
||||
@@ -408,7 +347,7 @@ class WindowsSmoke {
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
private async runUpgradeLane(): Promise<void> {
|
||||
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, () =>
|
||||
@@ -466,33 +405,24 @@ class WindowsSmoke {
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
private async phase(
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
|
||||
return this.phases.remainingTimeoutMs(fallbackMs);
|
||||
}
|
||||
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
|
||||
this.phases.remainingTimeoutMs(fallbackMs);
|
||||
|
||||
private async phaseReturns(
|
||||
private phaseReturns = async (
|
||||
name: string,
|
||||
timeoutSeconds: number,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
|
||||
}
|
||||
): Promise<boolean> => await this.phases.phaseReturns(name, timeoutSeconds, fn);
|
||||
|
||||
private log(text: string): void {
|
||||
this.phases.append(text);
|
||||
}
|
||||
private log = (text: string): void => this.phases.append(text);
|
||||
|
||||
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
|
||||
return this.guest.exec(args, options);
|
||||
}
|
||||
private guestExec = (
|
||||
args: string[],
|
||||
options: { check?: boolean; timeoutMs?: number } = {},
|
||||
): string => this.guest.exec(args, options);
|
||||
|
||||
private guestPowerShell(
|
||||
script: string,
|
||||
@@ -620,16 +550,16 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST
|
||||
|
||||
private async verifyTargetVersion(): Promise<void> {
|
||||
if (this.options.targetPackageSpec) {
|
||||
this.verifyVersionContains(this.targetExpectVersion);
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
|
||||
return;
|
||||
}
|
||||
if (!this.artifact) {
|
||||
die("package artifact missing");
|
||||
}
|
||||
const commit =
|
||||
this.artifact.buildCommitShort ||
|
||||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
|
||||
this.verifyVersionContains(commit);
|
||||
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
|
||||
}
|
||||
|
||||
private verifyVersionContains(needle: string): void {
|
||||
@@ -820,39 +750,25 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
|
||||
}
|
||||
|
||||
private async extractLastVersion(phaseName: string): Promise<string> {
|
||||
const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => "");
|
||||
const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)];
|
||||
return matches.at(-1)?.[1] ?? "";
|
||||
return await extractLastOpenClawVersion(this.runDir, phaseName, /OpenClaw\s+([0-9][^\s]*)/gi);
|
||||
}
|
||||
|
||||
private async writeSummary(): Promise<string> {
|
||||
const summary: WindowsSummary = {
|
||||
currentHead:
|
||||
this.artifact?.buildCommitShort ||
|
||||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
|
||||
freshMain: {
|
||||
agent: this.status.freshAgent,
|
||||
gateway: this.status.freshGateway,
|
||||
status: this.status.freshMain,
|
||||
version: this.status.freshVersion,
|
||||
},
|
||||
installVersion: this.options.installVersion || "",
|
||||
protected async writeSummary(): Promise<string> {
|
||||
const common = buildCommonSmokeSummary({
|
||||
artifact: this.artifact,
|
||||
latestVersion: this.latestVersion,
|
||||
mode: this.options.mode,
|
||||
provider: this.options.provider,
|
||||
options: this.options,
|
||||
runDir: this.runDir,
|
||||
snapshotHint: this.options.snapshotHint,
|
||||
snapshotId: this.snapshot.id,
|
||||
targetPackageSpec: this.options.targetPackageSpec || "",
|
||||
snapshot: this.snapshot,
|
||||
status: this.status,
|
||||
vmName: this.options.vmName,
|
||||
});
|
||||
const summary: WindowsSummary = {
|
||||
...common,
|
||||
upgrade: {
|
||||
agent: this.status.upgradeAgent,
|
||||
gateway: this.status.upgradeGateway,
|
||||
latestVersionInstalled: this.status.latestInstalledVersion,
|
||||
mainVersion: this.status.upgradeVersion,
|
||||
...common.upgrade,
|
||||
precheck: this.status.upgradePrecheck,
|
||||
status: this.status.upgrade,
|
||||
},
|
||||
vm: this.options.vmName,
|
||||
};
|
||||
const summaryPath = path.join(this.runDir, "summary.json");
|
||||
await writeJson(summaryPath, summary);
|
||||
@@ -870,11 +786,9 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
|
||||
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`);
|
||||
}
|
||||
printSmokeTargetSummary({ ...this.options, includeInstallVersion: false });
|
||||
if (this.options.upgradeFromPackedMain) {
|
||||
process.stdout.write(" upgrade-from-packed-main: yes\n");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const PLUGIN_SDK_TYPE_INPUTS = [
|
||||
"packages/markdown-core/src",
|
||||
"packages/memory-host-sdk/src",
|
||||
"packages/media-generation-core/src",
|
||||
"packages/media-understanding-common/src",
|
||||
"packages/terminal-core/src",
|
||||
"src/video-generation/dashscope-compatible.ts",
|
||||
"src/video-generation/types.ts",
|
||||
|
||||
@@ -12,6 +12,7 @@ const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
|
||||
"packages/gateway-protocol/src",
|
||||
"packages/markdown-core/src",
|
||||
"packages/media-generation-core/src",
|
||||
"packages/media-understanding-common/src",
|
||||
"packages/terminal-core/src",
|
||||
"packages/net-policy/src",
|
||||
];
|
||||
|
||||
@@ -10,11 +10,18 @@ import {
|
||||
|
||||
const DEFAULT_WINDOWS_EXTENSION_CHUNK_SIZE = 8;
|
||||
const DEFAULT_SHARD_HEARTBEAT_MS = 30_000;
|
||||
const DEFAULT_SHARD_TIMEOUT_MS = 15 * 60_000;
|
||||
const DEFAULT_SHARD_KILL_GRACE_MS = 5_000;
|
||||
const FAST_LOCAL_CHECK_MIN_CPUS = 12;
|
||||
const FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * 1024 ** 3;
|
||||
const EXTENSION_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
|
||||
const EXTENSIONS_DIR = "extensions";
|
||||
const OXLINT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
|
||||
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
|
||||
const ACTIVE_SHARD_CHILDREN = new Set();
|
||||
let parentTerminationSignal = null;
|
||||
let parentTerminationForceKill = null;
|
||||
let parentSignalForwardingInstalled = false;
|
||||
|
||||
const CORE_SHARD = {
|
||||
name: "core",
|
||||
@@ -319,24 +326,35 @@ async function runShardsSerial({ entries, env, extraArgs, runner }) {
|
||||
const results = [];
|
||||
for (const shard of entries) {
|
||||
results.push(await runShard({ env, extraArgs, runner, shard }));
|
||||
if (isParentTerminationRequested()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runShard({ env, extraArgs, runner, shard }) {
|
||||
export async function runShard({ env, extraArgs, runner, shard }) {
|
||||
console.error(`[oxlint:${shard.name}] starting`);
|
||||
const startedAt = Date.now();
|
||||
const heartbeatMs = resolveShardHeartbeatMs(env);
|
||||
const timeoutMs = resolveShardTimeoutMs(env);
|
||||
const killGraceMs = resolveShardKillGraceMs(env);
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const child = spawn(process.execPath, [runner, ...shard.args, ...extraArgs], {
|
||||
stdio: "inherit",
|
||||
detached: useProcessGroup,
|
||||
env: {
|
||||
...env,
|
||||
OPENCLAW_OXLINT_SKIP_LOCK: "1",
|
||||
OPENCLAW_OXLINT_SKIP_PREPARE: "1",
|
||||
},
|
||||
});
|
||||
const unregisterShardChild = registerShardChild({ child, killGraceMs, useProcessGroup });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let finished = false;
|
||||
let timedOut = false;
|
||||
let forceKill = null;
|
||||
const heartbeat =
|
||||
heartbeatMs > 0
|
||||
? setInterval(() => {
|
||||
@@ -345,10 +363,42 @@ async function runShard({ env, extraArgs, runner, shard }) {
|
||||
}, heartbeatMs)
|
||||
: null;
|
||||
heartbeat?.unref();
|
||||
const timeout =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
||||
console.error(
|
||||
`[oxlint:${shard.name}] timed out after ${elapsedSeconds}s; terminating shard`,
|
||||
);
|
||||
signalChildProcess({ child, signal: "SIGTERM", useProcessGroup });
|
||||
if (killGraceMs > 0) {
|
||||
forceKill = setTimeout(() => {
|
||||
console.error(`[oxlint:${shard.name}] did not exit cleanly; killing shard`);
|
||||
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
|
||||
}, killGraceMs);
|
||||
forceKill.unref();
|
||||
} else {
|
||||
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
|
||||
}
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
timeout?.unref();
|
||||
const finish = (status) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (forceKill) {
|
||||
clearTimeout(forceKill);
|
||||
}
|
||||
unregisterShardChild();
|
||||
console.error(`[oxlint:${shard.name}] finished`);
|
||||
resolve(status);
|
||||
};
|
||||
@@ -357,19 +407,131 @@ async function runShard({ env, extraArgs, runner, shard }) {
|
||||
finish(1);
|
||||
});
|
||||
child.once("close", (status) => {
|
||||
finish(status ?? 1);
|
||||
finish(
|
||||
parentTerminationSignal
|
||||
? getSignalExitCode(parentTerminationSignal)
|
||||
: timedOut
|
||||
? 124
|
||||
: (status ?? 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveShardHeartbeatMs(env) {
|
||||
const rawValue = env.OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS;
|
||||
return resolveNonNegativeEnvInt(
|
||||
env,
|
||||
"OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS",
|
||||
DEFAULT_SHARD_HEARTBEAT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveShardTimeoutMs(env) {
|
||||
return resolveNonNegativeEnvInt(
|
||||
env,
|
||||
"OPENCLAW_OXLINT_SHARD_TIMEOUT_MS",
|
||||
DEFAULT_SHARD_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveShardKillGraceMs(env) {
|
||||
return resolveNonNegativeEnvInt(
|
||||
env,
|
||||
"OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS",
|
||||
DEFAULT_SHARD_KILL_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNonNegativeEnvInt(env, key, defaultValue) {
|
||||
const rawValue = env[key];
|
||||
if (rawValue === undefined) {
|
||||
return DEFAULT_SHARD_HEARTBEAT_MS;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsedValue) && parsedValue >= 0
|
||||
? parsedValue
|
||||
: DEFAULT_SHARD_HEARTBEAT_MS;
|
||||
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : defaultValue;
|
||||
}
|
||||
|
||||
function signalChildProcess({ child, signal, useProcessGroup }) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (useProcessGroup) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerShardChild(entry) {
|
||||
installParentSignalForwarding();
|
||||
ACTIVE_SHARD_CHILDREN.add(entry);
|
||||
return () => {
|
||||
ACTIVE_SHARD_CHILDREN.delete(entry);
|
||||
if (ACTIVE_SHARD_CHILDREN.size === 0 && parentTerminationForceKill) {
|
||||
clearTimeout(parentTerminationForceKill);
|
||||
parentTerminationForceKill = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function installParentSignalForwarding() {
|
||||
if (parentSignalForwardingInstalled) {
|
||||
return;
|
||||
}
|
||||
parentSignalForwardingInstalled = true;
|
||||
for (const signal of PARENT_TERMINATION_SIGNALS) {
|
||||
process.on(signal, () => {
|
||||
parentTerminationSignal = signal;
|
||||
process.exitCode = getSignalExitCode(signal);
|
||||
if (ACTIVE_SHARD_CHILDREN.size === 0) {
|
||||
process.exit(process.exitCode);
|
||||
}
|
||||
signalActiveShardChildren(signal);
|
||||
scheduleParentTerminationForceKill();
|
||||
});
|
||||
}
|
||||
process.once("exit", () => {
|
||||
signalActiveShardChildren("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
function isParentTerminationRequested() {
|
||||
return parentTerminationSignal !== null;
|
||||
}
|
||||
|
||||
function signalActiveShardChildren(signal) {
|
||||
for (const entry of ACTIVE_SHARD_CHILDREN) {
|
||||
signalChildProcess({ ...entry, signal });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleParentTerminationForceKill() {
|
||||
if (parentTerminationForceKill) {
|
||||
return;
|
||||
}
|
||||
const killGraceMs = Math.max(
|
||||
0,
|
||||
...Array.from(ACTIVE_SHARD_CHILDREN, (entry) => entry.killGraceMs),
|
||||
);
|
||||
if (killGraceMs === 0) {
|
||||
signalActiveShardChildren("SIGKILL");
|
||||
return;
|
||||
}
|
||||
parentTerminationForceKill = setTimeout(() => {
|
||||
parentTerminationForceKill = null;
|
||||
signalActiveShardChildren("SIGKILL");
|
||||
}, killGraceMs);
|
||||
parentTerminationForceKill.unref();
|
||||
}
|
||||
|
||||
function getSignalExitCode(signal) {
|
||||
return signal === "SIGINT" ? 130 : 143;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
||||
const USAGE = "Usage: node scripts/run-with-env.mjs KEY=value [KEY=value ...] -- command [args...]";
|
||||
|
||||
export function isRunWithEnvHelpRequest(argv) {
|
||||
for (const arg of argv) {
|
||||
if (arg === "--") {
|
||||
return false;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseRunWithEnvArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
if (separatorIndex <= 0 || separatorIndex === argv.length - 1) {
|
||||
throw new Error("usage: node scripts/run-with-env.mjs KEY=value [KEY=value ...] -- command [args...]");
|
||||
throw new Error(USAGE);
|
||||
}
|
||||
|
||||
const assignments = argv.slice(0, separatorIndex);
|
||||
@@ -39,12 +52,17 @@ export function resolveSpawnCommand(command, args, execPath = process.execPath)
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
if (isRunWithEnvHelpRequest(argv)) {
|
||||
console.log(USAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseRunWithEnvArgs(argv);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const spawnCommand = resolveSpawnCommand(parsed.command, parsed.args);
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { forceFreePort, type PortProcess } from "../src/cli/ports.js";
|
||||
import { resolveGatewayPort } from "../src/config/config.js";
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: node --import tsx scripts/test-force.ts",
|
||||
"",
|
||||
"Clears the configured OpenClaw gateway port, then runs the local test suite.",
|
||||
"",
|
||||
"Options:",
|
||||
" -h, --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): { help: boolean } {
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { help: true };
|
||||
}
|
||||
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
|
||||
}
|
||||
return { help: false };
|
||||
}
|
||||
|
||||
function killGatewayListeners(port: number): PortProcess[] {
|
||||
try {
|
||||
const killed = forceFreePort(port);
|
||||
@@ -42,7 +64,13 @@ function runTests() {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
export function main(argv: readonly string[] = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const port = resolveGatewayPort(undefined, process.env);
|
||||
|
||||
console.log(`🧹 test:force - clearing gateway on port ${port}`);
|
||||
@@ -55,4 +83,11 @@ function main() {
|
||||
runTests();
|
||||
}
|
||||
|
||||
main();
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
|
||||
|
||||
const forwardedArgs = [];
|
||||
let quietOverride;
|
||||
let forceCodexHarness = false;
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--codex-harness") {
|
||||
forceCodexHarness = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--quiet" || arg === "--quiet-live") {
|
||||
quietOverride = "1";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-quiet" || arg === "--no-quiet-live") {
|
||||
quietOverride = "0";
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
export function testLiveUsage() {
|
||||
return [
|
||||
"Usage: node scripts/test-live.mjs [options] [--] [vitest targets/args...]",
|
||||
"",
|
||||
"Runs live Vitest suites with OPENCLAW_LIVE_TEST=1.",
|
||||
"",
|
||||
"Options:",
|
||||
" --codex-harness Enable the live Codex harness.",
|
||||
" --quiet, --quiet-live Keep live test output quiet.",
|
||||
" --no-quiet, --no-quiet-live",
|
||||
" Show live test output.",
|
||||
" -h, --help Show this help without starting live tests.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CI: process.env.CI || "1",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: process.env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN || "false",
|
||||
pnpm_config_verify_deps_before_run: process.env.pnpm_config_verify_deps_before_run || "false",
|
||||
OPENCLAW_LIVE_TEST: process.env.OPENCLAW_LIVE_TEST || "1",
|
||||
OPENCLAW_LIVE_TEST_QUIET: quietOverride ?? process.env.OPENCLAW_LIVE_TEST_QUIET ?? "1",
|
||||
...(forceCodexHarness ? { OPENCLAW_LIVE_CODEX_HARNESS: "1" } : {}),
|
||||
};
|
||||
export function parseTestLiveArgs(argv) {
|
||||
const forwardedArgs = [];
|
||||
let quietOverride;
|
||||
let forceCodexHarness = false;
|
||||
let help = false;
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--codex-harness") {
|
||||
forceCodexHarness = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--quiet" || arg === "--quiet-live") {
|
||||
quietOverride = "1";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-quiet" || arg === "--no-quiet-live") {
|
||||
quietOverride = "0";
|
||||
continue;
|
||||
}
|
||||
forwardedArgs.push(arg);
|
||||
}
|
||||
return {
|
||||
forceCodexHarness,
|
||||
forwardedArgs,
|
||||
help,
|
||||
quietOverride,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTestLiveEnv(args, baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
CI: baseEnv.CI || "1",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: baseEnv.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN || "false",
|
||||
pnpm_config_verify_deps_before_run: baseEnv.pnpm_config_verify_deps_before_run || "false",
|
||||
OPENCLAW_LIVE_TEST: baseEnv.OPENCLAW_LIVE_TEST || "1",
|
||||
OPENCLAW_LIVE_TEST_QUIET: args.quietOverride ?? baseEnv.OPENCLAW_LIVE_TEST_QUIET ?? "1",
|
||||
...(args.forceCodexHarness ? { OPENCLAW_LIVE_CODEX_HARNESS: "1" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
if (!value) {
|
||||
@@ -41,66 +71,83 @@ function parsePositiveInt(value, fallback) {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
const heartbeatMs = parsePositiveInt(process.env.OPENCLAW_LIVE_WRAPPER_HEARTBEAT_MS, 20_000);
|
||||
const startedAt = Date.now();
|
||||
let lastOutputAt = startedAt;
|
||||
|
||||
const child = spawnPnpmRunner({
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
pnpmArgs: [
|
||||
export function buildTestLivePnpmArgs(args) {
|
||||
return [
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"test/vitest/vitest.live.config.ts",
|
||||
...forwardedArgs,
|
||||
],
|
||||
env,
|
||||
});
|
||||
...args.forwardedArgs,
|
||||
];
|
||||
}
|
||||
|
||||
const noteOutput = () => {
|
||||
lastOutputAt = Date.now();
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
noteOutput();
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
noteOutput();
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastOutputAt < heartbeatMs) {
|
||||
return;
|
||||
export function main(argv = process.argv.slice(2), baseEnv = process.env) {
|
||||
const args = parseTestLiveArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(testLiveUsage());
|
||||
process.exit(0);
|
||||
}
|
||||
const elapsedSec = Math.max(1, Math.round((now - startedAt) / 1_000));
|
||||
const quietSec = Math.max(1, Math.round((now - lastOutputAt) / 1_000));
|
||||
process.stderr.write(
|
||||
`[test:live] still running (${elapsedSec}s elapsed, ${quietSec}s since last output)\n`,
|
||||
);
|
||||
lastOutputAt = now;
|
||||
}, heartbeatMs);
|
||||
heartbeat.unref?.();
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearInterval(heartbeat);
|
||||
if (signal) {
|
||||
process.stderr.write(`[test:live] vitest exited via signal=${signal}\n`);
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
if ((code ?? 1) !== 0) {
|
||||
process.stderr.write(`[test:live] vitest exited code=${code ?? 1}\n`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
const env = buildTestLiveEnv(args, baseEnv);
|
||||
const heartbeatMs = parsePositiveInt(baseEnv.OPENCLAW_LIVE_WRAPPER_HEARTBEAT_MS, 20_000);
|
||||
const startedAt = Date.now();
|
||||
let lastOutputAt = startedAt;
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearInterval(heartbeat);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
const child = spawnPnpmRunner({
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
pnpmArgs: buildTestLivePnpmArgs(args),
|
||||
env,
|
||||
});
|
||||
|
||||
const noteOutput = () => {
|
||||
lastOutputAt = Date.now();
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
noteOutput();
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
noteOutput();
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastOutputAt < heartbeatMs) {
|
||||
return;
|
||||
}
|
||||
const elapsedSec = Math.max(1, Math.round((now - startedAt) / 1_000));
|
||||
const quietSec = Math.max(1, Math.round((now - lastOutputAt) / 1_000));
|
||||
process.stderr.write(
|
||||
`[test:live] still running (${elapsedSec}s elapsed, ${quietSec}s since last output)\n`,
|
||||
);
|
||||
lastOutputAt = now;
|
||||
}, heartbeatMs);
|
||||
heartbeat.unref?.();
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearInterval(heartbeat);
|
||||
if (signal) {
|
||||
process.stderr.write(`[test:live] vitest exited via signal=${signal}\n`);
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
if ((code ?? 1) !== 0) {
|
||||
process.stderr.write(`[test:live] vitest exited code=${code ?? 1}\n`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearInterval(heartbeat);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,25 @@ const releaseLockOnce = () => {
|
||||
releaseLock();
|
||||
};
|
||||
|
||||
function isWrapperMetadataRequest(args) {
|
||||
for (const arg of args) {
|
||||
if (arg === "--") {
|
||||
return false;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/test-projects.mjs [--changed <base>] [--watch] [targets...] [-- vitest-args...]
|
||||
|
||||
Runs the Vitest project shards that own the requested targets. With no targets,
|
||||
this runs the full local suite. Use explicit targets for local edit loops.`);
|
||||
}
|
||||
|
||||
function cleanupVitestRunSpec(spec) {
|
||||
if (!spec.includeFilePath) {
|
||||
return;
|
||||
@@ -192,6 +211,10 @@ async function runVitestSpecsParallel(specs, concurrency) {
|
||||
async function main() {
|
||||
const suiteStartedAt = performance.now();
|
||||
const args = process.argv.slice(2);
|
||||
if (isWrapperMetadataRequest(args)) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const baseEnv = resolveLocalVitestEnv(process.env);
|
||||
const { targetArgs } = parseTestProjectsArgs(args, process.cwd());
|
||||
const unmatchedExplicitTargets = findUnmatchedExplicitTestTargets(args, process.cwd());
|
||||
|
||||
@@ -222,6 +222,10 @@ function interleaveSlowAndFastSpecs(sortedSpecs) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
export function orderFullSuiteSpecsForParallelRun(specs, shardTimings = new Map()) {
|
||||
const sortedSpecs = specs.toSorted((a, b) => {
|
||||
const weightDelta =
|
||||
@@ -361,20 +365,79 @@ const PRECISE_SOURCE_TEST_TARGETS = new Map([
|
||||
]);
|
||||
const BROAD_ONLY_TEST_HELPERS = new Set(["test/helpers/poll.ts"]);
|
||||
const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
[".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]],
|
||||
[
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
|
||||
],
|
||||
[
|
||||
".github/workflows/crabbox-hydrate.yml",
|
||||
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
|
||||
],
|
||||
["scripts/build-all.mjs", ["test/scripts/build-all.test.ts"]],
|
||||
["scripts/crabbox-wrapper.mjs", ["test/scripts/crabbox-wrapper.test.ts"]],
|
||||
["scripts/github/barnacle-auto-response.mjs", ["test/scripts/barnacle-auto-response.test.ts"]],
|
||||
["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]],
|
||||
["scripts/check.mjs", ["test/scripts/check.test.ts"]],
|
||||
["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]],
|
||||
[
|
||||
"scripts/check-changelog-attributions.mjs",
|
||||
["test/scripts/check-changelog-attributions.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/check-composite-action-input-interpolation.py",
|
||||
["test/scripts/check-composite-action-input-interpolation.test.ts"],
|
||||
],
|
||||
["scripts/check-dependency-pins.mjs", ["test/scripts/check-dependency-pins.test.ts"]],
|
||||
["scripts/check-deadcode-unused-files.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"]],
|
||||
["scripts/check-dynamic-import-warts.mjs", ["test/scripts/check-dynamic-import-warts.test.ts"]],
|
||||
["scripts/check-no-conflict-markers.mjs", ["test/scripts/check-no-conflict-markers.test.ts"]],
|
||||
[
|
||||
"scripts/check-workflows.mjs",
|
||||
[
|
||||
"test/scripts/check-composite-action-input-interpolation.test.ts",
|
||||
"test/scripts/check-no-conflict-markers.test.ts",
|
||||
"test/scripts/ci-workflow-guards.test.ts",
|
||||
],
|
||||
],
|
||||
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
|
||||
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
|
||||
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
|
||||
["scripts/dependency-changes-report.mjs", ["test/scripts/dependency-changes-report.test.ts"]],
|
||||
[
|
||||
"scripts/dependency-ownership-surface-report.mjs",
|
||||
["test/scripts/dependency-ownership-surface-report.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/dependency-vulnerability-gate.mjs",
|
||||
["test/scripts/dependency-vulnerability-gate.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/deadcode-unused-files.allowlist.mjs",
|
||||
["test/scripts/check-deadcode-unused-files.test.ts"],
|
||||
],
|
||||
["scripts/docs-link-audit.mjs", ["src/scripts/docs-link-audit.test.ts"]],
|
||||
["scripts/lib/arg-utils.mjs", ["test/scripts/arg-utils.test.ts"]],
|
||||
[
|
||||
"scripts/lib/bundled-plugin-build-entries.mjs",
|
||||
["test/scripts/bundled-plugin-build-entries.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/lib/bundled-plugin-source-utils.mjs",
|
||||
["test/scripts/bundled-plugin-source-utils.test.ts"],
|
||||
],
|
||||
["scripts/lib/dev-tooling-safety.ts", ["test/scripts/dev-tooling-safety.test.ts"]],
|
||||
["scripts/lib/docker-e2e-container.sh", ["test/scripts/docker-build-helper.test.ts"]],
|
||||
["scripts/lib/docker-e2e-package.sh", ["test/scripts/docker-build-helper.test.ts"]],
|
||||
["scripts/lib/format-generated-module.mjs", ["test/scripts/format-generated-module.test.ts"]],
|
||||
["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]],
|
||||
["scripts/lib/local-heavy-check-runtime.mjs", ["test/scripts/local-heavy-check-runtime.test.ts"]],
|
||||
["scripts/lib/managed-child-process.mjs", ["test/scripts/managed-child-process.test.ts"]],
|
||||
["scripts/lib/npm-verify-exec.ts", ["test/scripts/npm-verify-exec.test.ts"]],
|
||||
["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]],
|
||||
["scripts/lib/source-file-scan-cache.mjs", ["test/scripts/source-file-scan-cache.test.ts"]],
|
||||
["scripts/lib/test-group-report.mjs", ["test/scripts/test-group-report.test.ts"]],
|
||||
["scripts/lib/ts-guard-utils.mjs", ["test/scripts/ts-guard-utils.test.ts"]],
|
||||
["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]],
|
||||
[
|
||||
"scripts/mantis/build-telegram-evidence.mjs",
|
||||
@@ -395,13 +458,33 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
],
|
||||
["scripts/run-oxlint.mjs", ["test/scripts/run-oxlint.test.ts"]],
|
||||
["scripts/run-oxlint-shards.mjs", ["test/scripts/run-oxlint.test.ts"]],
|
||||
["scripts/run-with-env.mjs", ["test/scripts/run-with-env.test.ts"]],
|
||||
["scripts/run-node.mjs", ["src/infra/run-node.test.ts"]],
|
||||
["scripts/ci-run-timings.mjs", ["test/scripts/ci-run-timings.test.ts"]],
|
||||
["scripts/docker-e2e.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
|
||||
["scripts/docker-e2e-rerun.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
|
||||
["scripts/docker-e2e-timings.mjs", ["test/scripts/docker-e2e-helper-cli.test.ts"]],
|
||||
["scripts/generate-npm-shrinkwrap.mjs", ["test/scripts/generate-npm-shrinkwrap.test.ts"]],
|
||||
["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]],
|
||||
["scripts/openclaw-npm-postpublish-verify.ts", ["test/openclaw-npm-postpublish-verify.test.ts"]],
|
||||
["scripts/openclaw-npm-release-check.ts", ["test/openclaw-npm-release-check.test.ts"]],
|
||||
["scripts/openclaw-prepack.ts", ["test/openclaw-prepack.test.ts"]],
|
||||
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
|
||||
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
|
||||
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
|
||||
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
|
||||
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
|
||||
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
|
||||
[
|
||||
"scripts/preinstall-package-manager-warning.mjs",
|
||||
["test/scripts/preinstall-package-manager-warning.test.ts"],
|
||||
],
|
||||
["scripts/test-extension-batch.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
["scripts/test-force.ts", ["test/scripts/test-force.test.ts"]],
|
||||
["scripts/test-live.mjs", ["test/scripts/test-live.test.ts"]],
|
||||
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
|
||||
["scripts/verify.mjs", ["test/scripts/verify.test.ts"]],
|
||||
["scripts/zai-fallback-repro.ts", ["test/scripts/zai-fallback-repro.test.ts"]],
|
||||
["scripts/lib/extension-test-plan.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
["scripts/lib/vitest-batch-runner.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
@@ -416,7 +499,33 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
[
|
||||
"scripts/e2e/kitchen-sink-plugin-docker.sh",
|
||||
["test/scripts/plugin-prerelease-test-plan.test.ts"],
|
||||
[
|
||||
"test/scripts/docker-build-helper.test.ts",
|
||||
"test/scripts/plugin-prerelease-test-plan.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/kitchen-sink-rpc-docker.sh",
|
||||
[
|
||||
"test/scripts/docker-build-helper.test.ts",
|
||||
"test/scripts/plugin-prerelease-test-plan.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/kitchen-sink-rpc-walk.mjs",
|
||||
[
|
||||
"test/scripts/kitchen-sink-rpc-walk.test.ts",
|
||||
"test/scripts/plugin-prerelease-test-plan.test.ts",
|
||||
],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/onboard-docker.sh",
|
||||
["test/scripts/docker-build-helper.test.ts", "test/scripts/openclaw-test-state.test.ts"],
|
||||
],
|
||||
["scripts/e2e/plugin-lifecycle-matrix-docker.sh", ["test/scripts/docker-build-helper.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/release-media-memory-docker.sh",
|
||||
["test/scripts/docker-e2e-plan.test.ts", "test/scripts/release-media-memory-scenario.test.ts"],
|
||||
],
|
||||
["scripts/lib/vitest-shard-timings.mjs", ["test/scripts/vitest-shard-timings.test.ts"]],
|
||||
[
|
||||
@@ -426,6 +535,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
|
||||
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]],
|
||||
@@ -626,6 +736,7 @@ const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY";
|
||||
export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(
|
||||
DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS,
|
||||
);
|
||||
const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12;
|
||||
const GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT = 4;
|
||||
const GATEWAY_SERVER_BACKED_HTTP_TEST_TARGETS = new Set([
|
||||
"src/gateway/embeddings-http.test.ts",
|
||||
@@ -785,6 +896,14 @@ function isTestFileTarget(arg) {
|
||||
return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg);
|
||||
}
|
||||
|
||||
function isTestSupportFileTarget(arg) {
|
||||
if (/(?:^|\/)(?:test-helpers|test-support)(?:\/|$)/u.test(arg)) {
|
||||
return true;
|
||||
}
|
||||
const basename = path.posix.basename(arg).replace(/\.[cm]?[jt]sx?$/u, "");
|
||||
return /(?:^|[._-])test-(?:helpers|support)(?:[._-]|$)/u.test(basename);
|
||||
}
|
||||
|
||||
function isLikelyFileTarget(arg) {
|
||||
return /(?:^|\/)[^/]+\.[A-Za-z0-9]+$/u.test(arg);
|
||||
}
|
||||
@@ -863,6 +982,51 @@ function includePatternMatchesAnyFile(pattern, files) {
|
||||
return files.some((file) => file === pattern || path.matchesGlob(file, pattern));
|
||||
}
|
||||
|
||||
function resolveExplicitSourceTestTargets(targetArg, cwd, options = {}) {
|
||||
const relative = toRepoRelativeTarget(targetArg, cwd);
|
||||
const kind = classifyTarget(targetArg, cwd);
|
||||
if (shouldUseWholeConfigTarget(kind, targetArg, cwd)) {
|
||||
return null;
|
||||
}
|
||||
if (!isExistingFileTarget(targetArg, cwd)) {
|
||||
return null;
|
||||
}
|
||||
if (isTestFileTarget(relative)) {
|
||||
return null;
|
||||
}
|
||||
const preciseTargets = resolvePreciseChangedTestTargets(relative, {
|
||||
cwd,
|
||||
forceFullImportGraph: options.forceFullImportGraph === true,
|
||||
});
|
||||
if (preciseTargets && preciseTargets.length > 0) {
|
||||
return [...new Set(preciseTargets)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
if (!isTestSupportFileTarget(relative)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
resolveAffectedTestsFromImportGraph(relative, cwd, {
|
||||
forceFull: options.forceFullImportGraph === true,
|
||||
}),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function expandExplicitSourceTestTargets(targetArgs, cwd) {
|
||||
const sourceTargetCount = targetArgs.filter((targetArg) => {
|
||||
const relative = toRepoRelativeTarget(targetArg, cwd);
|
||||
return isExistingFileTarget(targetArg, cwd) && !isTestFileTarget(relative);
|
||||
}).length;
|
||||
const forceFullImportGraph = sourceTargetCount > EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD;
|
||||
return targetArgs.flatMap((targetArg) => {
|
||||
const targets = resolveExplicitSourceTestTargets(targetArg, cwd, {
|
||||
forceFullImportGraph,
|
||||
});
|
||||
return targets && targets.length > 0 ? targets : [targetArg];
|
||||
});
|
||||
}
|
||||
|
||||
export function findUnmatchedExplicitTestTargets(args, cwd = process.cwd()) {
|
||||
const { targetArgs } = parseTestProjectsArgs(args, cwd);
|
||||
if (targetArgs.length === 0) {
|
||||
@@ -907,6 +1071,17 @@ export function findUnmatchedExplicitTestTargets(args, cwd = process.cwd()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const explicitSupportTargets = resolveExplicitSourceTestTargets(targetArg, cwd);
|
||||
if (explicitSupportTargets) {
|
||||
if (explicitSupportTargets.length === 0) {
|
||||
unmatched.push({
|
||||
target: targetArg,
|
||||
reason: "target-matched-no-test-files",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const includePattern = toScopedIncludePattern(targetArg, cwd);
|
||||
if (!includePatternMatchesAnyFile(includePattern, getCandidateFiles())) {
|
||||
unmatched.push({
|
||||
@@ -1195,11 +1370,13 @@ function getImportGraph(cwd) {
|
||||
return cachedImportGraph;
|
||||
}
|
||||
|
||||
function resolveAffectedTestsFromImportGraph(changedPath, cwd) {
|
||||
function resolveAffectedTestsFromImportGraph(changedPath, cwd, options = {}) {
|
||||
const normalized = normalizePathPattern(changedPath);
|
||||
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(normalized, cwd);
|
||||
if (targetedTargets !== null) {
|
||||
return targetedTargets;
|
||||
if (options.forceFull !== true) {
|
||||
const targetedTargets = resolveAffectedTestsFromTargetedImportScan(normalized, cwd);
|
||||
if (targetedTargets !== null) {
|
||||
return targetedTargets;
|
||||
}
|
||||
}
|
||||
|
||||
const { reverseImports, testFiles } = getImportGraph(cwd);
|
||||
@@ -1336,10 +1513,10 @@ function shouldKeepBroadChangedRun(changedPaths) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveToolingChangedTestTargets(changedPaths) {
|
||||
function resolveToolingChangedTestTargets(changedPaths, cwd = process.cwd()) {
|
||||
const targets = [];
|
||||
for (const changedPath of changedPaths) {
|
||||
const testTargets = resolveToolingTestTargets(changedPath);
|
||||
const testTargets = resolveToolingTestTargets(changedPath, cwd);
|
||||
if (!testTargets) {
|
||||
return null;
|
||||
}
|
||||
@@ -1348,8 +1525,34 @@ function resolveToolingChangedTestTargets(changedPaths) {
|
||||
return [...new Set(targets)];
|
||||
}
|
||||
|
||||
function resolveToolingTestTargets(changedPath) {
|
||||
return TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
|
||||
function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd()) {
|
||||
const match = /^scripts\/(.+)\.(?:mjs|ts|js|sh|py)$/u.exec(changedPath);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const stem = match[1];
|
||||
const basename = path.posix.basename(stem);
|
||||
const dashedStem = stem.replaceAll("/", "-");
|
||||
const candidates = [
|
||||
`test/scripts/${stem}.test.ts`,
|
||||
`test/scripts/${dashedStem}.test.ts`,
|
||||
`test/scripts/${basename}.test.ts`,
|
||||
`src/scripts/${stem}.test.ts`,
|
||||
`src/scripts/${dashedStem}.test.ts`,
|
||||
`src/scripts/${basename}.test.ts`,
|
||||
];
|
||||
const targets = candidates.filter((candidate) => fs.existsSync(path.join(cwd, candidate)));
|
||||
return targets.length > 0 ? targets : null;
|
||||
}
|
||||
|
||||
function resolveToolingTestTargets(changedPath, cwd = process.cwd()) {
|
||||
const explicitTargets =
|
||||
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
|
||||
const conventionalTargets = resolveConventionalToolingTestTargets(changedPath, cwd);
|
||||
if (explicitTargets && conventionalTargets) {
|
||||
return uniqueOrdered([...explicitTargets, ...conventionalTargets]);
|
||||
}
|
||||
return explicitTargets ?? conventionalTargets;
|
||||
}
|
||||
|
||||
function shouldUseBroadChangedTargets(env = process.env) {
|
||||
@@ -1403,8 +1606,13 @@ function resolvePreciseChangedTestTargets(changedPath, options) {
|
||||
if (shouldRouteChangedTargetWithoutImportGraph(changedPath)) {
|
||||
return changedPath.startsWith("ui/src/") ? [changedPath] : null;
|
||||
}
|
||||
if (options.skipImportGraph === true) {
|
||||
return null;
|
||||
}
|
||||
if (/^(?:src|test\/helpers|extensions|packages|ui\/src|ui\/config)\//u.test(changedPath)) {
|
||||
const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd);
|
||||
const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd, {
|
||||
forceFull: options.forceFullImportGraph === true,
|
||||
});
|
||||
if (affectedTests.length > 0) {
|
||||
return affectedTests;
|
||||
}
|
||||
@@ -1416,16 +1624,21 @@ export function resolveChangedTestTargetPlan(changedPaths, options = {}) {
|
||||
if (changedPaths.length === 0) {
|
||||
return { mode: "none", targets: [] };
|
||||
}
|
||||
const toolingTargets = resolveToolingChangedTestTargets(changedPaths);
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const toolingTargets = resolveToolingChangedTestTargets(changedPaths, cwd);
|
||||
if (toolingTargets) {
|
||||
return { mode: "targets", targets: toolingTargets };
|
||||
}
|
||||
const changedLanes = detectChangedLanes(changedPaths);
|
||||
const env = options.env ?? {};
|
||||
const useBroadFallback = options.broad ?? shouldUseBroadChangedTargets(env);
|
||||
const skipImportGraph = changedLanes.lanes.all && !useBroadFallback;
|
||||
const targets = [];
|
||||
for (const changedPath of changedPaths) {
|
||||
const preciseTargets = resolvePreciseChangedTestTargets(changedPath, options);
|
||||
const preciseTargets = resolvePreciseChangedTestTargets(changedPath, {
|
||||
...options,
|
||||
skipImportGraph,
|
||||
});
|
||||
if (preciseTargets) {
|
||||
targets.push(...preciseTargets);
|
||||
continue;
|
||||
@@ -1778,7 +1991,8 @@ export function buildVitestRunPlans(
|
||||
const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
|
||||
const changedTargetArgs =
|
||||
targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths, options) : null;
|
||||
const activeTargetArgs = changedTargetArgs ?? targetArgs;
|
||||
const requestedTargetArgs = changedTargetArgs ?? targetArgs;
|
||||
const activeTargetArgs = expandExplicitSourceTestTargets(requestedTargetArgs, cwd);
|
||||
const activeForwardedArgs =
|
||||
changedTargetArgs !== null ? stripChangedArgs(forwardedArgs) : forwardedArgs;
|
||||
if (changedTargetArgs !== null && activeTargetArgs.length === 0) {
|
||||
@@ -1824,7 +2038,7 @@ export function buildVitestRunPlans(
|
||||
);
|
||||
}
|
||||
|
||||
const nonTargetArgs = activeForwardedArgs.filter((arg) => !activeTargetArgs.includes(arg));
|
||||
const nonTargetArgs = activeForwardedArgs.filter((arg) => !requestedTargetArgs.includes(arg));
|
||||
const orderedKinds = [
|
||||
"unitFast",
|
||||
"unitFastFakeTimers",
|
||||
@@ -1948,11 +2162,13 @@ export function buildVitestRunPlans(
|
||||
? null
|
||||
: useWholeConfigTarget
|
||||
? null
|
||||
: grouped.flatMap((targetArg) => {
|
||||
const lightLanePatterns = resolveLightLaneIncludePatterns(kind, targetArg, cwd);
|
||||
return lightLanePatterns ?? [toScopedIncludePattern(targetArg, cwd)];
|
||||
});
|
||||
const scopedTargetArgs = useCliTargetArgs ? grouped : [];
|
||||
: uniqueOrdered(
|
||||
grouped.flatMap((targetArg) => {
|
||||
const lightLanePatterns = resolveLightLaneIncludePatterns(kind, targetArg, cwd);
|
||||
return lightLanePatterns ?? [toScopedIncludePattern(targetArg, cwd)];
|
||||
}),
|
||||
);
|
||||
const scopedTargetArgs = useCliTargetArgs ? uniqueOrdered(grouped) : [];
|
||||
plans.push({
|
||||
config,
|
||||
forwardedArgs: [...nonTargetArgs, ...scopedTargetArgs],
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "./postinstall-bundled-plugins.mjs";
|
||||
|
||||
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const INEFFECTIVE_DYNAMIC_IMPORT_MARKER = "[INEFFECTIVE_DYNAMIC_IMPORT]";
|
||||
const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/;
|
||||
const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
|
||||
@@ -355,6 +354,32 @@ function resolveTsdownEnv(env, params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function tsdownBuildUsage() {
|
||||
return [
|
||||
"Usage: node scripts/tsdown-build.mjs [tsdown args...]",
|
||||
"",
|
||||
"Builds OpenClaw with tsdown and validates emitted import diagnostics.",
|
||||
"",
|
||||
"Options:",
|
||||
" -h, --help Show this help without starting tsdown.",
|
||||
"",
|
||||
"Other arguments are forwarded to tsdown.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseTsdownBuildArgs(argv) {
|
||||
if (argv.includes("--help") || argv.includes("-h")) {
|
||||
return {
|
||||
forwardedArgs: [],
|
||||
help: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
forwardedArgs: argv,
|
||||
help: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTsdownOutputScanner(params = {}) {
|
||||
const maxCaptureBytes = params.maxCaptureBytes ?? DEFAULT_CAPTURE_BYTES;
|
||||
let captured = "";
|
||||
@@ -399,13 +424,14 @@ export function createTsdownOutputScanner(params = {}) {
|
||||
|
||||
export function resolveTsdownBuildInvocation(params = {}) {
|
||||
const env = resolveTsdownEnv(params.env ?? process.env, params);
|
||||
const forwardedArgs = params.args ?? [];
|
||||
const tsdownArgs = [
|
||||
"--config-loader",
|
||||
"unrun",
|
||||
"--logLevel",
|
||||
logLevel,
|
||||
"--no-clean",
|
||||
...extraArgs,
|
||||
...forwardedArgs,
|
||||
];
|
||||
if (env.OPENCLAW_BUILD_ALL_NO_PNPM === "1") {
|
||||
return {
|
||||
@@ -540,11 +566,16 @@ function isMainModule() {
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const args = parseTsdownBuildArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(tsdownBuildUsage());
|
||||
process.exit(0);
|
||||
}
|
||||
pruneSourceCheckoutBundledPluginNodeModules();
|
||||
pruneUntrackedGeneratedSourceDeclarations();
|
||||
pruneStaleRuntimeSymlinks();
|
||||
cleanTsdownOutputRoots();
|
||||
const invocation = resolveTsdownBuildInvocation();
|
||||
const invocation = resolveTsdownBuildInvocation({ args: args.forwardedArgs });
|
||||
const result = await runTsdownBuildInvocation(invocation);
|
||||
|
||||
if (result.status === 0 && result.hasIneffectiveDynamicImport) {
|
||||
|
||||
@@ -7,6 +7,29 @@ const stages = [
|
||||
{ name: "test", args: ["test"] },
|
||||
];
|
||||
|
||||
export function usage() {
|
||||
return [
|
||||
"Usage: node scripts/verify.mjs",
|
||||
"",
|
||||
"Runs the full verification graph: pnpm check, then pnpm test.",
|
||||
"",
|
||||
"Options:",
|
||||
" -h, --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseVerifyArgs(argv) {
|
||||
const args = { help: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runStage(stage) {
|
||||
console.error(`CRABBOX_PHASE:${stage.name}`);
|
||||
console.error(`[verify] ${stage.name}`);
|
||||
@@ -22,7 +45,21 @@ async function runStage(stage) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
let args;
|
||||
try {
|
||||
args = parseVerifyArgs(argv);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const timings = [];
|
||||
for (const stage of stages) {
|
||||
const result = await runStage(stage);
|
||||
|
||||
@@ -254,6 +254,33 @@ describe("acp translator stable lifecycle handlers", () => {
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("lists Gateway sessions with invalid updated timestamps", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return createGatewaySessions([
|
||||
createSessionRow({
|
||||
key: "agent:main:work",
|
||||
cwd: "/tmp/openclaw",
|
||||
title: "Work session",
|
||||
updatedAt: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
const result = await agent.listSessions(createListSessionsRequest({ cwd: "/tmp/openclaw" }));
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.updatedAt).toBeUndefined();
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("rejects session/list cursors when the cwd filter changes", async () => {
|
||||
const allRows = [
|
||||
createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }),
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
resolveFixedWindowRateLimitInteger,
|
||||
type FixedWindowRateLimiter,
|
||||
} from "../infra/fixed-window-rate-limit.js";
|
||||
import { timestampMsToIsoString } from "../shared/number-coercion.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import {
|
||||
@@ -502,10 +503,7 @@ function buildSessionMetadata(params: {
|
||||
normalizeOptionalString(params.row?.displayName) ||
|
||||
normalizeOptionalString(params.row?.label) ||
|
||||
params.sessionKey;
|
||||
const updatedAt =
|
||||
typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt)
|
||||
? new Date(params.row.updatedAt).toISOString()
|
||||
: null;
|
||||
const updatedAt = timestampMsToIsoString(params.row?.updatedAt) ?? null;
|
||||
return {
|
||||
title,
|
||||
updatedAt,
|
||||
@@ -1914,7 +1912,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
sessionId: session.key,
|
||||
cwd,
|
||||
title: session.derivedTitle ?? session.displayName ?? session.label ?? session.key,
|
||||
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
|
||||
updatedAt: timestampMsToIsoString(session.updatedAt),
|
||||
_meta: toAcpSessionLineageMeta(session),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,12 +97,18 @@ export function hasMatchingOAuthIdentity(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSafeToOverwriteStoredOAuthIdentity(
|
||||
type OAuthIdentitySafetyPolicy = {
|
||||
whenExistingCredentialMissing: boolean;
|
||||
whenExistingIdentityMissing: boolean;
|
||||
};
|
||||
|
||||
function isSafeOAuthIdentityTransition(
|
||||
existing: OAuthCredential | undefined,
|
||||
incoming: OAuthCredential,
|
||||
policy: OAuthIdentitySafetyPolicy,
|
||||
): boolean {
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
return true;
|
||||
return policy.whenExistingCredentialMissing;
|
||||
}
|
||||
if (existing.provider !== incoming.provider) {
|
||||
return false;
|
||||
@@ -111,47 +117,39 @@ export function isSafeToOverwriteStoredOAuthIdentity(
|
||||
return true;
|
||||
}
|
||||
if (!hasOAuthIdentity(existing)) {
|
||||
return false;
|
||||
return policy.whenExistingIdentityMissing;
|
||||
}
|
||||
return hasMatchingOAuthIdentity(existing, incoming);
|
||||
}
|
||||
|
||||
export function isSafeToOverwriteStoredOAuthIdentity(
|
||||
existing: OAuthCredential | undefined,
|
||||
incoming: OAuthCredential,
|
||||
): boolean {
|
||||
return isSafeOAuthIdentityTransition(existing, incoming, {
|
||||
whenExistingCredentialMissing: true,
|
||||
whenExistingIdentityMissing: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function isSafeToAdoptBootstrapOAuthIdentity(
|
||||
existing: OAuthCredential | undefined,
|
||||
incoming: OAuthCredential,
|
||||
): boolean {
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
return true;
|
||||
}
|
||||
if (existing.provider !== incoming.provider) {
|
||||
return false;
|
||||
}
|
||||
if (areOAuthCredentialsEquivalent(existing, incoming)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOAuthIdentity(existing)) {
|
||||
return true;
|
||||
}
|
||||
return hasMatchingOAuthIdentity(existing, incoming);
|
||||
return isSafeOAuthIdentityTransition(existing, incoming, {
|
||||
whenExistingCredentialMissing: true,
|
||||
whenExistingIdentityMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function isSafeToAdoptMainStoreOAuthIdentity(
|
||||
existing: OAuthCredential | undefined,
|
||||
incoming: OAuthCredential,
|
||||
): boolean {
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
if (existing.provider !== incoming.provider) {
|
||||
return false;
|
||||
}
|
||||
if (areOAuthCredentialsEquivalent(existing, incoming)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOAuthIdentity(existing)) {
|
||||
return true;
|
||||
}
|
||||
return hasMatchingOAuthIdentity(existing, incoming);
|
||||
return isSafeOAuthIdentityTransition(existing, incoming, {
|
||||
whenExistingCredentialMissing: false,
|
||||
whenExistingIdentityMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldBootstrapFromExternalCliCredential(params: {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { Model } from "../llm/types.js";
|
||||
|
||||
const providerRuntimeMocks = vi.hoisted(() => ({
|
||||
prepareProviderDynamicModel: vi.fn(),
|
||||
resolveProviderModernModelRef: vi.fn(),
|
||||
runProviderDynamicModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
normalizeDiscoveredAgentModel: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => providerRuntimeMocks);
|
||||
|
||||
import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js";
|
||||
|
||||
const REGISTRY = { find: () => undefined } as never;
|
||||
@@ -36,6 +44,15 @@ function model(provider: string, id: string): Model {
|
||||
}
|
||||
|
||||
describe("appendPrioritizedDynamicLiveModels", () => {
|
||||
beforeEach(() => {
|
||||
providerRuntimeMocks.prepareProviderDynamicModel.mockReset();
|
||||
providerRuntimeMocks.prepareProviderDynamicModel.mockResolvedValue(undefined);
|
||||
providerRuntimeMocks.resolveProviderModernModelRef.mockReset();
|
||||
providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined);
|
||||
providerRuntimeMocks.runProviderDynamicModel.mockReset();
|
||||
providerRuntimeMocks.runProviderDynamicModel.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("materializes prioritized refs from provider dynamic model hooks", async () => {
|
||||
const resolveDynamicModel: DynamicModelResolver = vi.fn((params) =>
|
||||
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
|
||||
@@ -130,4 +147,25 @@ describe("appendPrioritizedDynamicLiveModels", () => {
|
||||
expect(prepareDynamicModel).not.toHaveBeenCalled();
|
||||
expect(resolveDynamicModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses default provider runtime hooks when resolvers are not injected", async () => {
|
||||
providerRuntimeMocks.runProviderDynamicModel.mockImplementation((params) =>
|
||||
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
|
||||
? model(DYNAMIC_PROVIDER, "glm-5")
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const result = await appendPrioritizedDynamicLiveModels({
|
||||
models: [],
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
modelRegistry: REGISTRY,
|
||||
refs: [{ provider: DYNAMIC_PROVIDER, id: "glm-5" }],
|
||||
});
|
||||
|
||||
expect(result.added.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
|
||||
`${DYNAMIC_PROVIDER}/glm-5`,
|
||||
]);
|
||||
expect(providerRuntimeMocks.prepareProviderDynamicModel).toHaveBeenCalledTimes(1);
|
||||
expect(providerRuntimeMocks.runProviderDynamicModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,25 +5,31 @@ import type {
|
||||
runProviderDynamicModel,
|
||||
} from "../plugins/provider-runtime.js";
|
||||
import type { ProviderResolveDynamicModelContext } from "../plugins/types.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import { listPrioritizedHighSignalLiveModelRefs } from "./live-model-filter.js";
|
||||
import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
type ProviderRuntimeModule = typeof import("../plugins/provider-runtime.js");
|
||||
type DynamicModelResolver = typeof runProviderDynamicModel;
|
||||
type DynamicModelPreparer = typeof prepareProviderDynamicModel;
|
||||
type DynamicModelNormalizer = (model: Model, agentDir: string) => Model | Promise<Model>;
|
||||
|
||||
const providerRuntimeLoader = createLazyImportLoader<ProviderRuntimeModule>(
|
||||
() => import("../plugins/provider-runtime.js"),
|
||||
);
|
||||
|
||||
async function prepareProviderDynamicModelDefault(
|
||||
params: Parameters<DynamicModelPreparer>[0],
|
||||
): Promise<void> {
|
||||
const { prepareProviderDynamicModel } = await import("../plugins/provider-runtime.js");
|
||||
const { prepareProviderDynamicModel } = await providerRuntimeLoader.load();
|
||||
await prepareProviderDynamicModel(params);
|
||||
}
|
||||
|
||||
async function runProviderDynamicModelDefault(
|
||||
params: Parameters<DynamicModelResolver>[0],
|
||||
): Promise<ReturnType<DynamicModelResolver>> {
|
||||
const { runProviderDynamicModel } = await import("../plugins/provider-runtime.js");
|
||||
const { runProviderDynamicModel } = await providerRuntimeLoader.load();
|
||||
return runProviderDynamicModel(params);
|
||||
}
|
||||
|
||||
|
||||
@@ -1770,6 +1770,33 @@ describe("/acp command", () => {
|
||||
expect(hoisted.getStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tolerates Date-invalid ACP status timestamps", async () => {
|
||||
mockBoundThreadSession();
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
...createAcpSessionEntry(),
|
||||
acp: {
|
||||
...createAcpSessionEntry().acp,
|
||||
lastActivityAt: 8_700_000_000_000_000,
|
||||
},
|
||||
});
|
||||
createTaskRecord({
|
||||
runtime: "acp",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
childSessionKey: defaultAcpSessionKey,
|
||||
runId: "acp-run-1",
|
||||
task: "Inspect ACP backlog",
|
||||
status: "running",
|
||||
lastEventAt: 8_700_000_000_000_000,
|
||||
});
|
||||
|
||||
const result = await runThreadAcpCommand("/acp status", baseCfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("ACP status:");
|
||||
expect(result?.reply?.text).toContain("lastActivityAt: n/a");
|
||||
expect(result?.reply?.text).not.toContain("taskUpdatedAt:");
|
||||
});
|
||||
|
||||
it("sanitizes leaked task and runtime details in ACP status output", async () => {
|
||||
mockBoundThreadSession({
|
||||
identity: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateRuntimePermissionProfileInput,
|
||||
} from "../../../acp/control-plane/runtime-options.js";
|
||||
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
|
||||
import { timestampMsToIsoString } from "../../../shared/number-coercion.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
|
||||
import { findLatestTaskForRelatedSessionKeyForOwner } from "../../../tasks/task-owner-access.js";
|
||||
import { sanitizeTaskStatusText } from "../../../tasks/task-status.js";
|
||||
@@ -145,6 +146,11 @@ export async function handleAcpStatusAction(
|
||||
const runtimeDetails = sanitizeTaskStatusText(status.runtimeStatus?.details, {
|
||||
errorContext: true,
|
||||
});
|
||||
const taskUpdatedAt =
|
||||
typeof linkedTask?.lastEventAt === "number"
|
||||
? timestampMsToIsoString(linkedTask.lastEventAt)
|
||||
: undefined;
|
||||
const lastActivityAt = timestampMsToIsoString(status.lastActivityAt) ?? "n/a";
|
||||
const lines = [
|
||||
"ACP status:",
|
||||
"-----",
|
||||
@@ -162,14 +168,12 @@ export async function handleAcpStatusAction(
|
||||
...(taskProgress ? [`taskProgress: ${taskProgress}`] : []),
|
||||
...(taskSummary ? [`taskSummary: ${taskSummary}`] : []),
|
||||
...(taskError ? [`taskError: ${taskError}`] : []),
|
||||
...(typeof linkedTask.lastEventAt === "number"
|
||||
? [`taskUpdatedAt: ${new Date(linkedTask.lastEventAt).toISOString()}`]
|
||||
: []),
|
||||
...(taskUpdatedAt ? [`taskUpdatedAt: ${taskUpdatedAt}`] : []),
|
||||
]
|
||||
: []),
|
||||
`runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`,
|
||||
`capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`,
|
||||
`lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`,
|
||||
`lastActivityAt: ${lastActivityAt}`,
|
||||
...(lastError ? [`lastError: ${lastError}`] : []),
|
||||
...(runtimeSummary ? [`runtime: ${runtimeSummary}`] : []),
|
||||
...(runtimeDetails ? [`runtimeDetails: ${runtimeDetails}`] : []),
|
||||
|
||||
@@ -4,8 +4,13 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getSessionEntry, upsertSessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js";
|
||||
import {
|
||||
formatGoalContinuationPrompt,
|
||||
handleGoalCommand,
|
||||
parseGoalCommand,
|
||||
} from "./commands-goal.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.parse.js";
|
||||
|
||||
const sessionKey = "agent:main:web:main";
|
||||
let tempRoots: string[] = [];
|
||||
@@ -76,6 +81,18 @@ describe("goal commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats command-looking continuation prompts so inline directives leave them intact", () => {
|
||||
const prompt = formatGoalContinuationPrompt("ship /fast off");
|
||||
expect(prompt).toBe(
|
||||
`Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`,
|
||||
);
|
||||
|
||||
const directives = parseInlineDirectives(prompt);
|
||||
|
||||
expect(directives.cleaned).toBe(prompt);
|
||||
expect(directives.hasFastDirective).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a goal from Codex-style bare /goal objective text", async () => {
|
||||
const storePath = await createStorePath();
|
||||
await upsertSessionEntry({
|
||||
@@ -84,13 +101,118 @@ describe("goal commands", () => {
|
||||
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
|
||||
});
|
||||
|
||||
const result = await handleGoalCommand(
|
||||
buildGoalParams("/goal build a 3d game", storePath),
|
||||
true,
|
||||
);
|
||||
const params = buildGoalParams("/goal build a 3d game", storePath);
|
||||
const result = await handleGoalCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("Goal started: build a 3d game");
|
||||
expect(result?.shouldContinue).toBe(true);
|
||||
expect(result?.reply).toBeUndefined();
|
||||
expect(params.command.commandBodyNormalized).toBe("build a 3d game");
|
||||
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe("build a 3d game");
|
||||
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("build a 3d game");
|
||||
});
|
||||
|
||||
it("wraps command-prefixed goal objectives before continuing", async () => {
|
||||
const storePath = await createStorePath();
|
||||
await upsertSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
|
||||
});
|
||||
|
||||
const slashParams = buildGoalParams("/goal start /status", storePath);
|
||||
const slashResult = await handleGoalCommand(slashParams, true);
|
||||
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
|
||||
|
||||
expect(slashResult?.shouldContinue).toBe(true);
|
||||
expect(slashParams.command.commandBodyNormalized).toBe(slashPrompt);
|
||||
expect((slashParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(slashPrompt);
|
||||
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("/status");
|
||||
|
||||
const bangStorePath = await createStorePath();
|
||||
await upsertSessionEntry({
|
||||
storePath: bangStorePath,
|
||||
sessionKey,
|
||||
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
|
||||
});
|
||||
|
||||
const bangParams = buildGoalParams("/goal start !npm test", bangStorePath);
|
||||
const bangResult = await handleGoalCommand(bangParams, true);
|
||||
const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`;
|
||||
|
||||
expect(bangResult?.shouldContinue).toBe(true);
|
||||
expect(bangParams.command.commandBodyNormalized).toBe(bangPrompt);
|
||||
expect((bangParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(bangPrompt);
|
||||
expect(getSessionEntry({ storePath: bangStorePath, sessionKey })?.goal?.objective).toBe(
|
||||
"!npm test",
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a goal and continues with a resume prompt", async () => {
|
||||
const storePath = await createStorePath();
|
||||
await upsertSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
entry: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: 1,
|
||||
goal: {
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "finish the migration",
|
||||
status: "paused",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokenStartFresh: true,
|
||||
tokensUsed: 0,
|
||||
continuationTurns: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = buildGoalParams("/goal resume CI passed", storePath);
|
||||
const result = await handleGoalCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(true);
|
||||
expect(params.command.commandBodyNormalized).toBe(
|
||||
"Continue pursuing the current goal. Note: CI passed",
|
||||
);
|
||||
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("wraps command-looking resume notes before continuing", async () => {
|
||||
const storePath = await createStorePath();
|
||||
await upsertSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
entry: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: 1,
|
||||
goal: {
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "finish the migration",
|
||||
status: "paused",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokenStartFresh: true,
|
||||
tokensUsed: 0,
|
||||
continuationTurns: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = buildGoalParams("/goal resume /fast off", storePath);
|
||||
const result = await handleGoalCommand(params, true);
|
||||
const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`;
|
||||
const directives = parseInlineDirectives(prompt);
|
||||
|
||||
expect(result?.shouldContinue).toBe(true);
|
||||
expect(params.command.commandBodyNormalized).toBe(prompt);
|
||||
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(prompt);
|
||||
expect(directives.cleaned).toBe(prompt);
|
||||
expect(directives.hasFastDirective).toBe(false);
|
||||
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,61 @@ function goalReply(text: string): CommandHandlerResult {
|
||||
};
|
||||
}
|
||||
|
||||
function hasCommandLikeGoalText(trimmed: string): boolean {
|
||||
return /(?:^|\s)\//.test(trimmed) || trimmed.startsWith("!");
|
||||
}
|
||||
|
||||
function encodeGoalJsonString(trimmed: string): string {
|
||||
return JSON.stringify(trimmed).replaceAll("/", "\\/");
|
||||
}
|
||||
|
||||
export function formatGoalContinuationPrompt(objective: string): string {
|
||||
const trimmed = objective.trim();
|
||||
return hasCommandLikeGoalText(trimmed)
|
||||
? `Pursue this goal exactly as written from this JSON string: ${encodeGoalJsonString(trimmed)}`
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
export function formatGoalResumeContinuationPrompt(note: string): string {
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) {
|
||||
return "Continue pursuing the current goal.";
|
||||
}
|
||||
return hasCommandLikeGoalText(trimmed)
|
||||
? `Continue pursuing the current goal. Interpret this JSON string as the resume note: ${encodeGoalJsonString(trimmed)}`
|
||||
: `Continue pursuing the current goal. Note: ${trimmed}`;
|
||||
}
|
||||
|
||||
function applyGoalPromptToContext(ctx: HandleCommandsParams["ctx"], message: string): void {
|
||||
const mutableCtx = ctx as HandleCommandsParams["ctx"] & {
|
||||
Body?: string;
|
||||
RawBody?: string;
|
||||
CommandBody?: string;
|
||||
BodyForCommands?: string;
|
||||
BodyForAgent?: string;
|
||||
BodyStripped?: string;
|
||||
};
|
||||
mutableCtx.Body = message;
|
||||
mutableCtx.RawBody = message;
|
||||
mutableCtx.CommandBody = message;
|
||||
mutableCtx.BodyForCommands = message;
|
||||
mutableCtx.BodyForAgent = message;
|
||||
mutableCtx.BodyStripped = message;
|
||||
}
|
||||
|
||||
function applyGoalContinuationPrompt(params: HandleCommandsParams, message: string): void {
|
||||
applyGoalPromptToContext(params.ctx, message);
|
||||
if (params.rootCtx && params.rootCtx !== params.ctx) {
|
||||
applyGoalPromptToContext(params.rootCtx, message);
|
||||
}
|
||||
params.command.rawBodyNormalized = message;
|
||||
params.command.commandBodyNormalized = message;
|
||||
}
|
||||
|
||||
function goalContinuation(): CommandHandlerResult {
|
||||
return { shouldContinue: true };
|
||||
}
|
||||
|
||||
function goalErrorReply(error: unknown): CommandHandlerResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return goalReply(`Goal error: ${message}`);
|
||||
@@ -115,7 +170,8 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
|
||||
fallbackEntry: params.sessionEntry,
|
||||
});
|
||||
syncGoalSessionEntry(params);
|
||||
return goalReply(`Goal started: ${goal.objective}`);
|
||||
applyGoalContinuationPrompt(params, formatGoalContinuationPrompt(goal.objective));
|
||||
return goalContinuation();
|
||||
}
|
||||
case "pause": {
|
||||
const goal = await updateSessionGoalStatus({
|
||||
@@ -128,14 +184,16 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
|
||||
return goalReply(`Goal paused: ${goal.objective}`);
|
||||
}
|
||||
case "resume": {
|
||||
const goal = await updateSessionGoalStatus({
|
||||
await updateSessionGoalStatus({
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
status: "active",
|
||||
...(parsed.text ? { note: parsed.text } : {}),
|
||||
});
|
||||
syncGoalSessionEntry(params);
|
||||
return goalReply(`Goal resumed: ${goal.objective}`);
|
||||
const message = formatGoalResumeContinuationPrompt(parsed.text);
|
||||
applyGoalContinuationPrompt(params, message);
|
||||
return goalContinuation();
|
||||
}
|
||||
case "complete":
|
||||
case "done": {
|
||||
|
||||
@@ -570,6 +570,25 @@ describe("/session idle and /session max-age", () => {
|
||||
expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back when active idle timeout expiry is Date-invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
|
||||
createThreadBinding({
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
lastActivityAt: 8_700_000_000_000_000,
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleSessionCommand(createThreadCommandParams("/session idle"), true);
|
||||
expect(result?.reply?.text).toBe("ℹ️ Idle timeout active (2h, next auto-unfocus at n/a).");
|
||||
});
|
||||
|
||||
it("sets max age for the focused thread-chat session", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
|
||||
import { timestampMsToIsoString } from "../../shared/number-coercion.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -87,7 +88,7 @@ function parseSessionDurationMs(raw: string): number {
|
||||
}
|
||||
|
||||
function formatSessionExpiry(expiresAt: number) {
|
||||
return new Date(expiresAt).toISOString();
|
||||
return timestampMsToIsoString(expiresAt) ?? "n/a";
|
||||
}
|
||||
|
||||
function resolveSessionBindingDurationMs(
|
||||
|
||||
@@ -200,6 +200,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
|
||||
if (!commandResult.shouldContinue) {
|
||||
return { handled: true, reply: commandResult.reply };
|
||||
}
|
||||
const continuationTriggerBodyNormalized = command.rawBodyNormalized;
|
||||
|
||||
const directiveResult = await resolveReplyDirectives({
|
||||
ctx: params.ctx,
|
||||
@@ -216,7 +217,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
|
||||
sessionScope: sessionState.sessionScope,
|
||||
groupResolution: sessionState.groupResolution,
|
||||
isGroup: sessionState.isGroup,
|
||||
triggerBodyNormalized: sessionState.triggerBodyNormalized,
|
||||
triggerBodyNormalized: continuationTriggerBodyNormalized,
|
||||
resetTriggered: false,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
defaultProvider: params.defaultProvider,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./get-reply-fast-path.js";
|
||||
import {
|
||||
buildGetReplyCtx,
|
||||
createGetReplyContinueDirectivesResult,
|
||||
createGetReplySessionState,
|
||||
expectResolvedTelegramTimezone,
|
||||
registerGetReplyRuntimeOverrides,
|
||||
@@ -28,6 +29,7 @@ function emptyAliasIndex(): ModelAliasIndex {
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureAgentWorkspace: vi.fn(),
|
||||
handleInlineActions: vi.fn(),
|
||||
initSessionState: vi.fn(),
|
||||
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
|
||||
{
|
||||
@@ -113,6 +115,8 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
resolveRuntimeCliBackends: () => [],
|
||||
});
|
||||
mocks.ensureAgentWorkspace.mockReset();
|
||||
mocks.handleInlineActions.mockReset();
|
||||
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } });
|
||||
mocks.initSessionState.mockReset();
|
||||
mocks.loadModelCatalog.mockReset();
|
||||
mocks.loadModelCatalog.mockResolvedValue([
|
||||
@@ -525,6 +529,82 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
|
||||
});
|
||||
|
||||
it("continues native slash goal starts with the rewritten command-safe prompt", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-goal-fast-"));
|
||||
const targetSessionKey = "agent:main:telegram:123";
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const cfg = markCompleteReplyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
workspace: path.join(home, "workspace"),
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
} as OpenClawConfig);
|
||||
const continuationPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
|
||||
const continueDirectives = async (params: unknown) =>
|
||||
createGetReplyContinueDirectivesResult({
|
||||
body: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
|
||||
abortKey: targetSessionKey,
|
||||
from: "telegram:user:42",
|
||||
to: "telegram:123",
|
||||
senderId: "telegram:user:42",
|
||||
commandSource: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
|
||||
senderIsOwner: true,
|
||||
resetHookTriggered: false,
|
||||
});
|
||||
mocks.resolveReplyDirectives
|
||||
.mockImplementationOnce(continueDirectives)
|
||||
.mockImplementationOnce(async (params: unknown) => {
|
||||
expect((params as { triggerBodyNormalized: string }).triggerBodyNormalized).toBe(
|
||||
continuationPrompt,
|
||||
);
|
||||
return continueDirectives(params);
|
||||
});
|
||||
mocks.handleInlineActions.mockImplementation(async (params: unknown) => {
|
||||
expect(params).toMatchObject({
|
||||
command: {
|
||||
rawBodyNormalized: continuationPrompt,
|
||||
commandBodyNormalized: continuationPrompt,
|
||||
},
|
||||
cleanedBody: continuationPrompt,
|
||||
});
|
||||
return {
|
||||
kind: "continue",
|
||||
directives: {},
|
||||
abortedLastRun: false,
|
||||
cleanedBody: continuationPrompt,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
getReplyFromConfig(
|
||||
buildGetReplyCtx({
|
||||
Body: "/goal start /status",
|
||||
BodyForAgent: "/goal start /status",
|
||||
RawBody: "/goal start /status",
|
||||
CommandBody: "/goal start /status",
|
||||
CommandSource: "native",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: "telegram:slash:123",
|
||||
CommandTargetSessionKey: targetSessionKey,
|
||||
}),
|
||||
undefined,
|
||||
cfg,
|
||||
),
|
||||
).resolves.toEqual({ text: "ok" });
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf8")) as {
|
||||
sessions?: Record<string, { goal?: { objective?: string } }>;
|
||||
};
|
||||
expect(stored.sessions?.[targetSessionKey]?.goal?.objective).toBe("/status");
|
||||
const preparedReplyParams = requirePreparedReplyParams();
|
||||
expect(preparedReplyParams.command.commandBodyNormalized).toBe(continuationPrompt);
|
||||
expect(preparedReplyParams.sessionCtx.BodyForAgent).toBe(continuationPrompt);
|
||||
expect(mocks.handleInlineActions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses native command target session keys during fast bootstrap", () => {
|
||||
const result = initFastReplySessionState({
|
||||
ctx: buildGetReplyCtx({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
|
||||
import { isModelKeyAllowedBySet } from "../../agents/model-selection-shared.js";
|
||||
import {
|
||||
buildAllowedModelSetWithFallbacks,
|
||||
isModelKeyAllowedBySet,
|
||||
} from "../../agents/model-selection-shared.js";
|
||||
import { normalizeProviderId } from "../../agents/provider-id.js";
|
||||
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
@@ -58,8 +61,6 @@ async function buildResetAllowedModelKeys(params: {
|
||||
}): Promise<Set<string>> {
|
||||
const rawAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {});
|
||||
if (rawAllowlist.length > 0 || params.cfg.models?.providers) {
|
||||
const { buildAllowedModelSetWithFallbacks } =
|
||||
await import("../../agents/model-selection-shared.js");
|
||||
return buildAllowedModelSetWithFallbacks(params).allowedKeys;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ type DevicesRpcOpts = {
|
||||
|
||||
const DEFAULT_DEVICES_TIMEOUT_MS = 10_000;
|
||||
|
||||
type DevicesRuntimeModule = typeof import("./devices-cli.runtime.js");
|
||||
|
||||
let devicesRuntimePromise: Promise<DevicesRuntimeModule> | undefined;
|
||||
|
||||
function loadDevicesRuntime(): Promise<DevicesRuntimeModule> {
|
||||
return (devicesRuntimePromise ??= import("./devices-cli.runtime.js"));
|
||||
}
|
||||
|
||||
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
@@ -37,7 +45,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.command("list")
|
||||
.description("List pending and paired devices")
|
||||
.action(async (opts: DevicesRpcOpts) => {
|
||||
const { runDevicesListCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesListCommand } = await loadDevicesRuntime();
|
||||
await runDevicesListCommand(opts);
|
||||
}),
|
||||
);
|
||||
@@ -48,7 +56,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.description("Remove a paired device entry")
|
||||
.argument("<deviceId>", "Paired device id")
|
||||
.action(async (deviceId: string, opts: DevicesRpcOpts) => {
|
||||
const { runDevicesRemoveCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesRemoveCommand } = await loadDevicesRuntime();
|
||||
await runDevicesRemoveCommand(deviceId, opts);
|
||||
}),
|
||||
);
|
||||
@@ -60,7 +68,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.option("--pending", "Also reject all pending pairing requests", false)
|
||||
.option("--yes", "Confirm destructive clear", false)
|
||||
.action(async (opts: DevicesRpcOpts) => {
|
||||
const { runDevicesClearCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesClearCommand } = await loadDevicesRuntime();
|
||||
await runDevicesClearCommand(opts);
|
||||
}),
|
||||
);
|
||||
@@ -72,7 +80,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.argument("[requestId]", "Pending request id")
|
||||
.option("--latest", "Show the most recent pending request to approve explicitly", false)
|
||||
.action(async (requestId: string | undefined, opts: DevicesRpcOpts) => {
|
||||
const { runDevicesApproveCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesApproveCommand } = await loadDevicesRuntime();
|
||||
await runDevicesApproveCommand(requestId, opts);
|
||||
}),
|
||||
);
|
||||
@@ -83,7 +91,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.description("Reject a pending device pairing request")
|
||||
.argument("<requestId>", "Pending request id")
|
||||
.action(async (requestId: string, opts: DevicesRpcOpts) => {
|
||||
const { runDevicesRejectCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesRejectCommand } = await loadDevicesRuntime();
|
||||
await runDevicesRejectCommand(requestId, opts);
|
||||
}),
|
||||
);
|
||||
@@ -96,7 +104,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.requiredOption("--role <role>", "Role name")
|
||||
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
|
||||
.action(async (opts: DevicesRpcOpts) => {
|
||||
const { runDevicesRotateCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesRotateCommand } = await loadDevicesRuntime();
|
||||
await runDevicesRotateCommand(opts);
|
||||
}),
|
||||
);
|
||||
@@ -108,7 +116,7 @@ export function registerDevicesCli(program: Command) {
|
||||
.requiredOption("--device <id>", "Device id")
|
||||
.requiredOption("--role <role>", "Role name")
|
||||
.action(async (opts: DevicesRpcOpts) => {
|
||||
const { runDevicesRevokeCommand } = await import("./devices-cli.runtime.js");
|
||||
const { runDevicesRevokeCommand } = await loadDevicesRuntime();
|
||||
await runDevicesRevokeCommand(opts);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,10 +4,33 @@ import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
|
||||
type ModelsCliRuntime = typeof import("./models-cli.runtime.js");
|
||||
|
||||
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
let promise: Promise<T> | undefined;
|
||||
return () => (promise ??= load());
|
||||
}
|
||||
|
||||
const loadModelsRuntime = createModuleLoader<ModelsCliRuntime>(
|
||||
() => import("./models-cli.runtime.js"),
|
||||
);
|
||||
const loadModelsStatusCommands = createModuleLoader(
|
||||
() => import("../commands/models/list.status-command.js"),
|
||||
);
|
||||
const loadModelsAliasesCommands = createModuleLoader(() => import("../commands/models/aliases.js"));
|
||||
const loadModelsFallbacksCommands = createModuleLoader(
|
||||
() => import("../commands/models/fallbacks.js"),
|
||||
);
|
||||
const loadModelsImageFallbacksCommands = createModuleLoader(
|
||||
() => import("../commands/models/image-fallbacks.js"),
|
||||
);
|
||||
const loadModelsAuthCommands = createModuleLoader(() => import("../commands/models/auth.js"));
|
||||
const loadModelsAuthOrderCommands = createModuleLoader(
|
||||
() => import("../commands/models/auth-order.js"),
|
||||
);
|
||||
|
||||
async function withModelsRuntime(
|
||||
action: (runtime: ModelsCliRuntime) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const runtime = await import("./models-cli.runtime.js");
|
||||
const runtime = await loadModelsRuntime();
|
||||
return runtime.runModelsCommand(() => action(runtime));
|
||||
}
|
||||
|
||||
@@ -67,7 +90,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command, opts);
|
||||
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
|
||||
const { modelsStatusCommand } = await loadModelsStatusCommands();
|
||||
await modelsStatusCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
@@ -91,7 +114,7 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Set the default model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string, _opts: unknown, command: Command) => {
|
||||
const runtime = await import("./models-cli.runtime.js");
|
||||
const runtime = await loadModelsRuntime();
|
||||
runtime.rejectAgentScopedModelWrite(command, "set");
|
||||
await runtime.runModelsCommand(async () => {
|
||||
const { modelsSetCommand } = await import("../commands/models/set.js");
|
||||
@@ -104,7 +127,7 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Set the image model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string, _opts: unknown, command: Command) => {
|
||||
const runtime = await import("./models-cli.runtime.js");
|
||||
const runtime = await loadModelsRuntime();
|
||||
runtime.rejectAgentScopedModelWrite(command, "set-image");
|
||||
await runtime.runModelsCommand(async () => {
|
||||
const { modelsSetImageCommand } = await import("../commands/models/set-image.js");
|
||||
@@ -121,7 +144,7 @@ export function registerModelsCli(program: Command) {
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsAliasesListCommand } = await import("../commands/models/aliases.js");
|
||||
const { modelsAliasesListCommand } = await loadModelsAliasesCommands();
|
||||
await modelsAliasesListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -133,7 +156,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (alias: string, model: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsAliasesAddCommand } = await import("../commands/models/aliases.js");
|
||||
const { modelsAliasesAddCommand } = await loadModelsAliasesCommands();
|
||||
await modelsAliasesAddCommand(alias, model, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -144,7 +167,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<alias>", "Alias name")
|
||||
.action(async (alias: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsAliasesRemoveCommand } = await import("../commands/models/aliases.js");
|
||||
const { modelsAliasesRemoveCommand } = await loadModelsAliasesCommands();
|
||||
await modelsAliasesRemoveCommand(alias, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -158,7 +181,7 @@ export function registerModelsCli(program: Command) {
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsFallbacksListCommand } = await import("../commands/models/fallbacks.js");
|
||||
const { modelsFallbacksListCommand } = await loadModelsFallbacksCommands();
|
||||
await modelsFallbacksListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -169,7 +192,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsFallbacksAddCommand } = await import("../commands/models/fallbacks.js");
|
||||
const { modelsFallbacksAddCommand } = await loadModelsFallbacksCommands();
|
||||
await modelsFallbacksAddCommand(model, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -180,7 +203,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsFallbacksRemoveCommand } = await import("../commands/models/fallbacks.js");
|
||||
const { modelsFallbacksRemoveCommand } = await loadModelsFallbacksCommands();
|
||||
await modelsFallbacksRemoveCommand(model, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -190,7 +213,7 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Clear all fallback models")
|
||||
.action(async () => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsFallbacksClearCommand } = await import("../commands/models/fallbacks.js");
|
||||
const { modelsFallbacksClearCommand } = await loadModelsFallbacksCommands();
|
||||
await modelsFallbacksClearCommand(defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -206,8 +229,7 @@ export function registerModelsCli(program: Command) {
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsImageFallbacksListCommand } =
|
||||
await import("../commands/models/image-fallbacks.js");
|
||||
const { modelsImageFallbacksListCommand } = await loadModelsImageFallbacksCommands();
|
||||
await modelsImageFallbacksListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -218,8 +240,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsImageFallbacksAddCommand } =
|
||||
await import("../commands/models/image-fallbacks.js");
|
||||
const { modelsImageFallbacksAddCommand } = await loadModelsImageFallbacksCommands();
|
||||
await modelsImageFallbacksAddCommand(model, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -230,8 +251,7 @@ export function registerModelsCli(program: Command) {
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsImageFallbacksRemoveCommand } =
|
||||
await import("../commands/models/image-fallbacks.js");
|
||||
const { modelsImageFallbacksRemoveCommand } = await loadModelsImageFallbacksCommands();
|
||||
await modelsImageFallbacksRemoveCommand(model, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -241,8 +261,7 @@ export function registerModelsCli(program: Command) {
|
||||
.description("Clear all image fallback models")
|
||||
.action(async () => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsImageFallbacksClearCommand } =
|
||||
await import("../commands/models/image-fallbacks.js");
|
||||
const { modelsImageFallbacksClearCommand } = await loadModelsImageFallbacksCommands();
|
||||
await modelsImageFallbacksClearCommand(defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -271,7 +290,7 @@ export function registerModelsCli(program: Command) {
|
||||
|
||||
models.action(async (opts) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime }) => {
|
||||
const { modelsStatusCommand } = await import("../commands/models/list.status-command.js");
|
||||
const { modelsStatusCommand } = await loadModelsStatusCommands();
|
||||
await modelsStatusCommand(
|
||||
{
|
||||
json: Boolean(opts?.statusJson),
|
||||
@@ -316,7 +335,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command) ?? resolveModelAgentOption(auth);
|
||||
const { modelsAuthAddCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthAddCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthAddCommand({ agent }, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@@ -337,7 +356,7 @@ export function registerModelsCli(program: Command) {
|
||||
}
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command);
|
||||
const { modelsAuthLoginCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthLoginCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthLoginCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
@@ -359,7 +378,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command);
|
||||
const { modelsAuthSetupTokenCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthSetupTokenCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthSetupTokenCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
@@ -383,7 +402,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command);
|
||||
const { modelsAuthPasteTokenCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthPasteTokenCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthPasteTokenCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
@@ -404,7 +423,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command);
|
||||
const { modelsAuthPasteApiKeyCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthPasteApiKeyCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthPasteApiKeyCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
@@ -423,7 +442,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command);
|
||||
const { modelsAuthLoginCommand } = await import("../commands/models/auth.js");
|
||||
const { modelsAuthLoginCommand } = await loadModelsAuthCommands();
|
||||
await modelsAuthLoginCommand(
|
||||
{
|
||||
provider: "github-copilot",
|
||||
@@ -447,7 +466,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command, opts);
|
||||
const { modelsAuthOrderGetCommand } = await import("../commands/models/auth-order.js");
|
||||
const { modelsAuthOrderGetCommand } = await loadModelsAuthOrderCommands();
|
||||
await modelsAuthOrderGetCommand(
|
||||
{
|
||||
provider: opts.provider as string,
|
||||
@@ -468,7 +487,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (profileIds: string[], opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command, opts);
|
||||
const { modelsAuthOrderSetCommand } = await import("../commands/models/auth-order.js");
|
||||
const { modelsAuthOrderSetCommand } = await loadModelsAuthOrderCommands();
|
||||
await modelsAuthOrderSetCommand(
|
||||
{
|
||||
provider: opts.provider as string,
|
||||
@@ -488,7 +507,7 @@ export function registerModelsCli(program: Command) {
|
||||
.action(async (opts, command) => {
|
||||
await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => {
|
||||
const agent = resolveModelAgentOption(command, opts);
|
||||
const { modelsAuthOrderClearCommand } = await import("../commands/models/auth-order.js");
|
||||
const { modelsAuthOrderClearCommand } = await loadModelsAuthOrderCommands();
|
||||
await modelsAuthOrderClearCommand(
|
||||
{
|
||||
provider: opts.provider as string,
|
||||
|
||||
@@ -347,6 +347,25 @@ vi.mock("../plugins/status.js", () => ({
|
||||
formatPluginCompatibilityNotice: (entry: { message: string }) => entry.message,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/status-snapshot.js", () => ({
|
||||
buildPluginRegistrySnapshotReport: ((
|
||||
...args: Parameters<
|
||||
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
|
||||
>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<
|
||||
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
|
||||
>,
|
||||
ReturnType<
|
||||
(typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"]
|
||||
>
|
||||
>(
|
||||
buildPluginRegistrySnapshotReport,
|
||||
...args,
|
||||
)) as (typeof import("../plugins/status-snapshot.js"))["buildPluginRegistrySnapshotReport"],
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-registry.js", () => ({
|
||||
loadPluginManifestRegistryForPluginRegistry: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(loadPluginManifestRegistry, ...args)) as (
|
||||
|
||||
@@ -25,6 +25,18 @@ type PluginInstallActionOptions = {
|
||||
marketplace?: string;
|
||||
};
|
||||
|
||||
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
let promise: Promise<T> | undefined;
|
||||
return () => (promise ??= load());
|
||||
}
|
||||
|
||||
const loadPluginsConfigState = createModuleLoader(() => import("../plugins/config-state.js"));
|
||||
const loadPluginsStatus = createModuleLoader(() => import("../plugins/status.js"));
|
||||
const loadPluginsCommandHelpers = createModuleLoader(() => import("./plugins-command-helpers.js"));
|
||||
const loadPluginsRegistryRefresh = createModuleLoader(
|
||||
() => import("./plugins-registry-refresh.js"),
|
||||
);
|
||||
|
||||
function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number {
|
||||
return plugins.filter((plugin) => plugin.enabled).length;
|
||||
}
|
||||
@@ -168,12 +180,10 @@ export async function runPluginsEnableCommand(id: string): Promise<void> {
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
|
||||
const { enablePluginInConfig } = await import("../plugins/enable.js");
|
||||
const { normalizePluginId } = await import("../plugins/config-state.js");
|
||||
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
|
||||
const { applySlotSelectionForPlugin, logSlotWarnings } =
|
||||
await import("./plugins-command-helpers.js");
|
||||
const { refreshPluginRegistryAfterConfigMutation } =
|
||||
await import("./plugins-registry-refresh.js");
|
||||
const { normalizePluginId } = await loadPluginsConfigState();
|
||||
const { buildPluginRegistrySnapshotReport } = await loadPluginsStatus();
|
||||
const { applySlotSelectionForPlugin, logSlotWarnings } = await loadPluginsCommandHelpers();
|
||||
const { refreshPluginRegistryAfterConfigMutation } = await loadPluginsRegistryRefresh();
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
const report = buildPluginRegistrySnapshotReport({ config: cfg });
|
||||
@@ -212,11 +222,10 @@ export async function runPluginsEnableCommand(id: string): Promise<void> {
|
||||
export async function runPluginsDisableCommand(id: string): Promise<void> {
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
|
||||
const { normalizePluginId } = await import("../plugins/config-state.js");
|
||||
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
|
||||
const { normalizePluginId } = await loadPluginsConfigState();
|
||||
const { buildPluginRegistrySnapshotReport } = await loadPluginsStatus();
|
||||
const { setPluginEnabledInConfig } = await import("./plugins-config.js");
|
||||
const { refreshPluginRegistryAfterConfigMutation } =
|
||||
await import("./plugins-registry-refresh.js");
|
||||
const { refreshPluginRegistryAfterConfigMutation } = await loadPluginsRegistryRefresh();
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
const report = buildPluginRegistrySnapshotReport({ config: cfg });
|
||||
@@ -313,7 +322,7 @@ export async function runPluginsDoctorCommand(): Promise<void> {
|
||||
buildPluginCompatibilityNotices,
|
||||
buildPluginDiagnosticsReport,
|
||||
formatPluginCompatibilityNotice,
|
||||
} = await import("../plugins/status.js");
|
||||
} = await loadPluginsStatus();
|
||||
const {
|
||||
collectStalePluginConfigWarnings,
|
||||
isStalePluginAutoRepairBlocked,
|
||||
@@ -428,7 +437,7 @@ export async function runPluginMarketplaceListCommand(
|
||||
opts: PluginMarketplaceListOptions,
|
||||
): Promise<void> {
|
||||
const { listMarketplacePlugins } = await import("../plugins/marketplace.js");
|
||||
const { createPluginInstallLogger } = await import("./plugins-command-helpers.js");
|
||||
const { createPluginInstallLogger } = await loadPluginsCommandHelpers();
|
||||
const result = await listMarketplacePlugins({
|
||||
marketplace: source,
|
||||
logger: createPluginInstallLogger(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user