From 1a5325f03583cbf6324b8be4575b8a88d3faf2ec Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Sat, 26 Sep 2026 22:38:09 -0700 Subject: [PATCH] refactor: remove narrowly used helper dependencies (#158573) * refactor: remove narrowly used helper dependencies Replace markdown-it-task-lists and grammY runner sequencing with their local owners. Remove direct ms/pretty-ms usage and share the existing duration factors and formatting. Preserve rendering and parsing contracts, and keep overlapping Telegram lanes ordered when a predecessor fails. Remove obsolete mocks, declarations, dependency metadata, and build references; regenerate the lockfile and Control UI boot manifest. Deduplicate an inherited over-limit replay test without changing coverage. Closes #158499 Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: f4ef665a-7e2b-47ce-a454-adc19e798bee * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: abec5219-5bd0-4480-8604-a57c1063f638 * fix(ci): preserve base-only dependencies in UI comparison Materialize removed dependency declarations from the archived base lockfile before overlaying the candidate toolchain. Keep both builds on the same Vite and compressor without restoring removed application dependencies. Extend the real build regression with a dependency absent from the candidate and verify the original comparator fails to resolve it. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 2db2eea4-87fe-4071-ab37-28da02f1bd24 * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 8c381a6d-38f2-417f-85ee-9353cb5694dc --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- config/assertion-safety-baseline.txt | 2 - config/knip.config.ts | 1 - extensions/telegram/package.json | 1 - extensions/telegram/src/bot-core.ts | 6 +- .../bot.create-telegram-bot.test-harness.ts | 31 +--- .../src/bot.create-telegram-bot.test.ts | 100 ++++++------- .../src/bot.media.e2e.test-harness.ts | 1 - extensions/telegram/src/bot.runtime.ts | 1 - extensions/telegram/src/sequentialize.test.ts | 140 ++++++++++++++++++ extensions/telegram/src/sequentialize.ts | 31 ++++ package.json | 4 - pnpm-lock.yaml | 34 ----- scripts/audit-control-ui-dead-css.mts | 7 - scripts/build-all.mts | 4 +- scripts/check-control-ui-performance-base.mts | 42 +++++- scripts/e2e/parallels/npm-update-smoke.ts | 13 +- scripts/lib/dependency-ownership.json | 15 -- scripts/lib/format-duration.mts | 46 ++++++ scripts/pr-lib/materialize-dependencies.mjs | 1 - scripts/pr-lib/wrapper-components.txt | 4 + scripts/run-additional-boundary-checks.mts | 4 +- src/agents/failover/retry-evidence.test.ts | 103 +++++++++++++ src/agents/failover/retry-evidence.ts | 40 +++-- src/cli/cli-utils.test.ts | 56 ++++++- src/cli/parse-duration.ts | 16 +- src/infra/format-time/duration-units.ts | 9 ++ .../format-time/format-duration-exact.ts | 3 +- .../format-time/format-duration-internal.ts | 30 ++-- src/infra/format-time/format-duration.ts | 3 +- src/infra/format-time/format-time.test.ts | 37 ++++- test/scripts/build-all.test.ts | 6 + .../control-ui-performance-base.test.ts | 41 ++++- test/scripts/format-duration.test.ts | 41 +++++ test/scripts/pr-wrapper.test-support.ts | 1 - test/scripts/tsdown-declaration-fixture.ts | 4 +- test/tsconfig/tsconfig.core.test.shard.json | 1 - ui/config/control-ui-boot-modules.json | 5 +- ui/config/control-ui-chunking.ts | 1 - ui/package.json | 1 - .../markdown-assistant-transcript.ts | 2 +- ui/src/components/markdown-parser.ts | 49 +++--- ui/src/components/markdown.test.ts | 86 ++++++++++- ui/src/markdown-it-task-lists.d.ts | 11 -- ui/src/test-helpers/control-ui-e2e.ts | 7 +- ui/vite.config.ts | 7 +- ui/vitest.config.ts | 2 - 46 files changed, 770 insertions(+), 280 deletions(-) create mode 100644 extensions/telegram/src/sequentialize.test.ts create mode 100644 extensions/telegram/src/sequentialize.ts create mode 100644 scripts/lib/format-duration.mts create mode 100644 src/agents/failover/retry-evidence.test.ts create mode 100644 src/infra/format-time/duration-units.ts create mode 100644 test/scripts/format-duration.test.ts delete mode 100644 ui/src/markdown-it-task-lists.d.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 556bebf018d9..d5fcb7ff62de 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1592,7 +1592,6 @@ src/agents/exec-auto-reviewer.ts 1 src/agents/exec-defaults.ts 2 src/agents/execution-auth-binding.ts 1 src/agents/failover-error.ts 3 -src/agents/failover/retry-evidence.ts 1 src/agents/fallback-skip-cache.ts 1 src/agents/fast-mode.ts 1 src/agents/harness/builtin-openclaw.ts 2 @@ -2028,7 +2027,6 @@ src/cli/nodes-cli/rpc.ts 2 src/cli/one-shot-exit.ts 2 src/cli/output-file.runtime.ts 1 src/cli/pairing-cli.ts 1 -src/cli/parse-duration.ts 1 src/cli/plugins-authoring-command.ts 7 src/cli/plugins-command-helpers.ts 1 src/cli/plugins-update-command.ts 5 diff --git a/config/knip.config.ts b/config/knip.config.ts index 0d9fc96c6762..3c28748e4c01 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -506,7 +506,6 @@ const bundledPluginIgnoredRuntimeDependencies = [ const rootBundledPluginRuntimeDependencies = [ "@anthropic-ai/sdk", "@google/genai", - "@grammyjs/runner", "@grammyjs/transformer-throttler", "@homebridge/ciao", "@mozilla/readability", diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 4bdc7de61903..6993ed4e472f 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -5,7 +5,6 @@ "description": "OpenClaw Telegram channel plugin", "type": "module", "dependencies": { - "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "abort-controller": "3.0.0", "grammy": "1.46.0", diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 1d0ea01e86a6..f2699dba5dc4 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -47,7 +47,7 @@ import { } from "./bot-processing-outcome.js"; import { createTelegramUpdateTracker } from "./bot-update-tracker.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; -import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.runtime.js"; +import { apiThrottler, Bot, type ApiClientOptions } from "./bot.runtime.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { setTelegramCallbackQueryAnswerPromise, @@ -69,7 +69,7 @@ import { } from "./poll-answer-context.js"; import { formatTelegramRawUpdateForLog } from "./raw-update-log.js"; import type { TelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; -import { getTelegramSequentialConstraints } from "./sequential-key.js"; +import { createTelegramSequentializer } from "./sequentialize.js"; import { createTelegramThreadBindingManager } from "./thread-bindings.js"; export async function createTelegramBotCore( @@ -240,7 +240,7 @@ export async function createTelegramBotCore( await next(); }); - bot.use(sequentialize(getTelegramSequentialConstraints)); + bot.use(createTelegramSequentializer()); // A fast vote can know its route before outbound verification finishes. Hold // only that route's sequential lane until registration succeeds or declines it. diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 392301cef9a7..9c1e76ee00a0 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -321,18 +321,10 @@ function getRichMessageText(params: RichMessageParams): string { return rich.markdown ?? rich.html ?? ""; } -const runnerHoisted = vi.hoisted(() => ({ - sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { - if (typeof next === "function") { - await next(); - } - }), - sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware), +const throttlerHoisted = vi.hoisted(() => ({ throttlerSpy: vi.fn(() => "throttler"), })); -export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; -export let sequentializeKey: ((ctx: unknown) => string | string[] | undefined) | undefined; -export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; +export const throttlerSpy: AnyMock = throttlerHoisted.throttlerSpy; const telegramBotRuntimeForTest = { Bot: class { api = { @@ -402,17 +394,9 @@ const telegramBotRuntimeForTest = { ); } } as unknown as TelegramBotRuntimeForTest["Bot"], - sequentialize: ((keyFn: (ctx: unknown) => string | string[] | undefined) => { - sequentializeKey = keyFn; - return ( - runnerHoisted.sequentializeSpy as unknown as () => ReturnType< - TelegramBotRuntimeForTest["sequentialize"] - > - )(); - }) as unknown as TelegramBotRuntimeForTest["sequentialize"], apiThrottler: (() => ( - runnerHoisted.throttlerSpy as unknown as () => unknown + throttlerHoisted.throttlerSpy as unknown as () => unknown )()) as unknown as TelegramBotRuntimeForTest["apiThrottler"], }; export const telegramBotDepsForTest: TelegramBotDeps = { @@ -568,14 +552,5 @@ beforeEach(() => { modelCatalog: [{ provider: "openai", id: "gpt-5.4", name: "GPT-5.4", reasoning: false }], }); middlewareUseSpy.mockReset(); - runnerHoisted.sequentializeMiddleware.mockReset(); - runnerHoisted.sequentializeMiddleware.mockImplementation(async (_ctx, next) => { - if (typeof next === "function") { - await next(); - } - }); - sequentializeSpy.mockReset(); - sequentializeSpy.mockImplementation(() => runnerHoisted.sequentializeMiddleware); botCtorSpy.mockReset(); - sequentializeKey = undefined; }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 0fb9f47be52a..1ef5a892eeb5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -69,7 +69,6 @@ const { onSpy, replySpy, sendMessageSpy, - sequentializeSpy, telegramBotDepsForTest, throttlerSpy, useSpy, @@ -209,11 +208,6 @@ async function dispatchSpooledNativeStop( } async function setupUpdateOffsetTracker(params: { lastUpdateId: number }) { - sequentializeSpy.mockImplementationOnce( - () => async (_ctx: unknown, next: () => Promise) => { - await next(); - }, - ); const onUpdateId = vi.fn<(updateId: number) => void | Promise>(); await createTelegramBot({ token: "tok", @@ -233,34 +227,6 @@ async function runTelegramMiddlewareChain(params: { await runTelegramTestMiddlewareChain(middlewareUseSpy, params.ctx, params.finalHandler); } -function installPerKeySequentializer(): void { - sequentializeSpy.mockImplementationOnce(() => { - const lanes = new Map>(); - return async (ctx: TelegramMiddlewareTestContext, next: () => Promise) => { - const constraint = harness.sequentializeKey?.(ctx) ?? "default"; - const keys = Array.isArray(constraint) ? constraint : [constraint]; - const previous = Promise.all(keys.map((key) => lanes.get(key) ?? Promise.resolve())); - const current = previous.then(async () => { - await next(); - }); - const tracked = current.catch(() => undefined); - for (const key of keys) { - lanes.set(key, tracked); - } - - try { - await current; - } finally { - for (const key of keys) { - if (lanes.get(key) === tracked) { - lanes.delete(key); - } - } - } - }; - }); -} - async function withTelegramSpooledReplayUpdate( update: object, fn: () => Promise, @@ -386,6 +352,48 @@ describe("createTelegramBot", () => { expectBotClientFields({ apiRoot: "https://api.telegram.org" }); }); + it("acknowledges callbacks before waiting for their chat's active message handler", async () => { + await createTelegramBot({ token: "tok" }); + const started = createDeferred(); + const release = createDeferred(); + const callbackHandler = vi.fn(async () => {}); + const message = runTelegramMiddlewareChain({ + ctx: makePrivateTextContext({ updateId: 1, messageId: 1, chatId: 7, text: "first" }), + finalHandler: async () => { + started.resolve(); + await release.promise; + }, + }); + const runs = [message]; + + try { + await started.promise; + const callback = runTelegramMiddlewareChain({ + ctx: createTelegramCallbackContext({ + id: "queued-callback", + data: "ordinary-action", + updateId: 2, + message: { chat: { id: 7, type: "private" } }, + }), + finalHandler: callbackHandler, + }); + runs.push(callback); + await runTelegramMiddlewareChain({ + ctx: makePrivateTextContext({ updateId: 3, messageId: 3, chatId: 8, text: "other chat" }), + finalHandler: async () => {}, + }); + + expect(answerCallbackQuerySpy).toHaveBeenCalledExactlyOnceWith("queued-callback"); + expect(callbackHandler).not.toHaveBeenCalled(); + release.resolve(); + await Promise.all(runs); + expect(callbackHandler).toHaveBeenCalledOnce(); + } finally { + release.resolve(); + await Promise.allSettled(runs); + } + }); + it("keeps poll registry preparation failures retryable during durable replay", async () => { const readError = new Error("poll registry unavailable"); const openKeyedStore: TelegramRuntime["state"]["openKeyedStore"] = () => ({ @@ -490,7 +498,6 @@ describe("createTelegramBot", () => { it("preserves same-chat reply order when a debounced run is still active", async () => { configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const startedBodies: string[] = []; @@ -567,7 +574,6 @@ describe("createTelegramBot", () => { }; loadConfig.mockReturnValue(initialConfig); setRuntimeConfigSnapshot(initialConfig, initialConfig); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); const sourceWork: Promise[] = []; @@ -644,7 +650,6 @@ describe("createTelegramBot", () => { agents: { defaults: { envelopeTimezone: "utc" } }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "performance", "setTimeout", "clearTimeout"] }); vi.setSystemTime(1736380800000); replySpy.mockResolvedValue(undefined); @@ -717,7 +722,6 @@ describe("createTelegramBot", () => { it("cancels an expired fragment behind an earlier active message before releasing that message", async () => { configureOpenDm({ debounceMs: 3000, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); const earlierStarted = createDeferred(); const releaseEarlier = createDeferred(); @@ -781,8 +785,6 @@ describe("createTelegramBot", () => { async (stopText) => { configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "userTimezone" }); - installPerKeySequentializer(); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const startedBodies: string[] = []; let reusedWork: Promise | undefined; @@ -842,7 +844,6 @@ describe("createTelegramBot", () => { messages: { inbound: { byChannel: { telegram: 3000 } } }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); const sourceWork: Promise[] = []; @@ -913,7 +914,6 @@ describe("createTelegramBot", () => { }, }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); let sourceWork: Promise | undefined; @@ -970,7 +970,6 @@ describe("createTelegramBot", () => { }, }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); const sourceWork: Promise[] = []; @@ -1037,7 +1036,6 @@ describe("createTelegramBot", () => { messages: { inbound: { byChannel: { telegram: 3000 } } }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); let sourceWork: Promise | undefined; @@ -1081,7 +1079,6 @@ describe("createTelegramBot", () => { it("stop cancels ordinary and forwarded batches queued behind an active turn", async () => { configureOpenDm({ debounceMs: 3000, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); const attachmentPath = path.join( requireValue(process.env.OPENCLAW_STATE_DIR, "test state directory"), "caption.txt", @@ -1198,7 +1195,6 @@ describe("createTelegramBot", () => { messages: { inbound: { byChannel: { telegram: 3000 } } }, channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } } } }, }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); const preparationStarted = createDeferred(); @@ -1256,7 +1252,6 @@ describe("createTelegramBot", () => { it("keeps separate text-batch replay settlements isolated when the next batch fails", async () => { configureOpenDm({ debounceMs: 300, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); const secondDispatchError = new Error("next batch failed before adoption"); replySpy.mockResolvedValueOnce(undefined).mockRejectedValueOnce(secondDispatchError); @@ -1299,7 +1294,6 @@ describe("createTelegramBot", () => { it("retries deferred adoption after durable commit fails without settling buffered participants", async () => { configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const commitError = new Error("durable dispatch commit failed"); const commitSpy = vi @@ -1371,7 +1365,6 @@ describe("createTelegramBot", () => { it("serializes timeout settlement behind an in-flight durable adoption commit", async () => { configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); let markCommitStarted: (() => void) | undefined; let releaseCommit: (() => void) | undefined; @@ -1471,7 +1464,6 @@ describe("createTelegramBot", () => { it("blocks buffered adoption after an exposed replay participant times out", async () => { configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" }); - installPerKeySequentializer(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const commitSpy = vi.spyOn(messageDispatchDedupe, "commitTelegramMessageDispatchReplay"); let queuedLifecycle: GetReplyOptions["turnAdoptionLifecycle"]; @@ -1693,8 +1685,6 @@ describe("createTelegramBot", () => { }, }); - installPerKeySequentializer(); - vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); replySpy.mockResolvedValue(undefined); let pendingWork: Promise | undefined; @@ -3671,12 +3661,6 @@ describe("createTelegramBot", () => { }); it("treats permanent command pagination edit failures as completed updates", async () => { - sequentializeSpy.mockImplementationOnce( - () => async (_ctx: unknown, next: () => Promise) => { - await next(); - }, - ); - const onUpdateId = vi.fn(); await createTelegramBot({ token: "tok", diff --git a/extensions/telegram/src/bot.media.e2e.test-harness.ts b/extensions/telegram/src/bot.media.e2e.test-harness.ts index b48e6ab31c3c..867d0b7962db 100644 --- a/extensions/telegram/src/bot.media.e2e.test-harness.ts +++ b/extensions/telegram/src/bot.media.e2e.test-harness.ts @@ -171,7 +171,6 @@ const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { catch = vi.fn(); constructor(public token: string) {} } as unknown as TelegramBotRuntimeForTest["Bot"], - sequentialize: (() => vi.fn()) as TelegramBotRuntimeForTest["sequentialize"], apiThrottler: (() => throttlerSpy()) as unknown as TelegramBotRuntimeForTest["apiThrottler"], }; diff --git a/extensions/telegram/src/bot.runtime.ts b/extensions/telegram/src/bot.runtime.ts index edcb833c3697..c52a938173bf 100644 --- a/extensions/telegram/src/bot.runtime.ts +++ b/extensions/telegram/src/bot.runtime.ts @@ -1,4 +1,3 @@ -export { sequentialize } from "@grammyjs/runner"; export { apiThrottler } from "@grammyjs/transformer-throttler"; export { Bot } from "grammy"; export type { ApiClientOptions } from "grammy"; diff --git a/extensions/telegram/src/sequentialize.test.ts b/extensions/telegram/src/sequentialize.test.ts new file mode 100644 index 000000000000..1f8fc8424fa2 --- /dev/null +++ b/extensions/telegram/src/sequentialize.test.ts @@ -0,0 +1,140 @@ +import { Bot, type MiddlewareFn } from "grammy"; +import type { Chat, Update } from "grammy/types"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { describe, expect, it } from "vitest"; +import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; +import { createTelegramSequentializer } from "./sequentialize.js"; + +const user = { id: 42, is_bot: false, first_name: "Ada" }; + +const chat: Chat.SupergroupChat = { + id: -1001, + type: "supergroup", + title: "Sequencing", + is_forum: true, +}; + +function topicMessage(updateId: number, messageId: number, topicId: number): Update { + return { + update_id: updateId, + message: { + chat, + from: user, + message_id: messageId, + message_thread_id: topicId, + is_topic_message: true, + date: 1, + text: "message", + }, + }; +} + +function reaction(updateId: number, messageId: number): Update { + return { + update_id: updateId, + message_reaction: { + chat, + message_id: messageId, + date: 1, + user, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }; +} + +function createSequencedBot(handler: MiddlewareFn) { + const bot = new Bot("123:test", { botInfo: telegramBotInfoForTest }); + bot.use(createTelegramSequentializer()); + bot.use(handler); + return bot; +} + +describe("Telegram sequential middleware", () => { + it("reserves overlapping lanes in FIFO order without blocking other topics or deleting newer tails", async () => { + const firstGate = createDeferred(); + const secondGate = createDeferred(); + const secondStarted = createDeferred(); + const started: number[] = []; + const bot = createSequencedBot(async (ctx) => { + const id = ctx.update.update_id; + started.push(id); + if (id === 1) { + await firstGate.promise; + } else if (id === 2) { + secondStarted.resolve(); + await secondGate.promise; + } + }); + const first = bot.handleUpdate(topicMessage(1, 77, 9)); + const second = bot.handleUpdate(topicMessage(2, 78, 9)); + const queuedReaction = bot.handleUpdate(reaction(3, 78)); + const unrelated = bot.handleUpdate(topicMessage(4, 79, 10)); + const runs = [first, second, queuedReaction, unrelated]; + + try { + await unrelated; + expect(started).toEqual([1, 4]); + + firstGate.resolve(); + await first; + await secondStarted.promise; + const third = bot.handleUpdate(topicMessage(5, 80, 9)); + const anotherTopic = bot.handleUpdate(topicMessage(6, 81, 10)); + runs.push(third, anotherTopic); + await anotherTopic; + expect(started).toEqual([1, 4, 2, 6]); + + secondGate.resolve(); + await Promise.all(runs); + expect(started.slice(4).toSorted((left, right) => left - right)).toEqual([3, 5]); + } finally { + firstGate.resolve(); + secondGate.resolve(); + await Promise.allSettled(runs); + } + }); + + it.each(["throw", "reject"])( + "recovers from a handler %s without releasing a different unfinished constraint", + async (failure) => { + const gate = createDeferred(); + const error = new Error("reaction failed"); + const started: number[] = []; + const bot = createSequencedBot((ctx) => { + const id = ctx.update.update_id; + started.push(id); + if (id === 1) { + return gate.promise; + } + if (id === 2) { + if (failure === "throw") { + throw error; + } + return Promise.reject(error); + } + return Promise.resolve(); + }); + const topic = bot.handleUpdate(topicMessage(1, 76, 9)); + const failedReaction = bot.handleUpdate(reaction(2, 77)); + const rejection = expect(failedReaction).rejects.toMatchObject({ error }); + const joined = bot.handleUpdate(topicMessage(3, 77, 9)); + const runs = [topic, failedReaction, joined]; + + try { + await rejection; + await bot.handleUpdate(topicMessage(4, 78, 10)); + expect(started).toEqual([1, 2, 4]); + + gate.resolve(); + await Promise.all([topic, joined]); + expect(started).toEqual([1, 2, 4, 3]); + await bot.handleUpdate(topicMessage(5, 77, 9)); + expect(started).toEqual([1, 2, 4, 3, 5]); + } finally { + gate.resolve(); + await Promise.allSettled(runs); + } + }, + ); +}); diff --git a/extensions/telegram/src/sequentialize.ts b/extensions/telegram/src/sequentialize.ts new file mode 100644 index 000000000000..2be278b15de3 --- /dev/null +++ b/extensions/telegram/src/sequentialize.ts @@ -0,0 +1,31 @@ +import { getTelegramSequentialConstraints } from "./sequential-key.js"; + +export function createTelegramSequentializer() { + const tails = new Map>(); + return async ( + ctx: Parameters[0], + next: () => Promise, + ): Promise => { + const constraints = getTelegramSequentialConstraints(ctx); + const keys = Array.isArray(constraints) ? constraints : [constraints]; + const previous = keys.map((key) => tails.get(key)).filter((tail) => tail !== undefined); + const task = Promise.all(previous).then(next); + const tail = task.then( + () => undefined, + () => undefined, + ); + // Reserve all keys before yielding, including keys whose previous work is still waiting. + for (const key of keys) { + tails.set(key, tail); + } + try { + await task; + } finally { + for (const key of keys) { + if (tails.get(key) === tail) { + tails.delete(key); + } + } + } + }; +} diff --git a/package.json b/package.json index bbe18fef68ae..e61921bd7b1a 100644 --- a/package.json +++ b/package.json @@ -2260,7 +2260,6 @@ "@clack/prompts": "1.8.1", "@earendil-works/pi-tui": "0.85.1", "@google/genai": "2.23.0", - "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.12", "@lydell/node-pty": "1.2.0-beta.15", @@ -2299,14 +2298,12 @@ "kysely": "0.29.6", "linkedom": "0.18.13", "minimatch": "10.2.6", - "ms": "2.1.3", "node-edge-tts": "1.2.10", "openai": "7.18.0", "p-limit": "7.3.2", "p-map": "7.0.8", "partial-json": "0.1.7", "playwright-core": "1.63.0", - "pretty-ms": "9.3.1", "qrcode": "1.5.4", "rastermill": "0.3.3", "semver": "7.8.5", @@ -2342,7 +2339,6 @@ "@types/jsdom": "30.0.0", "@types/markdown-it": "14.2.0", "@types/mdast": "4.0.4", - "@types/ms": "2.1.0", "@types/node": "26.6.1", "@types/semver": "7.8.0", "@types/web-push": "3.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2b96b19e79d..4efbaa5a9ce7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,9 +251,6 @@ importers: '@google/genai': specifier: 2.23.0 version: 2.23.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.6.5))(supports-color@10.2.2) - '@grammyjs/runner': - specifier: 2.0.3 - version: 2.0.3(grammy@1.46.0(supports-color@10.2.2)) '@grammyjs/transformer-throttler': specifier: 1.2.1 version: 1.2.1(grammy@1.46.0(supports-color@10.2.2)) @@ -368,9 +365,6 @@ importers: minimatch: specifier: 10.2.6 version: 10.2.6 - ms: - specifier: 2.1.3 - version: 2.1.3 node-edge-tts: specifier: 1.2.10 version: 1.2.10(supports-color@10.2.2) @@ -389,9 +383,6 @@ importers: playwright-core: specifier: 1.63.0 version: 1.63.0 - pretty-ms: - specifier: 9.3.1 - version: 9.3.1 qrcode: specifier: 1.5.4 version: 1.5.4 @@ -492,9 +483,6 @@ importers: '@types/mdast': specifier: 4.0.4 version: 4.0.4 - '@types/ms': - specifier: 2.1.0 - version: 2.1.0 '@types/node': specifier: 26.6.1 version: 26.6.1 @@ -2240,9 +2228,6 @@ importers: extensions/telegram: dependencies: - '@grammyjs/runner': - specifier: 2.0.3 - version: 2.0.3(grammy@1.46.0(supports-color@10.2.2)) '@grammyjs/transformer-throttler': specifier: 1.2.1 version: 1.2.1(grammy@1.46.0(supports-color@10.2.2)) @@ -2905,9 +2890,6 @@ importers: markdown-it-emoji: specifier: 3.1.0 version: 3.1.0 - markdown-it-task-lists: - specifier: 2.1.1 - version: 2.1.1 parse5: specifier: 8.0.1 version: 8.0.1 @@ -3904,12 +3886,6 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@grammyjs/runner@2.0.3': - resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} - engines: {node: '>=12.20.0 || >=14.13.1'} - peerDependencies: - grammy: ^1.13.1 - '@grammyjs/transformer-throttler@1.2.1': resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==} engines: {node: ^12.20.0 || >=14.13.1} @@ -8221,9 +8197,6 @@ packages: markdown-it-emoji@3.1.0: resolution: {integrity: sha512-NhmMEH2ywduD4Nty1E8uB5NqfLhAT1VR0dyvoJyStKOqCzbZmVdn/+8wj7zpDsb/fLBikpCPsWwxqKlvMmbz4g==} - markdown-it-task-lists@2.1.1: - resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} - markdown-it@15.0.2: resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==} hasBin: true @@ -11416,11 +11389,6 @@ snapshots: - supports-color - utf-8-validate - '@grammyjs/runner@2.0.3(grammy@1.46.0(supports-color@10.2.2))': - dependencies: - abort-controller: 3.0.0 - grammy: 1.46.0(supports-color@10.2.2) - '@grammyjs/transformer-throttler@1.2.1(grammy@1.46.0(supports-color@10.2.2))': dependencies: bottleneck: 2.19.5 @@ -15820,8 +15788,6 @@ snapshots: markdown-it-emoji@3.1.0: {} - markdown-it-task-lists@2.1.1: {} - markdown-it@15.0.2: dependencies: argparse: 3.0.2 diff --git a/scripts/audit-control-ui-dead-css.mts b/scripts/audit-control-ui-dead-css.mts index 64c15b0dfd58..e7b27028c5fd 100644 --- a/scripts/audit-control-ui-dead-css.mts +++ b/scripts/audit-control-ui-dead-css.mts @@ -45,13 +45,6 @@ const EXTERNAL_CLASS_FAMILIES: ExternalClassFamily[] = [ matches: (className) => className.startsWith("ProseMirror"), producer: "ProseMirror editor DOM", }, - // markdown-it-task-lists emits these two classes from parsed markdown. - { - matches: (className) => - className === "task-list-item" || className === "task-list-item-checkbox", - producer: - "markdown-it-task-lists via ui/src/components/markdown-parser.ts (the contains-task-list class is removed there)", - }, ]; type SourceReferences = { diff --git a/scripts/build-all.mts b/scripts/build-all.mts index b5f4478d5990..f87bb44e9178 100644 --- a/scripts/build-all.mts +++ b/scripts/build-all.mts @@ -3,7 +3,6 @@ import type { SpawnSyncOptions } from "node:child_process"; import { performance } from "node:perf_hooks"; -import prettyMilliseconds from "pretty-ms"; import { resolveNodeRuntimeExecutable } from "../src/infra/node-runtime-executable.ts"; import { finalizeBuildStepCache, @@ -17,6 +16,7 @@ import { distArtifactEntryArgs, withDistArtifactOwnership, } from "./lib/dist-artifact-ownership.mts"; +import { formatDurationElapsed } from "./lib/format-duration.mts"; import { runManagedCommand } from "./lib/managed-child-process.mts"; import type { MemoryLimitParams } from "./lib/process-memory.mts"; import { preflightInstalledSourceArtifacts } from "./lib/source-update-artifact-preflight.mts"; @@ -492,7 +492,7 @@ export function formatBuildAllDuration(durationMs: number) { : clampedMs < 10_000 ? Math.round(clampedMs / 10) * 10 : Math.round(clampedMs / 100) * 100; - return prettyMilliseconds(roundedMs, { + return formatDurationElapsed(roundedMs, { secondsDecimalDigits: clampedMs < 10_000 ? 2 : 1, }); } diff --git a/scripts/check-control-ui-performance-base.mts b/scripts/check-control-ui-performance-base.mts index 5a1f850db02c..f34ebaa0bb49 100644 --- a/scripts/check-control-ui-performance-base.mts +++ b/scripts/check-control-ui-performance-base.mts @@ -8,6 +8,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createControlUiPrecompressedAssetVariants } from "../ui/vite.config.ts"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const require = createRequire(path.join(repoRoot, "ui/package.json")); @@ -42,6 +43,29 @@ function resolveCommit(ref: string): string { return result.stdout.trim(); } +function declaredDependencyNames(root: string): string[] { + const file = path.join(root, "package.json"); + if (!fs.existsSync(file)) { + return []; + } + const manifest: unknown = JSON.parse(fs.readFileSync(file, "utf8")); + if (!isRecord(manifest)) { + throw new Error(`Invalid package manifest: ${file}`); + } + const names: string[] = []; + for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) { + const entries = manifest[key]; + if (entries === undefined) { + continue; + } + if (!isRecord(entries)) { + throw new Error(`Invalid ${key} in package manifest: ${file}`); + } + names.push(...Object.keys(entries)); + } + return names; +} + function linkDependencies(baseRoot: string): void { const roots = ["", "ui"]; for (const parent of ["packages", "extensions"]) { @@ -51,6 +75,18 @@ function linkDependencies(baseRoot: string): void { } } } + if ( + roots.some((root) => { + const candidate = new Set(declaredDependencyNames(path.join(repoRoot, root))); + return declaredDependencyNames(path.join(baseRoot, root)).some( + (name) => !candidate.has(name), + ); + }) + ) { + // A removal must not make the historical source unbuildable. Materialize + // its lockfile in this private archive without running historical hooks. + run("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], baseRoot); + } const workspaceRoots = new Map( roots.map((root) => [fs.realpathSync(path.join(repoRoot, root)), root]), ); @@ -74,6 +110,7 @@ function linkDependencies(baseRoot: string): void { const target = workspace === undefined ? installed : path.join(baseRoot, workspace); const destination = path.join(destinationRoot, "node_modules", name); fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.rmSync(destination, { recursive: true, force: true }); fs.symlinkSync(target, destination, "junction"); } } @@ -114,8 +151,9 @@ function main(): void { run("tar", ["-xzf", archive, "-C", baseRoot]); linkDependencies(baseRoot); - // Both builds use the candidate's dependency installation. Calling Vite - // directly keeps historical policy out; one identity isolates source bytes. + // Both builds use the candidate's toolchain and shared dependencies; only + // base-only dependencies come from its lockfile. Calling Vite directly + // keeps historical policy out; one identity isolates source bytes. for (const root of [repoRoot, baseRoot]) { run(process.execPath, [viteBin, "build"], path.join(root, "ui"), buildEnv); } diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 234329094d45..c43a81a5e87f 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -13,9 +13,9 @@ import { } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; -import prettyMilliseconds from "pretty-ms"; import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts"; import { resolveProviderConfig } from "../../lib/cross-os-release-checks/config.ts"; +import { formatDurationElapsed } from "../../lib/format-duration.mts"; import { die, ensureValue, @@ -558,13 +558,10 @@ function formatDuration(durationMs: number): string { return "0ms"; } const roundedMs = Math.round(durationMs); - if (roundedMs < 1000) { - return prettyMilliseconds(roundedMs); - } - return prettyMilliseconds(Math.round(durationMs / 1000) * 1000, { - hideYear: true, - unitCount: 2, - }); + return formatDurationElapsed( + roundedMs < 1000 ? roundedMs : Math.round(durationMs / 1000) * 1000, + { showYears: false, unitCount: 2 }, + ); } function readHarnessCheckoutVersion(): string { diff --git a/scripts/lib/dependency-ownership.json b/scripts/lib/dependency-ownership.json index fb6cbafd3987..3b6e8338f50c 100644 --- a/scripts/lib/dependency-ownership.json +++ b/scripts/lib/dependency-ownership.json @@ -51,11 +51,6 @@ "class": "default-runtime-initially", "risk": ["provider-sdk", "network", "realtime"] }, - "@grammyjs/runner": { - "owner": "plugin:telegram", - "class": "plugin-runtime", - "risk": ["telegram-bot-api", "polling"] - }, "@grammyjs/transformer-throttler": { "owner": "plugin:telegram", "class": "plugin-runtime", @@ -240,11 +235,6 @@ "class": "core-runtime", "risk": ["pattern-matching"] }, - "ms": { - "owner": "core:duration-parsing", - "class": "core-runtime", - "risk": ["duration-parser"] - }, "node-edge-tts": { "owner": "plugin:microsoft", "class": "plugin-runtime", @@ -275,11 +265,6 @@ "class": "core-runtime", "risk": ["browser-automation", "cdp"] }, - "pretty-ms": { - "owner": "core:duration-formatting", - "class": "core-runtime", - "risk": ["formatting"] - }, "quickjs-wasi": { "owner": "plugin:code-mode-quickjs", "class": "plugin-runtime", diff --git a/scripts/lib/format-duration.mts b/scripts/lib/format-duration.mts new file mode 100644 index 000000000000..2d2674d30f7a --- /dev/null +++ b/scripts/lib/format-duration.mts @@ -0,0 +1,46 @@ +import { durationUnitMs } from "../../src/infra/format-time/duration-units.ts"; +import { resolveExactDurationParts } from "../../src/infra/format-time/format-duration-exact.ts"; +import { + formatDurationParts, + type DurationPart, +} from "../../src/infra/format-time/format-duration-internal.ts"; + +/** Formats non-negative, pre-rounded script timings with fractional seconds. */ +export function formatDurationElapsed( + ms: number, + options: { secondsDecimalDigits?: 1 | 2; unitCount?: 1 | 2; showYears?: boolean } = {}, +): string { + const exact = resolveExactDurationParts(ms); + if (!exact) { + throw new TypeError("Expected a non-negative finite number"); + } + if (ms < durationUnitMs.second) { + return `${ms}ms`; + } + const daysPerYear = BigInt(durationUnitMs.year / durationUnitMs.day); + const wholeParts = exact.flatMap(({ value, unit }): DurationPart[] => { + if (unit === "second" || unit === "millisecond") { + return []; + } + if (unit !== "day" || options.showYears === false) { + return [{ value, unit }]; + } + const days = BigInt(value); + const dayParts: DurationPart[] = [ + { value: days / daysPerYear, unit: "year" }, + { value: days % daysPerYear, unit: "day" }, + ]; + return dayParts.filter((part) => part.value !== 0n); + }); + const decimals = options.secondsDecimalDigits ?? 1; + const scale = 10 ** decimals; + // Avoid dropping a displayed decimal when binary division lands just below it. + const seconds = Math.floor(((ms / durationUnitMs.second) % 60) * scale + 0.000_000_1) / scale; + const unitCount = options.unitCount ?? Infinity; + const units = wholeParts.slice(0, unitCount); + const result = [formatDurationParts(units)]; + if (seconds > 0 && units.length < unitCount) { + result.push(`${seconds.toFixed(decimals).replace(/\.0+$/, "")}s`); + } + return result.filter(Boolean).join(" ") || "0ms"; +} diff --git a/scripts/pr-lib/materialize-dependencies.mjs b/scripts/pr-lib/materialize-dependencies.mjs index 00cddc4bb66a..fce82b816d2a 100644 --- a/scripts/pr-lib/materialize-dependencies.mjs +++ b/scripts/pr-lib/materialize-dependencies.mjs @@ -35,7 +35,6 @@ const dependencies = [ "json5", "koffi", "kysely", - "ms", "p-map", "semver", "string-width", diff --git a/scripts/pr-lib/wrapper-components.txt b/scripts/pr-lib/wrapper-components.txt index e58b082002d9..6caee1b53fec 100644 --- a/scripts/pr-lib/wrapper-components.txt +++ b/scripts/pr-lib/wrapper-components.txt @@ -117,6 +117,7 @@ scripts/lib/direct-run.mjs scripts/lib/error-format.mts scripts/lib/extension-test-plan.mts scripts/lib/failed-trailer.mts +scripts/lib/format-duration.mts scripts/lib/gateway-server-test-plan.mts scripts/lib/list-test-files.mts scripts/lib/local-check-runtime.mts @@ -648,6 +649,9 @@ src/infra/file-lock-manager.ts src/infra/file-lock-sync.ts src/infra/file-lock.ts src/infra/fixed-window-rate-limit.ts +src/infra/format-time/duration-units.ts +src/infra/format-time/format-duration-exact.ts +src/infra/format-time/format-duration-internal.ts src/infra/fs-safe-copy.worker.ts src/infra/fs-safe.ts src/infra/gateway-lock-process.ts diff --git a/scripts/run-additional-boundary-checks.mts b/scripts/run-additional-boundary-checks.mts index 92180fe5cfe3..41e5f134a12b 100644 --- a/scripts/run-additional-boundary-checks.mts +++ b/scripts/run-additional-boundary-checks.mts @@ -4,12 +4,12 @@ import { spawn, type ChildProcess } from "node:child_process"; import { performance } from "node:perf_hooks"; import pMap from "p-map"; -import prettyMilliseconds from "pretty-ms"; import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs, } from "../packages/normalization-core/src/number-coercion.ts"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { formatDurationElapsed } from "./lib/format-duration.mts"; import { inspectManagedProcessGroup, terminateManagedChild, @@ -448,7 +448,7 @@ function formatDuration(ms: number) { return ""; } const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 100) * 100; - return prettyMilliseconds(Math.max(0, roundedMs), { + return formatDurationElapsed(Math.max(0, roundedMs), { unitCount: 1, }); } diff --git a/src/agents/failover/retry-evidence.test.ts b/src/agents/failover/retry-evidence.test.ts new file mode 100644 index 000000000000..aadfef5ff8bb --- /dev/null +++ b/src/agents/failover/retry-evidence.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { classifyRateLimitWindow, resolveRetryAfterMs } from "./retry-evidence.js"; + +const NOW_MS = Date.parse("2015-10-21T07:27:00.000Z"); + +describe("resolveRetryAfterMs", () => { + it.each([ + ["1001 milliseconds", 1001], + ["1001 millisecond", 1001], + ["1001 msecs", 1001], + ["1001 msec", 1001], + ["1001ms", 1001], + ["1.25 seconds", 1250], + ["1.25 second", 1250], + ["1.25 secs", 1250], + ["1.25 sec", 1250], + ["1.25s", 1250], + ["1.5 minutes", 90_000], + ["1.5 minute", 90_000], + ["1.5 mins", 90_000], + ["1.5 min", 90_000], + ["1.5m", 90_000], + ["0.25 hours", 900_000], + ["0.25 hour", 900_000], + ["0.25 hrs", 900_000], + ["0.25 hr", 900_000], + ["0.25h", 900_000], + ["0.5 days", 43_200_000], + ["0.5 day", 43_200_000], + ["0.5d", 43_200_000], + ["1.25", 1250], + ["0.01ms", 1], + ["0s", 0], + ] as const)("converts the provider retry floor %s", (value, expected) => { + expect(resolveRetryAfterMs(`Retry-After: ${value}`, NOW_MS)).toBe(expected); + }); + + it("keeps the largest floor across message variants and headers", () => { + const message = + "Retry-After: 0.5 seconds; please try again in 1.25 SECS, then continue.\nRetry after in 1s"; + expect(resolveRetryAfterMs(message, NOW_MS)).toBe(1250); + expect( + resolveRetryAfterMs(message, NOW_MS, { + headers: { "retry-after": "2", "retry-after-ms": "2500.5" }, + }), + ).toBe(2501); + expect( + resolveRetryAfterMs(message, NOW_MS, JSON.stringify({ headers: { "retry-after": "1" } })), + ).toBe(1250); + }); + + it.each(["Infinity", "iNfInItY milliseconds", `${"9".repeat(400)} days`])( + "preserves an unbounded provider retry floor: %s", + (value) => { + const message = `Retry-After: ${value}`; + expect(resolveRetryAfterMs(message, NOW_MS)).toBe(Infinity); + expect(classifyRateLimitWindow(message, NOW_MS)).toEqual({ kind: "long" }); + }, + ); + + it("does not apply CLI token-length or safe-integer limits to retry floors", () => { + expect(resolveRetryAfterMs(`Retry-After: ${"0".repeat(101)}2s`, NOW_MS)).toBe(2000); + expect(resolveRetryAfterMs("Retry-After: 9007199254741 seconds", NOW_MS)).toBe( + 9_007_199_254_741_000, + ); + }); + + it.each([ + "Wed, 21 Oct 2015 07:28:00 GMT", + "Wednesday, 21-Oct-15 07:28:00 GMT", + "Wed Oct 21 07:28:00 2015", + ])("preserves HTTP-date retry floors: %s", (date) => { + expect(resolveRetryAfterMs(`Retry-After: ${date}`, NOW_MS)).toBe(60_000); + }); + + it("clamps elapsed HTTP dates to zero and rejects invalid dates", () => { + expect(resolveRetryAfterMs("Retry-After: Wed, 21 Oct 2015 07:26:00 GMT", NOW_MS)).toBe(0); + expect( + resolveRetryAfterMs("Retry-After: Tue, 21 Oct 2015 07:28:00 GMT", NOW_MS), + ).toBeUndefined(); + }); + + it.each(["1 week", "1y", "1 month", "1 constructor", "-1s", ".5s", "1e3s", "1h30m"])( + "rejects values outside the retry grammar: %s", + (value) => { + expect(resolveRetryAfterMs(`Retry-After: ${value}`, NOW_MS)).toBeUndefined(); + }, + ); +}); + +describe("classifyRateLimitWindow", () => { + it("compares converted fractions against the one-minute short-window limit", () => { + expect(classifyRateLimitWindow("429 Try again in 0.5 minutes", NOW_MS)).toEqual({ + kind: "short", + retryAfterSeconds: 30, + }); + expect(classifyRateLimitWindow("429 Retry-After: 60000ms", NOW_MS)).toEqual({ + kind: "short", + retryAfterSeconds: 60, + }); + expect(classifyRateLimitWindow("429 Retry-After: 60001ms", NOW_MS)).toEqual({ kind: "long" }); + }); +}); diff --git a/src/agents/failover/retry-evidence.ts b/src/agents/failover/retry-evidence.ts index e136c830f1fe..654877b7aae6 100644 --- a/src/agents/failover/retry-evidence.ts +++ b/src/agents/failover/retry-evidence.ts @@ -3,7 +3,7 @@ import { parseRetryAfterErrorSeconds, } from "@openclaw/ai/internal/retry-after"; import { safeParseJsonRecord } from "@openclaw/normalization-core/json-coercion"; -import milliseconds from "ms"; +import { durationUnitMs } from "../../infra/format-time/duration-units.js"; import { isTransientNetworkError } from "../../infra/retryable-network-errors.js"; import { extractErrorHttpStatus, @@ -34,6 +34,31 @@ const SHORT_WINDOW_RATE_LIMIT_RE = const RETRY_AFTER_VALUE_RE = /\b(?:retry[- ]after\b\s*:?\s*(?:in\b\s*)?|(?:please\s+)?try again in\s+)([^\r\n;]+)/i; const RETRY_AFTER_NUMBER_RE = /^(\d+(?:\.\d+)?|Infinity)\s*([a-z]+)?\b/i; +const RETRY_AFTER_UNIT_MS = new Map([ + ["milliseconds", durationUnitMs.millisecond], + ["millisecond", durationUnitMs.millisecond], + ["msecs", durationUnitMs.millisecond], + ["msec", durationUnitMs.millisecond], + ["ms", durationUnitMs.millisecond], + ["seconds", durationUnitMs.second], + ["second", durationUnitMs.second], + ["secs", durationUnitMs.second], + ["sec", durationUnitMs.second], + ["s", durationUnitMs.second], + ["minutes", durationUnitMs.minute], + ["minute", durationUnitMs.minute], + ["mins", durationUnitMs.minute], + ["min", durationUnitMs.minute], + ["m", durationUnitMs.minute], + ["hours", durationUnitMs.hour], + ["hour", durationUnitMs.hour], + ["hrs", durationUnitMs.hour], + ["hr", durationUnitMs.hour], + ["h", durationUnitMs.hour], + ["days", durationUnitMs.day], + ["day", durationUnitMs.day], + ["d", durationUnitMs.day], +]); const MAX_SHORT_WINDOW_RETRY_AFTER_SECONDS = 60; /** Extract guarded HTTP status evidence for retry and diagnostic consumers. */ @@ -83,16 +108,13 @@ function parseRetryAfterSeconds(valueText: string, nowMs: number): number | unde return undefined; } const unit = secondsMatch[2]?.toLowerCase(); - if ( - unit && - !/^(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)$/.test( - unit, - ) - ) { + const unitMilliseconds = RETRY_AFTER_UNIT_MS.get(unit ?? "s"); + if (unitMilliseconds === undefined) { return undefined; } - const unitMilliseconds = milliseconds(`1${unit ?? "s"}` as Parameters[0]); - return unitMilliseconds === 1 ? value / 1000 : value * (unitMilliseconds / 1000); + return unitMilliseconds === durationUnitMs.millisecond + ? value / durationUnitMs.second + : value * (unitMilliseconds / durationUnitMs.second); } const retryAtMs = parseRetryAfterHttpDateMs(valueText, nowMs); return retryAtMs === undefined ? undefined : Math.max(0, (retryAtMs - nowMs) / 1000); diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts index c3aa92a028a5..99d14be36fdf 100644 --- a/src/cli/cli-utils.test.ts +++ b/src/cli/cli-utils.test.ts @@ -288,17 +288,67 @@ describe("parseDurationMs", () => { ["supports decimals", "0.5s", 500], ["parses composite hours+minutes", "1h30m", 5_400_000], ["parses composite with milliseconds", "2m500ms", 120_500], + ["normalizes surrounding whitespace and case", " 1H30M ", 5_400_000], + ["accepts leading zeros", "0001.25s", 1250], + ["accepts zero", "0ms", 0], + ["rounds fractions of a millisecond", "0.5ms", 1], + ["rounds once after summing segments", "0.4ms0.4ms", 1], + ["allows repeated units in any order", "30m1h30m", 7_200_000], ] as const)("%s", (_name, input, expected) => { expect(parseDurationMs(input)).toBe(expected); }); - it("rejects invalid composite strings", () => { - expect(() => parseDurationMs("1h30")).toThrow(/Invalid duration/); - expect(() => parseDurationMs("1h-30m")).toThrow(/Invalid duration/); + it.each([ + ["ms", "1.5", 2], + ["s", "1.25", 1250], + ["m", "1.5", 90_000], + ["h", "0.25", 900_000], + ["d", "0.5", 43_200_000], + ] as const)("uses default unit %s only for bare numbers", (defaultUnit, input, expected) => { + expect(parseDurationMs(input, { defaultUnit })).toBe(expected); + expect(parseDurationMs("1s", { defaultUnit })).toBe(1000); + }); + + it.each([ + "", + " ", + "-1s", + "+1s", + ".5s", + "1.s", + "1e3s", + "Infinity", + "NaN", + "1 seconds", + "1sec", + "1w", + "1y", + "1h 30m", + "1h30", + "1h-30m", + ])("rejects values outside the CLI duration grammar: %j", (input) => { + expect(() => parseDurationMs(input)).toThrow(/Invalid duration/); + }); + + it("retains the 100-character limit per token, including the default unit", () => { + const value = `${"0".repeat(97)}1`; + expect(parseDurationMs(`${value}ms`)).toBe(1); + expect(parseDurationMs(value)).toBe(1); + expect(parseDurationMs(`0${value}`, { defaultUnit: "s" })).toBe(1000); + expect(parseDurationMs("1ms".repeat(40))).toBe(40); + expect(() => parseDurationMs(`0${value}ms`)).toThrow(/Invalid duration/); + expect(() => parseDurationMs(`0${value}`)).toThrow(/Invalid duration/); + expect(() => parseDurationMs(`1s0${value}ms`)).toThrow(/Invalid duration/); + }); + + it("keeps safe integer results exact", () => { + expect(parseDurationMs("9007199254740991ms")).toBe(Number.MAX_SAFE_INTEGER); + expect(parseDurationMs("9007199254740990ms1ms")).toBe(Number.MAX_SAFE_INTEGER); }); it("rejects unsafe millisecond results", () => { expect(() => parseDurationMs("9007199254740993ms")).toThrow(/Invalid duration/); expect(() => parseDurationMs("9007199254740990ms10ms")).toThrow(/Invalid duration/); + expect(() => parseDurationMs("99999999999999999999d")).toThrow(/Invalid duration/); }); }); diff --git a/src/cli/parse-duration.ts b/src/cli/parse-duration.ts index 9956d72530f2..6456d2457766 100644 --- a/src/cli/parse-duration.ts +++ b/src/cli/parse-duration.ts @@ -1,12 +1,20 @@ // Duration parser shared by CLI flags, command directives, and config-backed timing values. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import milliseconds from "ms"; +import { durationUnitMs } from "../infra/format-time/duration-units.js"; /** Options for choosing the unit used by bare numeric duration values. */ type DurationMsParseOptions = { defaultUnit?: "ms" | "s" | "m" | "h" | "d"; }; +const DURATION_UNIT_MS = new Map([ + ["ms", durationUnitMs.millisecond], + ["s", durationUnitMs.second], + ["m", durationUnitMs.minute], + ["h", durationUnitMs.hour], + ["d", durationUnitMs.day], +]); + function invalidDuration(raw: string, reason?: string): Error { const value = raw.trim() ? `"${raw}"` : "empty value"; const prefix = reason ? `Invalid duration (${reason}): ${value}.` : `Invalid duration: ${value}.`; @@ -14,7 +22,11 @@ function invalidDuration(raw: string, reason?: string): Error { } function parseDurationToken(raw: string, value: string, unit: string): number { - const parsed = milliseconds(`${value}${unit}` as Parameters[0]); + const multiplier = DURATION_UNIT_MS.get(unit); + if (multiplier === undefined || value.length + unit.length > 100) { + throw invalidDuration(raw); + } + const parsed = Number(value) * multiplier; if (!Number.isFinite(parsed) || parsed < 0) { throw invalidDuration(raw); } diff --git a/src/infra/format-time/duration-units.ts b/src/infra/format-time/duration-units.ts new file mode 100644 index 000000000000..e04aa6fce500 --- /dev/null +++ b/src/infra/format-time/duration-units.ts @@ -0,0 +1,9 @@ +export const durationUnitMs = { + year: 31_536_000_000, + week: 604_800_000, + day: 86_400_000, + hour: 3_600_000, + minute: 60_000, + second: 1_000, + millisecond: 1, +} as const; diff --git a/src/infra/format-time/format-duration-exact.ts b/src/infra/format-time/format-duration-exact.ts index 68711ee2b991..6937ec660750 100644 --- a/src/infra/format-time/format-duration-exact.ts +++ b/src/infra/format-time/format-duration-exact.ts @@ -1,4 +1,5 @@ -import { durationUnitMs, type DurationPart } from "./format-duration-internal.js"; +import { durationUnitMs } from "./duration-units.ts"; +import type { DurationPart } from "./format-duration-internal.js"; // Exact display stays outside startup formatting; health uses weeks, cron uses days. export function resolveExactDurationParts(ms?: number | null, showWeeks = false) { diff --git a/src/infra/format-time/format-duration-internal.ts b/src/infra/format-time/format-duration-internal.ts index 3d96375c11cf..ab463dcc6db8 100644 --- a/src/infra/format-time/format-duration-internal.ts +++ b/src/infra/format-time/format-duration-internal.ts @@ -1,14 +1,4 @@ -import prettyMilliseconds from "pretty-ms"; - -export const durationUnitMs = { - year: 31_536_000_000, - week: 604_800_000, - day: 86_400_000, - hour: 3_600_000, - minute: 60_000, - second: 1_000, - millisecond: 1, -} as const; +import { durationUnitMs } from "./duration-units.ts"; export type DurationPart = { value: number | bigint; unit: keyof typeof durationUnitMs }; @@ -23,21 +13,19 @@ function resolveDurationParts(ms: number, unitCount: number, showYears = false): // Large floats can retain a remainder after second-rounding; only subsecond input uses ms. { value: ms < 1_000 ? Math.trunc(ms) : 0, unit: "millisecond" }, ]; - // pretty-ms counts nonzero units, so an empty middle bucket must not hide the next one. + // Count nonzero units so an empty middle bucket does not hide the next one. const selected = parts.filter(({ value }) => value !== 0 && value !== 0n).slice(0, unitCount); return selected.length ? selected : [{ value: 0, unit: "millisecond" }]; } +function formatDurationPart({ value, unit }: DurationPart, verbose = false): string { + return verbose + ? `${value} ${unit}${value === 1 || value === 1n ? "" : "s"}` + : `${value}${unit === "millisecond" ? "ms" : unit[0]}`; +} + export function formatDurationParts(parts: DurationPart[], verbose = false): string { - return parts - .map(({ value, unit }) => - prettyMilliseconds(BigInt(value) * BigInt(durationUnitMs[unit]), { - hideYear: unit !== "year", - unitCount: 1, - verbose, - }), - ) - .join(" "); + return parts.map((part) => formatDurationPart(part, verbose)).join(" "); } export function resolveCompactDurationParts(ms?: number | null, showYears = false) { diff --git a/src/infra/format-time/format-duration.ts b/src/infra/format-time/format-duration.ts index 41ca9320519b..a91254918116 100644 --- a/src/infra/format-time/format-duration.ts +++ b/src/infra/format-time/format-duration.ts @@ -1,6 +1,5 @@ // Duration formatting helpers produce compact, precise, and human display // strings from millisecond values. -import prettyMilliseconds from "pretty-ms"; import { formatDurationParts, formatSingleUnitDuration, @@ -44,7 +43,7 @@ export function formatDurationPrecise( } const roundedMs = Math.max(0, Math.round(ms)); if (roundedMs < 1000) { - return prettyMilliseconds(roundedMs); + return `${roundedMs}ms`; } return formatDurationSeconds(ms, { decimals: options.decimals ?? 2, diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index 1c8577de7624..513286284db3 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -8,7 +8,11 @@ import { resolveTimeZoneDayStartMs, resolveTimezone, } from "./format-datetime.js"; -import { formatSingleUnitDuration } from "./format-duration-internal.js"; +import { + formatDurationParts, + formatSingleUnitDuration, + type DurationPart, +} from "./format-duration-internal.js"; import { formatDurationCompact, formatDurationHuman, @@ -34,12 +38,16 @@ afterEach(() => { describe("format-duration", () => { describe("formatDurationCompact", () => { - it.each([null, undefined, 0, -100])("returns undefined for %j", (value) => { - expect(formatDurationCompact(value)).toBeUndefined(); - }); + it.each([null, undefined, 0, -100, Number.NaN, Infinity, -Infinity])( + "returns undefined for %j", + (value) => { + expect(formatDurationCompact(value)).toBeUndefined(); + }, + ); it("formats compact units and omits trailing zero components", () => { expectFormatterCases(formatDurationCompact, [ + { input: 0.1, expected: "0ms" }, { input: 500, expected: "500ms" }, { input: 999, expected: "999ms" }, { input: 999.6, expected: "1s" }, @@ -56,6 +64,10 @@ describe("format-duration", () => { { input: 86400000, expected: "1d" }, { input: 90000000, expected: "1d1h" }, { input: 172800000, expected: "2d" }, + { input: 86_430_000, expected: "1d30s" }, + { input: 3_599_500, expected: "1h" }, + { input: 86_399_500, expected: "1d" }, + { input: 366 * 86400000, expected: "366d" }, ]); }); @@ -120,6 +132,10 @@ describe("format-duration", () => { }); it.each([ + [0, "0 milliseconds"], + [1, "1 millisecond"], + [2, "2 milliseconds"], + [1_000, "1 second"], [30_000, "30 seconds"], [89_500, "1 minute"], [1_800_000, "30 minutes"], @@ -131,6 +147,19 @@ describe("format-duration", () => { }); }); + describe("formatDurationParts", () => { + it("preserves bigint quantities without narrowing to numbers", () => { + const parts: DurationPart[] = [ + { value: 9_007_199_254_740_993n, unit: "day" }, + { value: 1n, unit: "hour" }, + ]; + expect(formatDurationParts(parts)).toBe("9007199254740993d 1h"); + expect(formatDurationParts(parts, true)).toBe("9007199254740993 days 1 hour"); + expect(formatDurationParts([{ value: 1n, unit: "year" }], true)).toBe("1 year"); + expect(formatDurationParts([{ value: 2n, unit: "year" }], true)).toBe("2 years"); + }); + }); + describe("formatDurationPrecise", () => { it.each([ { input: 500, expected: "500ms" }, diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index bc16ad968f12..9b968776c27b 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -815,6 +815,9 @@ describe("resolveBuildAllSteps", () => { const cwd = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-source-rebuild-")), ); + // Artifact ownership must stop at this fixture, even inside another checkout. + fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ name: "openclaw" })); + fs.writeFileSync(path.join(cwd, "pnpm-workspace.yaml"), "packages: []\n"); const childEnv = { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: skipDts, @@ -878,6 +881,9 @@ describe("resolveBuildAllSteps", () => { ]); expect(postbuild).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(cwd, ".artifacts/run-node-build.lock"))).toBe(false); + expect(fs.existsSync(path.join(cwd, ".artifacts/dist-artifacts.lock/owner.json"))).toBe( + false, + ); } finally { fs.rmSync(cwd, { recursive: true, force: true }); } diff --git a/test/scripts/control-ui-performance-base.test.ts b/test/scripts/control-ui-performance-base.test.ts index cd7dc625ef0a..99388c7e0b8f 100644 --- a/test/scripts/control-ui-performance-base.test.ts +++ b/test/scripts/control-ui-performance-base.test.ts @@ -47,9 +47,6 @@ it("compares real UI builds with canonical compression and keeps artifacts after ]) { fs.mkdirSync(path.join(root, directory), { recursive: true }); } - for (const directory of ["node_modules", "ui/node_modules"]) { - fs.symlinkSync(path.join(repoRoot, directory), path.join(root, directory), "junction"); - } for (const script of [ "check-control-ui-performance-base.mts", "check-control-ui-performance.mts", @@ -59,18 +56,32 @@ it("compares real UI builds with canonical compression and keeps artifacts after "lib/control-ui-i18n-config.ts", "lib/repo-root.mjs", "lib/output-root-guard.mjs", + "lib/record-shared.mjs", ]) { fs.copyFileSync(path.join(repoRoot, "scripts", script), path.join(root, "scripts", script)); } write("scripts/tsx.mjs", `await import(${JSON.stringify(tsxImport)});\n`); write(".gitignore", "node_modules\ndist/\n"); - write("package.json", '{"name":"ui-budget-proof","version":"1.0.0","type":"module"}'); + write( + "package.json", + JSON.stringify({ + name: "ui-budget-proof", + version: "1.0.0", + type: "module", + dependencies: { "base-only-library": "file:vendor/base-only-library" }, + }), + ); + write( + "vendor/base-only-library/package.json", + '{"name":"base-only-library","version":"1.0.0","type":"module","exports":"./index.js"}', + ); + write("vendor/base-only-library/index.js", 'export const value = "base dependency";'); write("pnpm-workspace.yaml", 'packages: ["ui", "packages/*"]\n'); write("ui/package.json", '{"name":"ui-budget-proof-ui","type":"module"}'); write("ui/index.html", ''); write( "ui/main.js", - 'import "./style.css"; import { message } from "../packages/styles/main.js"; document.body.textContent = message;', + 'import "./style.css"; import { message } from "../packages/styles/main.js"; import { value } from "base-only-library"; document.body.textContent = message + "/" + value;', ); write( "packages/styles/main.js", @@ -137,11 +148,26 @@ export default { `; write("ui/vite.config.ts", config); write("ui/style.css", css(1_000)); + const lockfile = spawnSync( + "pnpm", + ["install", "--lockfile-only", "--no-frozen-lockfile", "--ignore-scripts", "--offline"], + { cwd: root, encoding: "utf8", timeout: 30_000 }, + ); + expect(lockfile.status, `${lockfile.stdout}${lockfile.stderr}`).toBe(0); git("init", "--quiet"); git("add", "."); git("commit", "--quiet", "-m", "base"); const base = git("rev-parse", "HEAD"); + for (const directory of ["node_modules", "ui/node_modules"]) { + const target = path.join(root, directory); + fs.rmSync(target, { recursive: true, force: true }); + fs.symlinkSync(path.join(repoRoot, directory), target, "junction"); + } write("package.json", '{"name":"ui-budget-proof","version":"1.0.1","type":"module"}'); + write( + "ui/main.js", + 'import "./style.css"; import { message } from "../packages/styles/main.js"; document.body.textContent = message;', + ); write("ui/vite.config.ts", config.replace("level: 0", "level: 9")); write("packages/workspace-value/index.js", 'export const message = "candidate workspace";'); @@ -247,7 +273,10 @@ export default { runInNewContext(entryCode, { document }); return document.body.textContent; }), - ).toEqual(["candidate workspace", "base workspace"]); + ).toEqual(["candidate workspace", "base workspace/base dependency"]); + expect(fs.realpathSync(path.join(root, "node_modules"))).toBe( + fs.realpathSync(path.join(repoRoot, "node_modules")), + ); expect(fs.existsSync(path.join(root, "dist/control-ui/index.html"))).toBe(true); expect( fs.readdirSync(scratch).filter((name) => name.startsWith("openclaw-ui-performance-base-")), diff --git a/test/scripts/format-duration.test.ts b/test/scripts/format-duration.test.ts new file mode 100644 index 000000000000..35366ca8899d --- /dev/null +++ b/test/scripts/format-duration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { formatDurationElapsed } from "../../scripts/lib/format-duration.mts"; + +describe("formatDurationElapsed", () => { + it.each<[number, 1 | 2, string]>([ + [0, 1, "0ms"], + [999, 1, "999ms"], + [1_000, 1, "1s"], + [1_100, 2, "1.10s"], + [1_230, 2, "1.23s"], + [3_100, 1, "3.1s"], + [10_000, 2, "10s"], + [12_300, 1, "12.3s"], + [59_900, 1, "59.9s"], + [60_000, 1, "1m"], + [60_100, 1, "1m 0.1s"], + [3_661_100, 1, "1h 1m 1.1s"], + [86_400_100, 1, "1d 0.1s"], + [31_626_061_100, 1, "1y 1d 1h 1m 1.1s"], + ])("formats %dms with %d second decimals", (input, secondsDecimalDigits, expected) => { + expect(formatDurationElapsed(input, { secondsDecimalDigits })).toBe(expected); + }); + + it("folds years into days for smoke timings and skips empty units", () => { + expect(formatDurationElapsed(31_622_401_000, { showYears: false, unitCount: 2 })).toBe( + "366d 1s", + ); + }); + + it.each([ + [0, "0ms"], + [999, "999ms"], + [1_100, "1.1s"], + [59_900, "59.9s"], + [60_100, "1m"], + [3_660_000, "1h"], + [31_622_400_000, "1y"], + ])("keeps only the first nonzero timing unit for %dms", (input, expected) => { + expect(formatDurationElapsed(input, { unitCount: 1 })).toBe(expected); + }); +}); diff --git a/test/scripts/pr-wrapper.test-support.ts b/test/scripts/pr-wrapper.test-support.ts index f6ef99d8597c..c0be04dde144 100644 --- a/test/scripts/pr-wrapper.test-support.ts +++ b/test/scripts/pr-wrapper.test-support.ts @@ -51,7 +51,6 @@ export function linkPrWrapperDependencies(destination: string): void { "koffi", "kysely", "minimatch", - "ms", "p-map", "semver", "string-width", diff --git a/test/scripts/tsdown-declaration-fixture.ts b/test/scripts/tsdown-declaration-fixture.ts index 44824872f71e..586ed101b4dd 100644 --- a/test/scripts/tsdown-declaration-fixture.ts +++ b/test/scripts/tsdown-declaration-fixture.ts @@ -149,7 +149,6 @@ export function createFixture(groups: readonly string[], root: string) { "tsx", "esbuild", "import-meta-resolve", - ...(groups === TSDOWN_NON_SDK_DTS_CONFIG_GROUPS ? ["pretty-ms"] : []), ]) { const target = path.join(root, "node_modules", name); fs.mkdirSync(path.dirname(target), { recursive: true }); @@ -205,6 +204,9 @@ export function createFixture(groups: readonly string[], root: string) { "src/infra/update-managed-service-handoff-native-loader.ts", "src/shared/deferred.ts", "src/shared/freebsd-process-identity.ts", + "src/infra/format-time/duration-units.ts", + "src/infra/format-time/format-duration-exact.ts", + "src/infra/format-time/format-duration-internal.ts", "src/infra/node-runtime-executable.ts", "src/infra/runtime-dependency-ownership.ts", "src/shared/non-packaged-plugin-dirs.ts", diff --git a/test/tsconfig/tsconfig.core.test.shard.json b/test/tsconfig/tsconfig.core.test.shard.json index 07c946bc824c..1f0405b91c8f 100644 --- a/test/tsconfig/tsconfig.core.test.shard.json +++ b/test/tsconfig/tsconfig.core.test.shard.json @@ -14,7 +14,6 @@ "../../ui/src/css.d.ts", "../../ui/src/i18n/virtual-locale.d.ts", "../../ui/src/markdown-it-emoji.d.ts", - "../../ui/src/markdown-it-task-lists.d.ts", "../../ui/src/types/highlight-js-subpaths.d.ts", "../../ui/src/types/novnc.d.ts" ], diff --git a/ui/config/control-ui-boot-modules.json b/ui/config/control-ui-boot-modules.json index ba239774231b..fa0a2e455c86 100644 --- a/ui/config/control-ui-boot-modules.json +++ b/ui/config/control-ui-boot-modules.json @@ -185,7 +185,6 @@ "node_modules/lit/static-html.js", "node_modules/markdown-it-cjk-friendly/dist/index.js", "node_modules/markdown-it-emoji/lib/data/full.mjs", - "node_modules/markdown-it-task-lists/index.js", "node_modules/markdown-it/dist/markdown-it.mjs", "node_modules/mdast-util-from-markdown/lib/index.js", "node_modules/mdast-util-gfm-table/lib/index.js", @@ -245,11 +244,8 @@ "node_modules/micromark/lib/parse.js", "node_modules/micromark/lib/postprocess.js", "node_modules/micromark/lib/preprocess.js", - "node_modules/ms/index.js", "node_modules/nanoid/index.browser.js", "node_modules/nanoid/url-alphabet/index.js", - "node_modules/parse-ms/index.js", - "node_modules/pretty-ms/index.js", "node_modules/punycode.js/punycode.es6.js", "node_modules/remend/dist/index.js", "node_modules/typebox/build/compile/code.mjs", @@ -1064,6 +1060,7 @@ "src/gateway/events.ts", "src/infra/approval-display-paths.ts", "src/infra/exec-approvals-core.ts", + "src/infra/format-time/duration-units.ts", "src/infra/format-time/format-duration-internal.ts", "src/infra/format-time/format-duration.ts", "src/infra/map-size.ts", diff --git a/ui/config/control-ui-chunking.ts b/ui/config/control-ui-chunking.ts index 9fec596fbfec..4e76e568fb13 100644 --- a/ui/config/control-ui-chunking.ts +++ b/ui/config/control-ui-chunking.ts @@ -96,7 +96,6 @@ export function controlUiStableChunkName(id: string): string | undefined { if ( moduleIdIncludesPackage(id, "highlight.js") || moduleIdIncludesPackage(id, "markdown-it") || - moduleIdIncludesPackage(id, "markdown-it-task-lists") || moduleIdIncludesPackage(id, "dompurify") || moduleIdIncludesPackage(id, "entities") || moduleIdIncludesPackage(id, "linkify-it") || diff --git a/ui/package.json b/ui/package.json index a33c7680fad8..a559e534f149 100644 --- a/ui/package.json +++ b/ui/package.json @@ -48,7 +48,6 @@ "markdown-it": "15.0.2", "markdown-it-cjk-friendly": "3.0.0", "markdown-it-emoji": "3.1.0", - "markdown-it-task-lists": "2.1.1", "parse5": "8.0.1", "remend": "1.3.1", "zod": "4.6.5" diff --git a/ui/src/components/markdown-assistant-transcript.ts b/ui/src/components/markdown-assistant-transcript.ts index 660d4a111b43..2a4a7f44518b 100644 --- a/ui/src/components/markdown-assistant-transcript.ts +++ b/ui/src/components/markdown-assistant-transcript.ts @@ -62,7 +62,7 @@ export function installAssistantTranscriptRoleMarkdown( escapeHtml: (value: string) => string, ): void { md.use(markdownItAssistantTranscriptRoles, { - // The task-list plugin injects a trusted checkbox HTML token. It is visible + // The task-list rule injects a trusted checkbox HTML token. It is visible // UI structure, not text before the list item's semantic first character. isStructuralHtmlInline: (token) => token.meta?.taskListPlugin === true, }); diff --git a/ui/src/components/markdown-parser.ts b/ui/src/components/markdown-parser.ts index 0d6feb3b6e24..bdf45fde541c 100644 --- a/ui/src/components/markdown-parser.ts +++ b/ui/src/components/markdown-parser.ts @@ -1,6 +1,5 @@ import MarkdownIt, { type MarkdownIt as MarkdownItParser, type Token } from "markdown-it"; import markdownItCjkFriendly from "markdown-it-cjk-friendly"; -import markdownItTaskLists from "markdown-it-task-lists"; import { t } from "../i18n/index.ts"; import { fileKindForPath, shortestFileLabels } from "./file-kind.ts"; import { isGitHubHost } from "./github-link-eligibility.ts"; @@ -595,32 +594,44 @@ export function createMarkdownParser(): MarkdownItParser { installMarkdownGitHubRefs(markdownParser); - // Enable GFM task list checkboxes (- [x] / - [ ]). - // enabled: false keeps checkboxes read-only (disabled="") — task lists in - // chat messages are display-only, not interactive forms. - // label: false avoids wrapping item text in