@vellumai/assistant 0.11.1 → 0.11.2-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Withdraw a Telegram approval card when its underlying guardian request
|
|
3
|
+
* resolves.
|
|
4
|
+
*
|
|
5
|
+
* Telegram bots cannot re-read a delivered message, so the Slack shape
|
|
6
|
+
* (fetch the original content, strip the buttons, append a status line in
|
|
7
|
+
* one edit) is not available here. The closest faithful projection is:
|
|
8
|
+
*
|
|
9
|
+
* 1. Remove the inline keyboard in place (`editMessageReplyMarkup`), which
|
|
10
|
+
* preserves the card's text for the audit trail while dropping every
|
|
11
|
+
* live affordance.
|
|
12
|
+
* 2. Post the terminal outcome as a silent reply quoting the card, so the
|
|
13
|
+
* message thread durably shows what was decided. The reply is skipped
|
|
14
|
+
* when the decision originated on Telegram: the guardian reply router
|
|
15
|
+
* already delivers its own outcome message in that chat, and a second
|
|
16
|
+
* notice would read as a duplicate.
|
|
17
|
+
*
|
|
18
|
+
* Bot API wrappers differ on the "remove markup" form, so the edit tries the
|
|
19
|
+
* explicit `null` first and falls back to an empty inline keyboard, mirroring
|
|
20
|
+
* the gateway's callback-time clearing. "message is not modified" means the
|
|
21
|
+
* keyboard is already gone (e.g. the gateway cleared it when the deciding tap
|
|
22
|
+
* arrived) and is treated as success. Unlike the gateway's tap path, a failed
|
|
23
|
+
* edit never deletes the message: withdrawal must preserve the audit trail,
|
|
24
|
+
* and a tap on a leftover button already gets the stale "already resolved"
|
|
25
|
+
* handling.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
isParkAction,
|
|
30
|
+
resolveDecisionStatusWord,
|
|
31
|
+
} from "../../../runtime/channel-approval-types.js";
|
|
32
|
+
import { getLogger } from "../../../util/logger.js";
|
|
33
|
+
import { callTelegramBotApi } from "./api.js";
|
|
34
|
+
|
|
35
|
+
const log = getLogger("telegram-withdraw");
|
|
36
|
+
|
|
37
|
+
const STATUS_GLYPH: Record<string, string> = {
|
|
38
|
+
approved: "✅",
|
|
39
|
+
denied: "❌",
|
|
40
|
+
expired: "⌛",
|
|
41
|
+
cancelled: "\u{1f6ab}",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Glyph for a parked (leave-unverified) decision: a neutral "on hold" mark
|
|
46
|
+
* rather than the `denied` cross, since the sender was neither trusted nor
|
|
47
|
+
* kept out.
|
|
48
|
+
*/
|
|
49
|
+
const PARK_STATUS_GLYPH = "⏸️";
|
|
50
|
+
|
|
51
|
+
export interface WithdrawTelegramApprovalCardParams {
|
|
52
|
+
/** Telegram chat the approval message lives in. */
|
|
53
|
+
chatId: string;
|
|
54
|
+
/** Channel-native id of the approval message to withdraw. */
|
|
55
|
+
messageId: string;
|
|
56
|
+
/** Terminal status of the request (e.g. "approved", "denied", "expired"). */
|
|
57
|
+
status: string;
|
|
58
|
+
/**
|
|
59
|
+
* The action the guardian took, when known. A `denied` status reached by a
|
|
60
|
+
* park action (`leave_unverified`) reads as the neutral park label (see
|
|
61
|
+
* {@link resolveDecisionStatusWord}) rather than "Denied"; `block`/`reject`
|
|
62
|
+
* stay a denial. Omitted for status-only transitions (e.g. the expiry
|
|
63
|
+
* sweep).
|
|
64
|
+
*/
|
|
65
|
+
decidedAction?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Whether to post the quoted status reply. False when the decision
|
|
68
|
+
* originated on Telegram, where the guardian reply router already delivers
|
|
69
|
+
* the outcome message in the same chat.
|
|
70
|
+
*/
|
|
71
|
+
postStatusReply: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the plain-text outcome line for the quoted status reply. */
|
|
75
|
+
function buildStatusText(status: string, decidedAction?: string): string {
|
|
76
|
+
const park = status === "denied" && isParkAction(decidedAction);
|
|
77
|
+
const glyph = park ? PARK_STATUS_GLYPH : (STATUS_GLYPH[status] ?? "");
|
|
78
|
+
const word = resolveDecisionStatusWord(status, decidedAction);
|
|
79
|
+
return glyph ? `${glyph} ${word}` : word;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True for the Bot API's "message is not modified" no-op edit rejection. */
|
|
83
|
+
function isNoOpMarkupError(err: unknown): boolean {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
return msg.includes("message is not modified");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove the card's inline keyboard in place, keeping its text. Tries the
|
|
90
|
+
* explicit `reply_markup: null` form first, then an empty inline keyboard.
|
|
91
|
+
* Returns true when the keyboard is gone (including the already-cleared
|
|
92
|
+
* no-op case); false when both edit forms failed.
|
|
93
|
+
*/
|
|
94
|
+
async function clearInlineKeyboard(
|
|
95
|
+
chatId: string,
|
|
96
|
+
messageId: number,
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
const basePayload = { chat_id: chatId, message_id: messageId };
|
|
99
|
+
try {
|
|
100
|
+
await callTelegramBotApi("editMessageReplyMarkup", {
|
|
101
|
+
...basePayload,
|
|
102
|
+
reply_markup: null,
|
|
103
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
} catch (primaryErr) {
|
|
106
|
+
if (isNoOpMarkupError(primaryErr)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await callTelegramBotApi("editMessageReplyMarkup", {
|
|
111
|
+
...basePayload,
|
|
112
|
+
reply_markup: { inline_keyboard: [] },
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
} catch (fallbackErr) {
|
|
116
|
+
if (isNoOpMarkupError(fallbackErr)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
log.warn(
|
|
120
|
+
{ primaryErr, fallbackErr, chatId, messageId },
|
|
121
|
+
"Failed to clear inline keyboard on resolved Telegram approval card",
|
|
122
|
+
);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Project a resolved guardian request onto its Telegram approval card:
|
|
130
|
+
* drop the inline keyboard in place and (unless suppressed) post a silent
|
|
131
|
+
* reply quoting the card with the terminal outcome.
|
|
132
|
+
*
|
|
133
|
+
* Both steps are attempted independently, so a failed keyboard edit does not
|
|
134
|
+
* lose the durable outcome notice. Throws only if the status reply send
|
|
135
|
+
* fails, which the caller treats as non-fatal.
|
|
136
|
+
*/
|
|
137
|
+
export async function withdrawTelegramApprovalCard(
|
|
138
|
+
params: WithdrawTelegramApprovalCardParams,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const parsedMessageId = Number(params.messageId);
|
|
141
|
+
if (!Number.isFinite(parsedMessageId)) {
|
|
142
|
+
log.warn(
|
|
143
|
+
{ chatId: params.chatId, messageId: params.messageId },
|
|
144
|
+
"Skipping Telegram card withdrawal due to invalid message id",
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await clearInlineKeyboard(params.chatId, parsedMessageId);
|
|
150
|
+
|
|
151
|
+
if (!params.postStatusReply) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Silent (no push) reply quoting the card. `allow_sending_without_reply`
|
|
156
|
+
// keeps the outcome notice deliverable even if the card was deleted.
|
|
157
|
+
await callTelegramBotApi("sendMessage", {
|
|
158
|
+
chat_id: params.chatId,
|
|
159
|
+
text: buildStatusText(params.status, params.decidedAction),
|
|
160
|
+
reply_parameters: {
|
|
161
|
+
message_id: parsedMessageId,
|
|
162
|
+
allow_sending_without_reply: true,
|
|
163
|
+
},
|
|
164
|
+
disable_notification: true,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -8,7 +8,13 @@ import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { Database } from "bun:sqlite";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
afterEach,
|
|
13
|
+
beforeEach,
|
|
14
|
+
expect,
|
|
15
|
+
setDefaultTimeout,
|
|
16
|
+
test,
|
|
17
|
+
} from "bun:test";
|
|
12
18
|
|
|
13
19
|
import { assertNotLiveDb } from "../../__tests__/assert-not-live-db.js";
|
|
14
20
|
import {
|
|
@@ -17,6 +23,10 @@ import {
|
|
|
17
23
|
runIntegrityCheck,
|
|
18
24
|
} from "../db-integrity-check.js";
|
|
19
25
|
|
|
26
|
+
// quick_check and the cold `bun run` subprocess are disk-bound; loaded CI
|
|
27
|
+
// runners blow past the 5s default.
|
|
28
|
+
setDefaultTimeout(30_000);
|
|
29
|
+
|
|
20
30
|
let dir: string;
|
|
21
31
|
let dbPath: string;
|
|
22
32
|
|
|
@@ -36,9 +46,13 @@ function seedDb(walMode: boolean): void {
|
|
|
36
46
|
db.exec(`PRAGMA journal_mode=${walMode ? "WAL" : "DELETE"}`);
|
|
37
47
|
db.exec("CREATE TABLE t (id TEXT PRIMARY KEY, v TEXT)");
|
|
38
48
|
const ins = db.prepare("INSERT INTO t (id, v) VALUES (?, ?)");
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
// One transaction: 200 per-insert commits are fsync-bound and take
|
|
50
|
+
// seconds on loaded CI runners.
|
|
51
|
+
db.transaction(() => {
|
|
52
|
+
for (let i = 0; i < 200; i++) {
|
|
53
|
+
ins.run(`k-${i}`, `v-${i}`);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
42
56
|
} finally {
|
|
43
57
|
db.close();
|
|
44
58
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the descriptor poller's pure layers: limit parsing and the pressure
|
|
3
|
+
* decision. The collection functions read fixed `/proc` paths, so only the
|
|
4
|
+
* parsing and threshold logic are exercised here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createFdPressureState,
|
|
11
|
+
evaluateFdPressure,
|
|
12
|
+
parseOpenFileLimits,
|
|
13
|
+
type ProcessFdPressure,
|
|
14
|
+
type ProcessFdUsage,
|
|
15
|
+
} from "../file-descriptors.js";
|
|
16
|
+
|
|
17
|
+
const LIMITS = `Limit Soft Limit Hard Limit Units
|
|
18
|
+
Max cpu time unlimited unlimited seconds
|
|
19
|
+
Max file size unlimited unlimited bytes
|
|
20
|
+
Max open files 1024 1048576 files
|
|
21
|
+
Max locked memory 8388608 8388608 bytes
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
/** A process whose soft limit is known, so it carries a comparable ratio. */
|
|
25
|
+
function usage(fields: {
|
|
26
|
+
pid?: number;
|
|
27
|
+
command?: string;
|
|
28
|
+
openCount: number;
|
|
29
|
+
softLimit?: number;
|
|
30
|
+
}): ProcessFdPressure {
|
|
31
|
+
const {
|
|
32
|
+
pid = 1,
|
|
33
|
+
command = "daemon-main",
|
|
34
|
+
openCount,
|
|
35
|
+
softLimit = 1024,
|
|
36
|
+
} = fields;
|
|
37
|
+
return {
|
|
38
|
+
pid,
|
|
39
|
+
command,
|
|
40
|
+
openCount,
|
|
41
|
+
softLimit,
|
|
42
|
+
hardLimit: 1_048_576,
|
|
43
|
+
ratio: openCount / softLimit,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("parseOpenFileLimits", () => {
|
|
48
|
+
test("reads the soft and hard open-file limits", () => {
|
|
49
|
+
expect(parseOpenFileLimits(LIMITS)).toEqual({
|
|
50
|
+
soft: 1024,
|
|
51
|
+
hard: 1_048_576,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("reports null for an unlimited descriptor limit", () => {
|
|
56
|
+
expect(
|
|
57
|
+
parseOpenFileLimits(
|
|
58
|
+
"Max open files unlimited unlimited files\n",
|
|
59
|
+
),
|
|
60
|
+
).toEqual({ soft: null, hard: null });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("reports null when the row is absent or the file is empty", () => {
|
|
64
|
+
expect(
|
|
65
|
+
parseOpenFileLimits("Max cpu time unlimited unlimited seconds\n"),
|
|
66
|
+
).toEqual({ soft: null, hard: null });
|
|
67
|
+
expect(parseOpenFileLimits("")).toEqual({ soft: null, hard: null });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("evaluateFdPressure", () => {
|
|
72
|
+
const options = { thresholdRatio: 0.8, warnCooldownMs: 60_000 };
|
|
73
|
+
|
|
74
|
+
test("stays quiet while every process is under the threshold", () => {
|
|
75
|
+
const result = evaluateFdPressure(
|
|
76
|
+
[usage({ openCount: 700 }), usage({ pid: 2, openCount: 100 })],
|
|
77
|
+
options,
|
|
78
|
+
createFdPressureState(),
|
|
79
|
+
1_000,
|
|
80
|
+
);
|
|
81
|
+
expect(result.action).toEqual({ kind: "none" });
|
|
82
|
+
expect(result.state).toEqual({ lastWarnAt: 0, overThreshold: false });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("warns on the crossing, naming the over-threshold processes", () => {
|
|
86
|
+
const hot = usage({ pid: 7, openCount: 900 });
|
|
87
|
+
const result = evaluateFdPressure(
|
|
88
|
+
[hot, usage({ pid: 2, openCount: 10 })],
|
|
89
|
+
options,
|
|
90
|
+
createFdPressureState(),
|
|
91
|
+
5_000,
|
|
92
|
+
);
|
|
93
|
+
expect(result.action).toEqual({ kind: "warn", processes: [hot] });
|
|
94
|
+
expect(result.state).toEqual({ lastWarnAt: 5_000, overThreshold: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("throttles repeats to one warn per cooldown while usage stays over", () => {
|
|
98
|
+
const hot = usage({ openCount: 1000 });
|
|
99
|
+
const warned = { lastWarnAt: 5_000, overThreshold: true };
|
|
100
|
+
|
|
101
|
+
const cooling = evaluateFdPressure([hot], options, warned, 30_000);
|
|
102
|
+
expect(cooling.action).toEqual({ kind: "none" });
|
|
103
|
+
// The warn timestamp is preserved so the cooldown measures from the warn.
|
|
104
|
+
expect(cooling.state).toEqual(warned);
|
|
105
|
+
|
|
106
|
+
const reWarn = evaluateFdPressure([hot], options, warned, 65_000);
|
|
107
|
+
expect(reWarn.action).toEqual({ kind: "warn", processes: [hot] });
|
|
108
|
+
expect(reWarn.state).toEqual({ lastWarnAt: 65_000, overThreshold: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reports recovery once, then goes quiet", () => {
|
|
112
|
+
const cool = usage({ openCount: 10 });
|
|
113
|
+
const recovered = evaluateFdPressure(
|
|
114
|
+
[cool],
|
|
115
|
+
options,
|
|
116
|
+
{ lastWarnAt: 5_000, overThreshold: true },
|
|
117
|
+
70_000,
|
|
118
|
+
);
|
|
119
|
+
expect(recovered.action).toEqual({ kind: "recovered" });
|
|
120
|
+
expect(recovered.state).toEqual({ lastWarnAt: 0, overThreshold: false });
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
evaluateFdPressure([cool], options, recovered.state, 80_000).action,
|
|
124
|
+
).toEqual({ kind: "none" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("ignores processes whose soft limit is unknown", () => {
|
|
128
|
+
const unlimited: ProcessFdUsage = {
|
|
129
|
+
pid: 3,
|
|
130
|
+
command: "vellum-qdrant",
|
|
131
|
+
openCount: 100_000,
|
|
132
|
+
softLimit: null,
|
|
133
|
+
hardLimit: null,
|
|
134
|
+
ratio: null,
|
|
135
|
+
};
|
|
136
|
+
const result = evaluateFdPressure(
|
|
137
|
+
[unlimited],
|
|
138
|
+
options,
|
|
139
|
+
createFdPressureState(),
|
|
140
|
+
1_000,
|
|
141
|
+
);
|
|
142
|
+
expect(result.action).toEqual({ kind: "none" });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open file-descriptor accounting for the container's processes, polled by the
|
|
3
|
+
* resource monitor.
|
|
4
|
+
*
|
|
5
|
+
* Descriptor exhaustion is the memory limit's quieter twin: a process that
|
|
6
|
+
* reaches its `RLIMIT_NOFILE` soft limit starts failing every `open()`,
|
|
7
|
+
* `accept()`, and `socket()` with EMFILE, so a leak surfaces as unrelated
|
|
8
|
+
* connection and file errors scattered across the daemon rather than as a
|
|
9
|
+
* single recognizable failure. The limit is per process (unlike the cgroup
|
|
10
|
+
* memory limit, which is shared), so usage is measured per PID: the open count
|
|
11
|
+
* from `/proc/<pid>/fd` against the soft limit from `/proc/<pid>/limits`.
|
|
12
|
+
*
|
|
13
|
+
* Enumerating descriptors costs a readdir plus a small read per process, which
|
|
14
|
+
* is why this polls on its own slow timer instead of riding the 250ms resource
|
|
15
|
+
* sampler. Descriptor counts climb over minutes, not milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* Reads are best-effort: a process that exits mid-walk, or one owned by another
|
|
18
|
+
* user, is skipped rather than failing the pass.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
import type { MonitoringConfig } from "../config/schemas/monitoring.js";
|
|
24
|
+
import { getLogger } from "../util/logger.js";
|
|
25
|
+
import { readProcessCommand } from "../util/process-tree.js";
|
|
26
|
+
|
|
27
|
+
const log = getLogger("file-descriptors");
|
|
28
|
+
|
|
29
|
+
/** How many over-threshold processes a single warn line reports. */
|
|
30
|
+
const WARN_PROCESS_LIMIT = 5;
|
|
31
|
+
|
|
32
|
+
export interface OpenFileLimits {
|
|
33
|
+
/** The limit actually enforced on `open()`; null when unlimited or absent. */
|
|
34
|
+
soft: number | null;
|
|
35
|
+
/** Ceiling the process may raise its soft limit to; null when unlimited. */
|
|
36
|
+
hard: number | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProcessFdUsage {
|
|
40
|
+
pid: number;
|
|
41
|
+
/** Redacted process descriptor (see {@link readProcessCommand}). */
|
|
42
|
+
command: string;
|
|
43
|
+
/** Entries in `/proc/<pid>/fd`. */
|
|
44
|
+
openCount: number;
|
|
45
|
+
softLimit: number | null;
|
|
46
|
+
hardLimit: number | null;
|
|
47
|
+
/** openCount / softLimit, or null when the soft limit is unknown. */
|
|
48
|
+
ratio: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A process whose soft limit is known, so its usage ratio is comparable. */
|
|
52
|
+
export type ProcessFdPressure = ProcessFdUsage & { ratio: number };
|
|
53
|
+
|
|
54
|
+
/** `Max open files <soft> <hard> files` in `/proc/<pid>/limits`. */
|
|
55
|
+
const MAX_OPEN_FILES_RE = /^Max open files\s+(\S+)\s+(\S+)/m;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse the descriptor limits out of `/proc/<pid>/limits`. Both fields are null
|
|
59
|
+
* when the row is missing, and an individual field is null when the kernel
|
|
60
|
+
* reports `unlimited`.
|
|
61
|
+
*/
|
|
62
|
+
export function parseOpenFileLimits(raw: string): OpenFileLimits {
|
|
63
|
+
const match = MAX_OPEN_FILES_RE.exec(raw);
|
|
64
|
+
if (!match) {
|
|
65
|
+
return { soft: null, hard: null };
|
|
66
|
+
}
|
|
67
|
+
const count = (field: string) => {
|
|
68
|
+
const parsed = parseInt(field, 10);
|
|
69
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
70
|
+
};
|
|
71
|
+
return { soft: count(match[1]), hard: count(match[2]) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Descriptor usage for one PID, or null when the process is gone or its `/proc`
|
|
76
|
+
* entry is unreadable. The count includes the descriptor `readdirSync` itself
|
|
77
|
+
* holds when reading this process's own `/proc/self/fd`, so a process's
|
|
78
|
+
* self-measurement runs one high.
|
|
79
|
+
*/
|
|
80
|
+
function readProcessFdUsage(pid: number): ProcessFdUsage | null {
|
|
81
|
+
let openCount: number;
|
|
82
|
+
try {
|
|
83
|
+
openCount = readdirSync(`/proc/${pid}/fd`).length;
|
|
84
|
+
} catch {
|
|
85
|
+
// Exited between readdir and read, or owned by another user.
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const command = readProcessCommand(pid);
|
|
89
|
+
if (command == null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let limits: OpenFileLimits = { soft: null, hard: null };
|
|
93
|
+
try {
|
|
94
|
+
limits = parseOpenFileLimits(readFileSync(`/proc/${pid}/limits`, "utf-8"));
|
|
95
|
+
} catch {
|
|
96
|
+
// Limits unreadable; the raw count is still worth reporting.
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
pid,
|
|
100
|
+
command,
|
|
101
|
+
openCount,
|
|
102
|
+
softLimit: limits.soft,
|
|
103
|
+
hardLimit: limits.hard,
|
|
104
|
+
ratio: limits.soft != null ? openCount / limits.soft : null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Descriptor usage for every readable process, closest to its soft limit first
|
|
110
|
+
* (processes with an unknown limit sort last, by raw count). Empty when
|
|
111
|
+
* `/proc` is unavailable, e.g. on macOS.
|
|
112
|
+
*/
|
|
113
|
+
export function collectFdUsage(): ProcessFdUsage[] {
|
|
114
|
+
let entries: string[];
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync("/proc").filter((entry) => /^\d+$/.test(entry));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rows: ProcessFdUsage[] = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const usage = readProcessFdUsage(Number(entry));
|
|
124
|
+
if (usage != null) {
|
|
125
|
+
rows.push(usage);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
rows.sort(
|
|
130
|
+
(a, b) => (b.ratio ?? -1) - (a.ratio ?? -1) || b.openCount - a.openCount,
|
|
131
|
+
);
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The top `limit` processes by descriptor pressure, most pressured first. */
|
|
136
|
+
export function topProcessesByFd(limit: number): ProcessFdUsage[] {
|
|
137
|
+
return collectFdUsage().slice(0, limit);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a pass concluded about the container's descriptor pressure. */
|
|
141
|
+
export type FdPressureAction =
|
|
142
|
+
| { kind: "none" }
|
|
143
|
+
| { kind: "warn"; processes: ProcessFdPressure[] }
|
|
144
|
+
| { kind: "recovered" };
|
|
145
|
+
|
|
146
|
+
/** Pass-to-pass state, so a sustained condition warns on a cooldown. */
|
|
147
|
+
export interface FdPressureState {
|
|
148
|
+
/** When the last warn was emitted; 0 when none has been. */
|
|
149
|
+
lastWarnAt: number;
|
|
150
|
+
/** Whether the previous pass found any process over the threshold. */
|
|
151
|
+
overThreshold: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createFdPressureState(): FdPressureState {
|
|
155
|
+
return { lastWarnAt: 0, overThreshold: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Decide what a pass should report, given the pressure it measured and the
|
|
160
|
+
* previous pass's state. Crossing the threshold warns immediately; staying over
|
|
161
|
+
* it re-warns at most once per `warnCooldownMs`, so a leak logs a rising count
|
|
162
|
+
* instead of a line per poll. Dropping back under reports a single recovery.
|
|
163
|
+
*/
|
|
164
|
+
export function evaluateFdPressure(
|
|
165
|
+
usage: readonly ProcessFdUsage[],
|
|
166
|
+
options: { thresholdRatio: number; warnCooldownMs: number },
|
|
167
|
+
state: FdPressureState,
|
|
168
|
+
now: number,
|
|
169
|
+
): { action: FdPressureAction; state: FdPressureState } {
|
|
170
|
+
const over = usage.filter(
|
|
171
|
+
(p): p is ProcessFdPressure =>
|
|
172
|
+
p.ratio != null && p.ratio >= options.thresholdRatio,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (over.length === 0) {
|
|
176
|
+
return {
|
|
177
|
+
action: state.overThreshold ? { kind: "recovered" } : { kind: "none" },
|
|
178
|
+
state: createFdPressureState(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (state.overThreshold && now - state.lastWarnAt < options.warnCooldownMs) {
|
|
183
|
+
return { action: { kind: "none" }, state };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
action: { kind: "warn", processes: over },
|
|
188
|
+
state: { lastWarnAt: now, overThreshold: true },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function logFdPressure(action: FdPressureAction, thresholdRatio: number): void {
|
|
193
|
+
if (action.kind === "warn") {
|
|
194
|
+
log.warn(
|
|
195
|
+
{
|
|
196
|
+
thresholdRatio,
|
|
197
|
+
processCount: action.processes.length,
|
|
198
|
+
// PID only, no command: a command line can carry secrets passed as
|
|
199
|
+
// arguments, and this line lands in the rotating workspace log.
|
|
200
|
+
processes: action.processes.slice(0, WARN_PROCESS_LIMIT).map((p) => ({
|
|
201
|
+
pid: p.pid,
|
|
202
|
+
openCount: p.openCount,
|
|
203
|
+
softLimit: p.softLimit,
|
|
204
|
+
hardLimit: p.hardLimit,
|
|
205
|
+
ratio: Math.round(p.ratio * 1000) / 1000,
|
|
206
|
+
})),
|
|
207
|
+
},
|
|
208
|
+
"Processes near their open file-descriptor limit",
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (action.kind === "recovered") {
|
|
213
|
+
log.info(
|
|
214
|
+
{ thresholdRatio },
|
|
215
|
+
"Open file-descriptor usage back under threshold",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface FileDescriptorMonitorHandle {
|
|
221
|
+
stop: () => void;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Start the descriptor poller. The first pass runs one interval in, not at
|
|
226
|
+
* boot: descriptor counts are still ramping while the daemon opens its sockets
|
|
227
|
+
* and database handles, and the walk is I/O the boot path does not need.
|
|
228
|
+
*
|
|
229
|
+
* The timer is unref'd: the resource sampler is the monitor process's
|
|
230
|
+
* keep-alive, and this loop must not extend its life on its own.
|
|
231
|
+
*/
|
|
232
|
+
export function startFileDescriptorMonitor(
|
|
233
|
+
config: MonitoringConfig,
|
|
234
|
+
clock: () => number = Date.now,
|
|
235
|
+
): FileDescriptorMonitorHandle {
|
|
236
|
+
let state = createFdPressureState();
|
|
237
|
+
|
|
238
|
+
const poll = () => {
|
|
239
|
+
try {
|
|
240
|
+
const result = evaluateFdPressure(
|
|
241
|
+
collectFdUsage(),
|
|
242
|
+
{
|
|
243
|
+
thresholdRatio: config.highFdThresholdRatio,
|
|
244
|
+
warnCooldownMs: config.fdWarnCooldownMs,
|
|
245
|
+
},
|
|
246
|
+
state,
|
|
247
|
+
clock(),
|
|
248
|
+
);
|
|
249
|
+
state = result.state;
|
|
250
|
+
logFdPressure(result.action, config.highFdThresholdRatio);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
log.warn({ err }, "File-descriptor poll failed");
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const timer = setInterval(poll, config.fdPollIntervalMs);
|
|
257
|
+
timer.unref?.();
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
stop: () => clearInterval(timer),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { readdirSync, readFileSync } from "node:fs";
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { readProcessCommand } from "../util/process-tree.js";
|
|
15
15
|
|
|
16
16
|
/** Linux page size assumed when converting `/proc/<pid>/statm` pages to bytes. */
|
|
17
17
|
const PAGE_SIZE_BYTES = 4096;
|
|
@@ -101,16 +101,9 @@ export function topProcessesByMemory(limit: number): ProcessMemory[] {
|
|
|
101
101
|
if (mem == null) {
|
|
102
102
|
continue;
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let command: string;
|
|
108
|
-
try {
|
|
109
|
-
const raw = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
110
|
-
command =
|
|
111
|
-
deriveName(raw.split("\0").filter(Boolean).join(" ")) || `pid ${pid}`;
|
|
112
|
-
} catch {
|
|
113
|
-
// Process exited between readdir and read — skip.
|
|
104
|
+
const command = readProcessCommand(pid);
|
|
105
|
+
if (command == null) {
|
|
106
|
+
// Process exited between readdir and read; skip it.
|
|
114
107
|
continue;
|
|
115
108
|
}
|
|
116
109
|
rows.push({ pid, command, ...mem });
|
|
@@ -36,6 +36,7 @@ import { getLogger } from "../util/logger.js";
|
|
|
36
36
|
import { getMonitoringDataDir } from "../util/platform.js";
|
|
37
37
|
import { buildProcessTree, listProcesses } from "../util/process-tree.js";
|
|
38
38
|
import { readActiveConversations } from "./active-conversations.js";
|
|
39
|
+
import { topProcessesByFd } from "./file-descriptors.js";
|
|
39
40
|
import { getTrackedDataFiles, readFileResidency } from "./page-cache.js";
|
|
40
41
|
import { topProcessesByMemory } from "./process-memory.js";
|
|
41
42
|
import { prunePrefixedJsonFiles } from "./prune-snapshots.js";
|
|
@@ -176,6 +177,10 @@ export async function writeSnapshot(
|
|
|
176
177
|
// Slab memory belongs to no process; without this, cgroup usage that
|
|
177
178
|
// exceeds the per-process sum has no visible owner.
|
|
178
179
|
topSlabCaches: topSlabCaches(10),
|
|
180
|
+
// Descriptor pressure, ranked against each process's own soft limit. An
|
|
181
|
+
// EMFILE storm and a memory spike look alike from the outside (unrelated
|
|
182
|
+
// failures across the daemon), so the capture records both.
|
|
183
|
+
topProcessesByFd: topProcessesByFd(15),
|
|
179
184
|
processTree: tree,
|
|
180
185
|
};
|
|
181
186
|
|