From baefd067bb23ff4fd1763302f17ccf50251f07db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 22:12:45 -0700 Subject: [PATCH] fix(deps): keep package runtime dependencies single-owned (#126119) * fix(deps): consolidate shared runtime helpers * test(concurrency): support current test lib target * fix(time): preserve year-scale plugin durations * fix(agents): preserve empty subagent completions --- config/knip.config.ts | 6 -- extensions/discord/package.json | 1 - .../src/monitor/thread-bindings.lifecycle.ts | 18 ++-- extensions/google-meet/package.json | 1 - extensions/google-meet/src/cli-shared.ts | 7 +- extensions/memory-wiki/package.json | 1 - .../memory-wiki/src/import-runs-state.ts | 22 +++-- extensions/memory-wiki/src/query.ts | 18 ++-- extensions/memory-wiki/src/unsafe-local.ts | 15 +-- extensions/qa-lab/package.json | 3 - extensions/qa-lab/src/character-eval.ts | 34 +++---- extensions/qa-lab/src/evidence-gallery.ts | 93 ++++++++++--------- .../slack/adapter.runtime.test.ts | 3 +- .../live-transports/slack/adapter.runtime.ts | 8 +- .../slack/scenario-environment.ts | 2 +- .../slack/slack-live.approvals.ts | 2 +- .../slack/slack-live.codex-approval.ts | 2 +- .../slack/slack-live.config.ts | 2 +- .../slack/slack-live.contracts.ts | 8 +- .../slack/slack-live.message-observations.ts | 2 +- .../slack/slack-live.observations.ts | 2 +- extensions/qa-lab/src/suite-planning.ts | 18 ++-- extensions/qa-lab/web/src/ui-render-utils.ts | 6 +- extensions/slack/package.json | 1 - extensions/slack/src/monitor/media.ts | 24 ++--- pnpm-lock.yaml | 21 ----- .../command/attempt-execution.cli.test.ts | 2 + src/agents/command/attempt-execution.ts | 6 +- src/infra/format-time/format-duration.ts | 4 +- src/infra/format-time/format-time.test.ts | 5 + src/plugin-sdk/time-runtime.ts | 1 + src/utils/run-with-concurrency.test.ts | 70 ++++++++++++++ src/utils/run-with-concurrency.ts | 13 ++- tsdown.config.ts | 2 +- 34 files changed, 252 insertions(+), 171 deletions(-) diff --git a/config/knip.config.ts b/config/knip.config.ts index 6cf344dccb4a..0365abc319c0 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -265,7 +265,6 @@ const bundledPluginIgnoredRuntimeDependencies = [ const rootBundledPluginRuntimeDependencies = [ "@anthropic-ai/sdk", - "@anthropic-ai/vertex-sdk", "@google/genai", "@grammyjs/runner", "@grammyjs/transformer-throttler", @@ -273,16 +272,11 @@ const rootBundledPluginRuntimeDependencies = [ "@mozilla/readability", "@silvia-odwyer/photon-node", "@trycua/cua-driver", - "@slack/bolt", - "@slack/types", - "@slack/web-api", "grammy", "linkedom", "minimatch", "node-edge-tts", - "openshell", "clawpdf", - "tokenjuice", ] as const; // Root installation and build workflows deliberately mirror these dependencies from their diff --git a/extensions/discord/package.json b/extensions/discord/package.json index fe7d807e1c4a..0047b4510b57 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -13,7 +13,6 @@ "discord-api-types": "0.38.52", "libopus-wasm": "0.2.0", "mdast-util-from-markdown": "2.0.3", - "p-map": "7.0.6", "typebox": "1.3.6", "undici": "8.9.0", "ws": "8.21.1", diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts index 056b3dd8d217..59113963c5d0 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -1,4 +1,5 @@ import { readAcpSessionEntry, type AcpSessionStoreEntry } from "openclaw/plugin-sdk/acp-runtime"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; // Discord plugin module implements thread bindings.lifecycle behavior. import { @@ -6,7 +7,6 @@ import { normalizeOptionalString, uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap from "p-map"; import { parseDiscordTarget } from "../targets.js"; import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js"; import { getThreadBindingManager } from "./thread-bindings.manager.js"; @@ -266,9 +266,8 @@ export async function reconcileAcpThreadBindingsOnStartup(params: { } if (params.healthProbe && probeTargets.length > 0) { - const probeResults = await pMap( - probeTargets, - async ({ binding, sessionKey, session }) => { + const { results: probeResults } = await runTasksWithConcurrency({ + tasks: probeTargets.map(({ binding, sessionKey, session }) => async () => { try { const result = await params.healthProbe?.({ cfg: params.cfg, @@ -288,12 +287,11 @@ export async function reconcileAcpThreadBindingsOnStartup(params: { status: "uncertain" satisfies AcpThreadBindingHealthStatus, }; } - }, - { - concurrency: ACP_STARTUP_HEALTH_PROBE_CONCURRENCY_LIMIT, - stopOnError: true, - }, - ); + }), + limit: ACP_STARTUP_HEALTH_PROBE_CONCURRENCY_LIMIT, + errorMode: "stop", + throwOnError: true, + }); for (const probeResult of probeResults) { if (probeResult.status === "stale") { diff --git a/extensions/google-meet/package.json b/extensions/google-meet/package.json index 27b475762cbe..8fc5f6774b10 100644 --- a/extensions/google-meet/package.json +++ b/extensions/google-meet/package.json @@ -9,7 +9,6 @@ "type": "module", "dependencies": { "jszip": "3.10.1", - "pretty-ms": "9.3.0", "typebox": "1.3.6" }, "devDependencies": { diff --git a/extensions/google-meet/src/cli-shared.ts b/extensions/google-meet/src/cli-shared.ts index d92bb7ace8a8..c8d5bd2b8ae4 100644 --- a/extensions/google-meet/src/cli-shared.ts +++ b/extensions/google-meet/src/cli-shared.ts @@ -12,7 +12,7 @@ import { parseStrictPositiveInteger, } from "openclaw/plugin-sdk/number-runtime"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; -import prettyMilliseconds from "pretty-ms"; +import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime"; import type { GoogleMeetCalendarLookupResult } from "./calendar.js"; import type { GoogleMeetModeInput, GoogleMeetTransport } from "./config.js"; import type { GoogleMeetRuntime } from "./runtime.js"; @@ -355,9 +355,8 @@ export function formatDuration(value: number | undefined): string { if (value === undefined) { return "n/a"; } - return prettyMilliseconds(Math.max(0, Math.round(value / 1000) * 1000), { - unitCount: 2, - }); + const roundedMs = Math.max(0, Math.round(value / 1000) * 1000); + return formatDurationCompact(roundedMs, { showYears: true, spaced: true }) ?? "0ms"; } export function writeDoctorStatus(status: Awaited>): void { diff --git a/extensions/memory-wiki/package.json b/extensions/memory-wiki/package.json index 5e4c70263cab..520321a049e0 100644 --- a/extensions/memory-wiki/package.json +++ b/extensions/memory-wiki/package.json @@ -6,7 +6,6 @@ "type": "module", "dependencies": { "mdast-util-from-markdown": "2.0.3", - "p-map": "7.0.6", "typebox": "1.3.6", "yaml": "2.9.0", "zod": "4.4.3" diff --git a/extensions/memory-wiki/src/import-runs-state.ts b/extensions/memory-wiki/src/import-runs-state.ts index bec2d12b5bf8..3007e9d84c1e 100644 --- a/extensions/memory-wiki/src/import-runs-state.ts +++ b/extensions/memory-wiki/src/import-runs-state.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import type { OpenKeyedStoreOptions, @@ -12,7 +13,6 @@ import { normalizeOptionalString, normalizeUniqueTrimmedStringList, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap, { pMapSkip } from "p-map"; import { walkMemoryWikiDirectory } from "./bounded-walk.js"; const LEGACY_IMPORT_RUN_READ_CONCURRENCY = 16; @@ -510,12 +510,16 @@ export async function readLegacyMemoryWikiImportRunRecords( } throw error; }); - return await pMap( - entries.filter((entry) => entry.kind === "file"), - async (entry) => { - const raw = await fs.readFile(path.join(importRunsDir, entry.relativePath), "utf8"); - return normalizeMemoryWikiImportRunRecord(JSON.parse(raw) as unknown) ?? pMapSkip; - }, - { concurrency: LEGACY_IMPORT_RUN_READ_CONCURRENCY, stopOnError: true }, - ); + const { results } = await runTasksWithConcurrency({ + tasks: entries + .filter((entry) => entry.kind === "file") + .map((entry) => async () => { + const raw = await fs.readFile(path.join(importRunsDir, entry.relativePath), "utf8"); + return normalizeMemoryWikiImportRunRecord(JSON.parse(raw) as unknown); + }), + limit: LEGACY_IMPORT_RUN_READ_CONCURRENCY, + errorMode: "stop", + throwOnError: true, + }); + return results.filter((record): record is ChatGptImportRunRecord => record !== null); } diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 7692df1d7ad2..d84fbdceb551 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { filterMemorySearchHitsBySessionVisibility } from "@openclaw/memory-core/api.js"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search"; @@ -10,7 +11,6 @@ import { normalizeLowercaseStringOrEmpty, uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap, { pMapSkip } from "p-map"; import type { OpenClawConfig } from "../api.js"; import { walkMemoryWikiDirectory } from "./bounded-walk.js"; import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js"; @@ -230,16 +230,18 @@ async function readQueryableWikiPagesByPaths( rootDir: string, files: string[], ): Promise { - return await pMap( - files, - async (relativePath) => { + const { results } = await runTasksWithConcurrency({ + tasks: files.map((relativePath) => async () => { const absolutePath = path.join(rootDir, relativePath); const raw = await fs.readFile(absolutePath, "utf8"); const summary = toWikiPageSummary({ absolutePath, relativePath, raw }); - return summary ? { ...summary, raw } : pMapSkip; - }, - { concurrency: QUERY_PAGE_READ_CONCURRENCY, stopOnError: true }, - ); + return summary ? { ...summary, raw } : null; + }), + limit: QUERY_PAGE_READ_CONCURRENCY, + errorMode: "stop", + throwOnError: true, + }); + return results.filter((page): page is QueryableWikiPage => page !== null); } async function readQueryDigestBundle( diff --git a/extensions/memory-wiki/src/unsafe-local.ts b/extensions/memory-wiki/src/unsafe-local.ts index 04c00d0f7a93..b2d15680c545 100644 --- a/extensions/memory-wiki/src/unsafe-local.ts +++ b/extensions/memory-wiki/src/unsafe-local.ts @@ -2,8 +2,8 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap from "p-map"; import { walkMemoryWikiDirectory } from "./bounded-walk.js"; import type { BridgeMemoryWikiResult } from "./bridge.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; @@ -253,9 +253,8 @@ export async function syncMemoryWikiUnsafeLocalSources( group: "unsafe-local", incomingCount: new Set([...artifacts.map((artifact) => artifact.syncKey), ...activeKeys]).size, }); - const results = await pMap( - artifacts, - async (artifact) => { + const { results } = await runTasksWithConcurrency({ + tasks: artifacts.map((artifact) => async () => { const stats = await fs.stat(artifact.absolutePath); activeKeys.add(artifact.syncKey); return await writeUnsafeLocalSourcePage({ @@ -265,9 +264,11 @@ export async function syncMemoryWikiUnsafeLocalSources( sourceSize: stats.size, state, }); - }, - { concurrency: UNSAFE_LOCAL_SYNC_CONCURRENCY, stopOnError: true }, - ); + }), + limit: UNSAFE_LOCAL_SYNC_CONCURRENCY, + errorMode: "stop", + throwOnError: true, + }); const removedCount = await pruneImportedSourceEntries({ vaultRoot: config.vault.path, diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index 36bc7ca80b5f..4e645e91b450 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -8,10 +8,7 @@ "@copilotkit/aimock": "1.37.4", "@modelcontextprotocol/sdk": "1.30.0", "@openclaw/crabline": "0.1.11", - "p-limit": "7.3.1", - "p-map": "7.0.6", "playwright-core": "1.62.1", - "pretty-ms": "9.3.0", "semver": "7.8.5", "ws": "8.21.1", "yaml": "2.9.0", diff --git a/extensions/qa-lab/src/character-eval.ts b/extensions/qa-lab/src/character-eval.ts index a49570947fc3..5bdb8032bd88 100644 --- a/extensions/qa-lab/src/character-eval.ts +++ b/extensions/qa-lab/src/character-eval.ts @@ -1,10 +1,10 @@ // Qa Lab plugin module implements character eval behavior. import fs from "node:fs/promises"; import path from "node:path"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap from "p-map"; -import prettyMilliseconds from "pretty-ms"; +import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime"; import { createQaArtifactRunId } from "./artifact-run-id.js"; import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js"; import { @@ -242,9 +242,7 @@ function formatDuration(ms: number) { return "unknown"; } const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 1000) * 1000; - return prettyMilliseconds(roundedMs, { - unitCount: 2, - }); + return formatDurationCompact(roundedMs, { showYears: true, spaced: true }) ?? "0ms"; } function logCharacterEvalProgress( @@ -518,9 +516,8 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { `start scenario=${scenarioId} candidates=${models.length} candidateConcurrency=${candidateConcurrency} output=${outputDir}`, ); const candidatesStartedAt = Date.now(); - const runs = await pMap( - models, - async (model, index) => { + const { results: runs } = await runTasksWithConcurrency({ + tasks: models.map((model, index) => async () => { const thinkingDefault = resolveCandidateThinkingDefault({ model, candidateThinkingDefault: params.candidateThinkingDefault, @@ -599,9 +596,11 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { ); return run; } - }, - { concurrency: candidateConcurrency, stopOnError: true }, - ); + }), + limit: candidateConcurrency, + errorMode: "stop", + throwOnError: true, + }); const failedCandidateCount = runs.filter((run) => run.status === "fail").length; logCharacterEvalProgress( params.progress, @@ -626,9 +625,8 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { `judges start judges=${judgeModels.length} judgeConcurrency=${judgeConcurrency} timeout=${formatDuration(judgeTimeoutMs)} labels=${params.judgeBlindModels === true ? "blind" : "visible"}`, ); const judgesStartedAt = Date.now(); - const judgments = await pMap( - judgeModels, - async (judgeModel, index) => { + const { results: judgments } = await runTasksWithConcurrency({ + tasks: judgeModels.map((judgeModel, index) => async () => { const judgeOptions = resolveJudgeOptions({ model: judgeModel, judgeThinkingDefault: params.judgeThinkingDefault, @@ -680,9 +678,11 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) { `judge done ${formatEvalIndex(index, judgeModels.length)} model=${judgeModel} rankings=${rankings.length} duration=${formatDuration(judgment.durationMs)}${judgeError ? ` error="${judgeError}"` : ""}`, ); return judgment; - }, - { concurrency: judgeConcurrency, stopOnError: true }, - ); + }), + limit: judgeConcurrency, + errorMode: "stop", + throwOnError: true, + }); const failedJudgeCount = judgments.filter((judgment) => judgment.rankings.length === 0).length; logCharacterEvalProgress( params.progress, diff --git a/extensions/qa-lab/src/evidence-gallery.ts b/extensions/qa-lab/src/evidence-gallery.ts index d2ff05002532..c8a67b11e47e 100644 --- a/extensions/qa-lab/src/evidence-gallery.ts +++ b/extensions/qa-lab/src/evidence-gallery.ts @@ -4,12 +4,12 @@ import os from "node:os"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { pathToFileURL } from "node:url"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { asNullableRecord as readRecord, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pLimit from "p-limit"; import type { QaEvidenceArtifactView, QaEvidenceGalleryEntryView, @@ -890,50 +890,59 @@ export async function buildQaEvidenceGalleryModel(params: { repoRoot, summaryEntries: summary.entries, }); - const limitArtifactView = pLimit(ARTIFACT_VIEW_CONCURRENCY); - const entries = await Promise.all( - summary.entries.map(async (entry, entryIndex): Promise => { - counts[entry.result.status] += 1; - const sanitizeEntryText = (value: string) => - sanitizeGalleryText(value, { + const artifactTasks = summary.entries.flatMap((entry, entryIndex) => + (entry.execution?.artifacts ?? []).map( + (artifact, artifactIndex) => () => + buildArtifactView({ + allowedArtifactFiles, + artifact, + artifactIndex, + evidenceDir, + entryIndex, extraRoots: [requestedRepoRoot], + hrefEvidencePath, repoRoot, - }); - return { - artifacts: await limitArtifactView.map( - entry.execution?.artifacts ?? [], - (artifact, artifactIndex) => - buildArtifactView({ - allowedArtifactFiles, - artifact, - artifactIndex, - evidenceDir, - entryIndex, - extraRoots: [requestedRepoRoot], - hrefEvidencePath, - repoRoot, - }), - ), - coverage: entry.coverage.map((coverage) => ({ - id: sanitizeEntryText(coverage.id), - role: sanitizeEntryText(coverage.role), - })), - failureReason: entry.result.failure?.reason - ? sanitizeEntryText(entry.result.failure.reason) - : null, - id: sanitizeEntryText(entry.test.id), - kind: sanitizeEntryText(entry.test.kind), - sourcePath: entry.test.source?.path - ? displayGalleryPath(entry.test.source.path, { - extraRoots: [requestedRepoRoot], - repoRoot, - }) - : null, - status: entry.result.status, - title: sanitizeEntryText(entry.test.title), - }; - }), + }), + ), ); + const { results: artifactViews } = await runTasksWithConcurrency({ + tasks: artifactTasks, + limit: ARTIFACT_VIEW_CONCURRENCY, + errorMode: "continue", + throwOnError: true, + }); + let artifactOffset = 0; + const entries = summary.entries.map((entry): QaEvidenceGalleryEntryView => { + counts[entry.result.status] += 1; + const artifactCount = entry.execution?.artifacts?.length ?? 0; + const artifacts = artifactViews.slice(artifactOffset, artifactOffset + artifactCount); + artifactOffset += artifactCount; + const sanitizeEntryText = (value: string) => + sanitizeGalleryText(value, { + extraRoots: [requestedRepoRoot], + repoRoot, + }); + return { + artifacts, + coverage: entry.coverage.map((coverage) => ({ + id: sanitizeEntryText(coverage.id), + role: sanitizeEntryText(coverage.role), + })), + failureReason: entry.result.failure?.reason + ? sanitizeEntryText(entry.result.failure.reason) + : null, + id: sanitizeEntryText(entry.test.id), + kind: sanitizeEntryText(entry.test.kind), + sourcePath: entry.test.source?.path + ? displayGalleryPath(entry.test.source.path, { + extraRoots: [requestedRepoRoot], + repoRoot, + }) + : null, + status: entry.result.status, + title: sanitizeEntryText(entry.test.title), + }; + }); return { counts, entries, diff --git a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.test.ts b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.test.ts index ec034bb5cb9a..7b7601ff2c2a 100644 --- a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.test.ts @@ -1,8 +1,9 @@ // Qa Lab tests cover Slack live adapter message reconciliation. -import type { FetchFunction } from "@slack/web-api"; + import { describe, expect, it } from "vitest"; import { createQaBusState } from "../../bus-state.js"; import { testing } from "./adapter.runtime.js"; +import type { SlackQaFetchFunction as FetchFunction } from "./slack-live.contracts.js"; describe("Slack live adapter reconciliation", () => { it("aborts an in-flight observer fetch when the adapter stops", async () => { diff --git a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts index 353fa8768f29..b35780797e4e 100644 --- a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts @@ -6,7 +6,6 @@ import { createSlackWriteClient, resolveSlackWebClientOptions, } from "@openclaw/slack/api.js"; -import type { FetchFunction } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { acquireDebugProxyCaptureStore } from "openclaw/plugin-sdk/proxy-capture"; @@ -22,7 +21,11 @@ import { parseSlackQaCredentialPayload, resolveSlackQaRuntimeEnv, } from "./slack-live.config.js"; -import type { SlackMessage, SlackQaRuntimeEnv } from "./slack-live.contracts.js"; +import type { + SlackMessage, + SlackQaFetchFunction, + SlackQaRuntimeEnv, +} from "./slack-live.contracts.js"; import { waitForSlackChannelStable } from "./slack-live.message-observations.js"; import { getSlackIdentity, @@ -33,6 +36,7 @@ import { type AdapterFactory = NonNullable; type FactoryContext = Parameters[0]; +type FetchFunction = SlackQaFetchFunction; type AdapterDefinition = Awaited>; const SLACK_POLL_INTERVAL_MS = 500; diff --git a/extensions/qa-lab/src/live-transports/slack/scenario-environment.ts b/extensions/qa-lab/src/live-transports/slack/scenario-environment.ts index a155929e7ff5..9f5b0191ddaa 100644 --- a/extensions/qa-lab/src/live-transports/slack/scenario-environment.ts +++ b/extensions/qa-lab/src/live-transports/slack/scenario-environment.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import type { WebClient } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import { @@ -14,6 +13,7 @@ import type { SlackQaScenarioContext, SlackQaScenarioMetadata, SlackQaScenarioRun, + SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; import { assertSlackCodexApprovalModelSupported } from "./slack-live.contracts.js"; import { waitForSlackChannelStable } from "./slack-live.message-observations.js"; diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.approvals.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.approvals.ts index 817c1e226fb7..fb67bf9a9e8b 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.approvals.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.approvals.ts @@ -1,6 +1,5 @@ // QA Lab Slack native approval observation and resolution. import { randomUUID } from "node:crypto"; -import type { WebClient } from "@slack/web-api"; import type { ChannelApprovalKind } from "openclaw/plugin-sdk/approval-handler-runtime"; import { assertApprovalDecisionResult } from "../shared/live-approval-result.js"; import { @@ -18,6 +17,7 @@ import { type SlackObservedMessage, type SlackApprovalArtifact, type SlackMessage, + type SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; import { listSlackMessages, diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.codex-approval.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.codex-approval.ts index 1c6a8c85bef5..627b93481907 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.codex-approval.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.codex-approval.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { listSlackReactions } from "@openclaw/slack/api.js"; -import type { WebClient } from "@slack/web-api"; import { extractGatewayMessageText } from "../../gateway-log-sentinel.js"; import { formatApprovalResultValue } from "../shared/live-approval-result.js"; import { asPlainRecord } from "./slack-live.config.js"; @@ -13,6 +12,7 @@ import { type SlackQaCodexApprovalScenarioRun, type SlackQaScenarioContext, type SlackQaScenarioMetadata, + type SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; export function resolveCodexFileApprovalTargetPath(token: string) { diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts index 68fc8eee2e2e..c8ee8bcca9d6 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts @@ -1,5 +1,4 @@ // QA Lab Slack credentials, instrumentation, and channel config. -import type { WebClient } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { asNonArrayRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { @@ -7,6 +6,7 @@ import { type SlackQaConfigOverrides, SLACK_QA_ENV_KEYS, slackQaCredentialPayloadSchema, + type SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)[number]) { diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.contracts.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.contracts.ts index 85a8f3ef5bc6..36a79687e44a 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.contracts.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.contracts.ts @@ -1,11 +1,17 @@ // QA Lab Slack live domain contracts and wire schemas. -import type { WebClient } from "@slack/web-api"; +import type { createSlackWebClient } from "@openclaw/slack/api.js"; import type { ChannelApprovalKind } from "openclaw/plugin-sdk/approval-handler-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { z } from "zod"; import type { startQaGatewayChild } from "../../gateway-child.js"; import { splitQaModelRef } from "../../model-selection.js"; +export type SlackQaWebClient = ReturnType; +export type SlackQaFetchFunction = NonNullable< + NonNullable[1]>["fetch"] +>; +type WebClient = SlackQaWebClient; + export type SlackQaRuntimeEnv = { channelId: string; driverBotToken: string; diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.message-observations.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.message-observations.ts index 5d1d3ff77ea0..2f0ff5867124 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.message-observations.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.message-observations.ts @@ -1,5 +1,4 @@ // QA Lab Slack scenario reply observation and channel readiness. -import type { WebClient } from "@slack/web-api"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import type { startQaGatewayChild } from "../../gateway-child.js"; @@ -11,6 +10,7 @@ import { type SlackAuthIdentity, type SlackObservedMessage, type SlackMessage, + type SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; import { listSlackMessages, diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.observations.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.observations.ts index 27738bfa21ec..246e05aec399 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.observations.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.observations.ts @@ -1,7 +1,6 @@ // QA Lab Slack Web API and stored-message observations. import { isDeepStrictEqual } from "node:util"; import { createSlackWebClient, sendSlackMessage } from "@openclaw/slack/api.js"; -import type { WebClient } from "@slack/web-api"; import { asPlainRecord, countSlackNativeDataBlocks, @@ -23,6 +22,7 @@ import { type SlackMessage, slackHistorySchema, slackRepliesSchema, + type SlackQaWebClient as WebClient, } from "./slack-live.contracts.js"; import { buildSlackInvalidBlocksTableProbe } from "./slack-live.invalid-blocks.js"; diff --git a/extensions/qa-lab/src/suite-planning.ts b/extensions/qa-lab/src/suite-planning.ts index 205af5bc7494..3875486f0123 100644 --- a/extensions/qa-lab/src/suite-planning.ts +++ b/extensions/qa-lab/src/suite-planning.ts @@ -1,8 +1,8 @@ // Qa Lab plugin module implements suite planning behavior. import path from "node:path"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap from "p-map"; import { createQaArtifactRunId } from "./artifact-run-id.js"; import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js"; import type { QaCliBackendAuthMode } from "./gateway-child.js"; @@ -430,9 +430,8 @@ async function mapQaSuiteWithConcurrency( } })(); } - const results = await pMap( - items, - async (item, index) => { + const { results } = await runTasksWithConcurrency({ + tasks: items.map((item, index) => async () => { if (stopped) { return undefined; } @@ -445,12 +444,11 @@ async function mapQaSuiteWithConcurrency( stopped = true; } return result; - }, - { - concurrency: Math.max(1, Math.floor(concurrency)), - stopOnError: true, - }, - ); + }), + limit: Math.max(1, Math.floor(concurrency)), + errorMode: "stop", + throwOnError: true, + }); const completed: U[] = []; for (const result of results) { if (result !== undefined) { diff --git a/extensions/qa-lab/web/src/ui-render-utils.ts b/extensions/qa-lab/web/src/ui-render-utils.ts index bf72eea52187..e6be7a426f63 100644 --- a/extensions/qa-lab/web/src/ui-render-utils.ts +++ b/extensions/qa-lab/web/src/ui-render-utils.ts @@ -1,4 +1,4 @@ -import prettyMilliseconds from "pretty-ms"; +import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime"; export function formatTime(timestamp: number) { return new Date(timestamp).toLocaleTimeString([], { @@ -23,9 +23,7 @@ export function formatIso(iso?: string) { export function formatDuration(ms: number): string { const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 1000) * 1000; - return prettyMilliseconds(Math.max(0, roundedMs), { - unitCount: 2, - }); + return formatDurationCompact(Math.max(0, roundedMs), { showYears: true, spaced: true }) ?? "0ms"; } export function esc(text: string) { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index e2f0cd43cd8e..f8c165fecde9 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -13,7 +13,6 @@ "@slack/types": "3.0.0", "@slack/web-api": "8.0.0", "get-east-asian-width": "1.6.0", - "p-map": "7.0.6", "typebox": "1.3.6", "undici": "7.29.0", "ws": "8.21.1", diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index a2dfbfa4c42d..1c1fcf22f6f4 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,6 +1,7 @@ // Slack plugin module implements media behavior. import fs from "node:fs/promises"; import type { WebClient as SlackWebClient } from "@slack/web-api"; +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime"; import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; @@ -8,7 +9,6 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import pMap, { pMapSkip } from "p-map"; import { formatSlackFileReference } from "../file-reference.js"; import type { SlackAttachment, SlackFile } from "../types.js"; import { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js"; @@ -332,9 +332,8 @@ export async function resolveSlackMedia(params: { const limitedFiles = files.length > MAX_SLACK_MEDIA_FILES ? files.slice(0, MAX_SLACK_MEDIA_FILES) : files; - const resolved = await pMap( - limitedFiles, - async (file): Promise => { + const { results } = await runTasksWithConcurrency({ + tasks: limitedFiles.map((file) => async (): Promise => { // Audio preflight keys the original event file object so admission can // reuse that exact download without turning this into a persistent cache. const preloaded = params.preloadedMedia?.get(file); @@ -344,7 +343,7 @@ export async function resolveSlackMedia(params: { const eventUrl = file.url_private_download ?? file.url_private; const url = eventUrl ?? (await fetchFreshSlackFileUrl({ file, client: params.client })); if (!url) { - return pMapSkip; + return null; } const result = await downloadSlackMediaFile({ file, @@ -357,12 +356,12 @@ export async function resolveSlackMedia(params: { govSlack, }).catch(() => null); if (result || !eventUrl) { - return result ?? pMapSkip; + return result; } const freshUrl = await fetchFreshSlackFileUrl({ file, client: params.client }); if (!freshUrl) { - return pMapSkip; + return null; } const retryResult = await downloadSlackMediaFile({ file, @@ -374,10 +373,13 @@ export async function resolveSlackMedia(params: { abortSignal: params.abortSignal, govSlack, }).catch(() => null); - return retryResult ?? pMapSkip; - }, - { concurrency: MAX_SLACK_MEDIA_CONCURRENCY, stopOnError: true }, - ); + return retryResult; + }), + limit: MAX_SLACK_MEDIA_CONCURRENCY, + errorMode: "stop", + throwOnError: true, + }); + const resolved = results.filter((result): result is SlackMediaResult => result !== null); return resolved.length > 0 ? resolved : null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26548f6bccc2..a4af8faeaba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -832,9 +832,6 @@ importers: mdast-util-from-markdown: specifier: 2.0.3 version: 2.0.3(supports-color@10.2.2) - p-map: - specifier: 7.0.6 - version: 7.0.6 typebox: specifier: 1.3.6 version: 1.3.6 @@ -994,9 +991,6 @@ importers: jszip: specifier: 3.10.1 version: 3.10.1 - pretty-ms: - specifier: 9.3.0 - version: 9.3.0 typebox: specifier: 1.3.6 version: 1.3.6 @@ -1286,9 +1280,6 @@ importers: mdast-util-from-markdown: specifier: 2.0.3 version: 2.0.3(supports-color@10.2.2) - p-map: - specifier: 7.0.6 - version: 7.0.6 typebox: specifier: 1.3.6 version: 1.3.6 @@ -1621,18 +1612,9 @@ importers: '@openclaw/crabline': specifier: 0.1.11 version: 0.1.11 - p-limit: - specifier: 7.3.1 - version: 7.3.1 - p-map: - specifier: 7.0.6 - version: 7.0.6 playwright-core: specifier: 1.62.1 version: 1.62.1 - pretty-ms: - specifier: 9.3.0 - version: 9.3.0 semver: specifier: 7.8.5 version: 7.8.5 @@ -1769,9 +1751,6 @@ importers: get-east-asian-width: specifier: 1.6.0 version: 1.6.0 - p-map: - specifier: 7.0.6 - version: 7.0.6 typebox: specifier: 1.3.6 version: 1.3.6 diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index d2b5685ce89e..6f9420dc97fe 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -2960,6 +2960,8 @@ describe("CLI attempt execution", () => { }); expect(embeddedArg.suppressLiveStreamOutput).toBe(false); + expect(embeddedArg.terminalReplyExpectation).toBe("optional"); + expect(embeddedArg.allowEmptyAssistantReplyAsSilent).toBe(true); }); it("forwards exact cron creator authority into embedded execution", async () => { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 3e411245c4a4..ab7d7d6ea72d 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -83,6 +83,7 @@ import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-lo import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js"; import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js"; +import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; import { isCliProvider } from "../model-selection.js"; import { resolveOpenAIRuntimeProvider } from "../openai-routing.js"; @@ -1138,6 +1139,8 @@ export function runAgentAttempt(params: { sandboxSessionKey: params.sessionKey, agentId: params.sessionAgentId, trigger: "user", + // Subagent lifecycle owns the stricter explicit visible/silent/empty evidence check. + terminalReplyExpectation: params.opts.lane === AGENT_LANE_SUBAGENT ? "optional" : undefined, messageChannel: params.messageChannel, messageProvider: params.opts.messageProvider ?? params.messageChannel, agentAccountId: params.runContext.accountId, @@ -1227,7 +1230,8 @@ export function runAgentAttempt(params: { modelRun: params.opts.modelRun, promptMode: params.opts.promptMode, disableTools, - allowEmptyAssistantReplyAsSilent: isSubagentAnnounceHandoff, + allowEmptyAssistantReplyAsSilent: + params.opts.lane === AGENT_LANE_SUBAGENT || isSubagentAnnounceHandoff, onAgentEvent: params.onAgentEvent, deferTerminalLifecycle: params.deferTerminalLifecycle, suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true, diff --git a/src/infra/format-time/format-duration.ts b/src/infra/format-time/format-duration.ts index 2bd1af432a88..874a2c1cb05a 100644 --- a/src/infra/format-time/format-duration.ts +++ b/src/infra/format-time/format-duration.ts @@ -9,6 +9,8 @@ export type FormatDurationSecondsOptions = { }; export type FormatDurationCompactOptions = { + /** Show year units instead of folding them into days. Default: false */ + showYears?: boolean; /** Add space between units: "2m 5s" instead of "2m5s". Default: false */ spaced?: boolean; }; @@ -64,7 +66,7 @@ export function formatDurationCompact( return prettyMilliseconds(roundedMs); } const formatted = prettyMilliseconds(Math.round(ms / 1000) * 1000, { - hideYear: true, + hideYear: options?.showYears !== true, unitCount: 2, }); return options?.spaced ? formatted : formatted.replaceAll(" ", ""); diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index 20ae710d520a..c469bfc5020f 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -61,6 +61,11 @@ describe("format-duration", () => { { input: 65000, options: { spaced: true }, expected: "1m 5s" }, { input: 3660000, options: { spaced: true }, expected: "1h 1m" }, { input: 90000000, options: { spaced: true }, expected: "1d 1h" }, + { + input: 366 * 86400000, + options: { showYears: true, spaced: true }, + expected: "1y 1d", + }, { input: 59500, expected: "1m" }, { input: 59400, expected: "59s" }, ])("formats compact duration for %j", ({ input, options, expected }) => { diff --git a/src/plugin-sdk/time-runtime.ts b/src/plugin-sdk/time-runtime.ts index 443d765b9938..0bc2a7086a97 100644 --- a/src/plugin-sdk/time-runtime.ts +++ b/src/plugin-sdk/time-runtime.ts @@ -6,3 +6,4 @@ export { formatZonedTimestamp, resolveTimezone, } from "../infra/format-time/format-datetime.js"; +export { formatDurationCompact } from "../infra/format-time/format-duration.js"; diff --git a/src/utils/run-with-concurrency.test.ts b/src/utils/run-with-concurrency.test.ts index dbe331f32bec..e5b7c3f2d845 100644 --- a/src/utils/run-with-concurrency.test.ts +++ b/src/utils/run-with-concurrency.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { runTasksWithConcurrency } from "./run-with-concurrency.js"; +function createDeferred() { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("runTasksWithConcurrency", () => { it("preserves task order with bounded worker count", async () => { let running = 0; @@ -107,4 +115,66 @@ describe("runTasksWithConcurrency", () => { expect(onTaskError).toHaveBeenNthCalledWith(1, firstErr, 0); expect(onTaskError).toHaveBeenNthCalledWith(2, secondErr, 2); }); + + it("rejects early and stops scheduling new work in stop mode", async () => { + const err = new Error("boom"); + const releaseInFlight = createDeferred(); + const inFlightSettled = createDeferred(); + const started: number[] = []; + const run = runTasksWithConcurrency({ + tasks: [ + async () => { + started.push(0); + await releaseInFlight.promise; + inFlightSettled.resolve(); + return 10; + }, + async () => { + started.push(1); + throw err; + }, + async () => { + started.push(2); + return 30; + }, + ], + limit: 2, + errorMode: "stop", + throwOnError: true, + }); + + await expect(run).rejects.toBe(err); + releaseInFlight.resolve(); + await inFlightSettled.promise; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(started).toEqual([0, 1]); + }); + + it("keeps scheduling after an early rejection in continue mode", async () => { + const err = new Error("boom"); + const completed = createDeferred(); + const started: number[] = []; + const run = runTasksWithConcurrency({ + tasks: [ + async () => { + started.push(0); + throw err; + }, + async () => { + started.push(1); + completed.resolve(); + return 20; + }, + ], + limit: 1, + errorMode: "continue", + throwOnError: true, + }); + + await expect(run).rejects.toBe(err); + await completed.promise; + expect(started).toEqual([0, 1]); + }); }); diff --git a/src/utils/run-with-concurrency.ts b/src/utils/run-with-concurrency.ts index d7ab87a218c0..96979c1dbc90 100644 --- a/src/utils/run-with-concurrency.ts +++ b/src/utils/run-with-concurrency.ts @@ -11,6 +11,8 @@ export type RunTasksWithConcurrencyOptions = { limit: number; /** `stop` prevents new work after the first failure; in-flight workers still settle. */ errorMode?: ConcurrencyErrorMode; + /** Reject immediately on a task failure instead of returning aggregate error state. */ + throwOnError?: boolean; /** Called once per failed task with the original task index. */ onTaskError?: (error: unknown, index: number) => void; }; @@ -29,7 +31,7 @@ export type RunTasksWithConcurrencyResult = { export async function runTasksWithConcurrency( params: RunTasksWithConcurrencyOptions, ): Promise> { - const { tasks, limit, onTaskError } = params; + const { tasks, limit, onTaskError, throwOnError = false } = params; const errorMode = params.errorMode ?? "continue"; if (tasks.length === 0) { return { results: [], firstError: undefined, hasError: false }; @@ -56,10 +58,17 @@ export async function runTasksWithConcurrency( hasError = true; } onTaskError?.(error, index); + if (throwOnError) { + throw error; + } } }), ); - await Promise.allSettled(runs); + if (throwOnError) { + await Promise.all(runs); + } else { + await Promise.allSettled(runs); + } return { results, firstError, hasError }; } diff --git a/tsdown.config.ts b/tsdown.config.ts index c2e8d32ed2c7..7d51706ee98f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -514,7 +514,7 @@ function shouldExternalizeGatewayProtocolDependency(id: string): boolean { } function shouldExternalizeGatewayClientDependency(id: string): boolean { - return ["ws", "@openclaw/gateway-protocol"].some( + return ["ws", "@openclaw/gateway-protocol", "ipaddr.js"].some( (dependency) => id === dependency || id.startsWith(`${dependency}/`), ); }