discoclaw 0.1.0
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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
3
|
+
const INTERRUPTED_GRACEFUL = '*(Interrupted \u2014 bot is restarting.)*';
|
|
4
|
+
const INTERRUPTED_COLD = '*(Interrupted \u2014 bot was restarted.)*';
|
|
5
|
+
// --- Module state ---
|
|
6
|
+
const registry = new Map();
|
|
7
|
+
let shuttingDown = false;
|
|
8
|
+
let dataFilePath = null;
|
|
9
|
+
// --- Public API ---
|
|
10
|
+
/**
|
|
11
|
+
* Configure the path for the persistent inflight.json file.
|
|
12
|
+
* Call once at startup after dataDir is resolved.
|
|
13
|
+
*/
|
|
14
|
+
export function setDataFilePath(filePath) {
|
|
15
|
+
dataFilePath = filePath;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Register an in-progress Discord reply. Returns a disposer function
|
|
19
|
+
* that unregisters the entry. Use in try/finally to guarantee cleanup.
|
|
20
|
+
*/
|
|
21
|
+
export function registerInFlightReply(reply, channelId, messageId, label) {
|
|
22
|
+
if (shuttingDown) {
|
|
23
|
+
// Drain already happened — immediately edit and discard.
|
|
24
|
+
reply.edit({ content: INTERRUPTED_GRACEFUL, allowedMentions: NO_MENTIONS }).catch(() => { });
|
|
25
|
+
return () => { };
|
|
26
|
+
}
|
|
27
|
+
const key = `${channelId}:${messageId}`;
|
|
28
|
+
registry.set(key, { reply, channelId, messageId, label });
|
|
29
|
+
// Best-effort persist for cold-start recovery.
|
|
30
|
+
persistAdd({ channelId, messageId }).catch(() => { });
|
|
31
|
+
let disposed = false;
|
|
32
|
+
return () => {
|
|
33
|
+
if (disposed)
|
|
34
|
+
return;
|
|
35
|
+
disposed = true;
|
|
36
|
+
registry.delete(key);
|
|
37
|
+
persistRemove({ channelId, messageId }).catch(() => { });
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Number of currently tracked in-flight replies.
|
|
42
|
+
*/
|
|
43
|
+
export function inFlightReplyCount() {
|
|
44
|
+
return registry.size;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Returns true if there is at least one in-flight reply for the given channelId.
|
|
48
|
+
*/
|
|
49
|
+
export function hasInFlightForChannel(channelId) {
|
|
50
|
+
for (const entry of registry.values()) {
|
|
51
|
+
if (entry.channelId === channelId)
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns true once drainInFlightReplies has been called.
|
|
58
|
+
* Streaming loops should check this and no-op if true,
|
|
59
|
+
* preventing the "Interrupted" text from being overwritten.
|
|
60
|
+
*/
|
|
61
|
+
export function isShuttingDown() {
|
|
62
|
+
return shuttingDown;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Atomically snapshot and clear the registry, set the shutdown flag,
|
|
66
|
+
* then edit all tracked replies in parallel with a timeout.
|
|
67
|
+
*/
|
|
68
|
+
export async function drainInFlightReplies(opts) {
|
|
69
|
+
const timeoutMs = opts?.timeoutMs ?? 3000;
|
|
70
|
+
const log = opts?.log;
|
|
71
|
+
// Atomic snapshot + clear + flag.
|
|
72
|
+
const entries = Array.from(registry.values());
|
|
73
|
+
registry.clear();
|
|
74
|
+
shuttingDown = true;
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
// Clear persistent file even if registry was empty (belt-and-suspenders).
|
|
77
|
+
await persistClear().catch(() => { });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
log?.info({ count: entries.length }, 'inflight:drain editing in-flight replies');
|
|
81
|
+
const editPromises = entries.map((entry) => entry.reply
|
|
82
|
+
.edit({ content: INTERRUPTED_GRACEFUL, allowedMentions: NO_MENTIONS })
|
|
83
|
+
.then(() => {
|
|
84
|
+
log?.info({ channelId: entry.channelId, messageId: entry.messageId, label: entry.label }, 'inflight:drain edited');
|
|
85
|
+
})
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
log?.warn({ err, channelId: entry.channelId, messageId: entry.messageId }, 'inflight:drain edit failed');
|
|
88
|
+
}));
|
|
89
|
+
await Promise.race([
|
|
90
|
+
Promise.allSettled(editPromises),
|
|
91
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
92
|
+
]);
|
|
93
|
+
await persistClear().catch(() => { });
|
|
94
|
+
}
|
|
95
|
+
// --- Cold-start recovery ---
|
|
96
|
+
/**
|
|
97
|
+
* Load orphaned reply entries from the persistent file (for cold-start recovery).
|
|
98
|
+
*/
|
|
99
|
+
export function loadOrphanedReplies(filePath) {
|
|
100
|
+
return fs.readFile(filePath, 'utf-8')
|
|
101
|
+
.then((raw) => {
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (!Array.isArray(parsed))
|
|
104
|
+
return [];
|
|
105
|
+
return parsed.filter((e) => typeof e === 'object' && e !== null &&
|
|
106
|
+
typeof e.channelId === 'string' &&
|
|
107
|
+
typeof e.messageId === 'string');
|
|
108
|
+
})
|
|
109
|
+
.catch(() => []);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* On startup, detect and clean up any orphaned messages left by a previous unclean exit.
|
|
113
|
+
*/
|
|
114
|
+
export async function cleanupOrphanedReplies(opts) {
|
|
115
|
+
const { client, dataFilePath: filePath, log, timeoutMs = 5000 } = opts;
|
|
116
|
+
const orphans = await loadOrphanedReplies(filePath);
|
|
117
|
+
if (orphans.length === 0)
|
|
118
|
+
return;
|
|
119
|
+
log?.info({ count: orphans.length }, 'inflight:cold-start cleaning up orphaned replies');
|
|
120
|
+
const editPromises = orphans.map(async (orphan) => {
|
|
121
|
+
try {
|
|
122
|
+
const channel = await client.channels.fetch(orphan.channelId);
|
|
123
|
+
if (!channel || typeof channel.messages?.fetch !== 'function') {
|
|
124
|
+
log?.warn({ channelId: orphan.channelId }, 'inflight:cold-start channel not fetchable');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const message = await channel.messages.fetch(orphan.messageId);
|
|
128
|
+
await message.edit({ content: INTERRUPTED_COLD, allowedMentions: NO_MENTIONS });
|
|
129
|
+
log?.info({ channelId: orphan.channelId, messageId: orphan.messageId }, 'inflight:cold-start edited orphan');
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
log?.warn({ err, channelId: orphan.channelId, messageId: orphan.messageId }, 'inflight:cold-start orphan cleanup failed');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
await Promise.race([
|
|
136
|
+
Promise.allSettled(editPromises),
|
|
137
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
138
|
+
]);
|
|
139
|
+
// Clear the file after processing.
|
|
140
|
+
try {
|
|
141
|
+
await fs.unlink(filePath);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Already gone or inaccessible.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// --- Persistence helpers (serial queue + atomic write-tmp-rename) ---
|
|
148
|
+
// Serial promise queue prevents read-modify-write races when multiple
|
|
149
|
+
// handlers register/dispose concurrently (maxConcurrentInvocations > 1).
|
|
150
|
+
let persistQueue = Promise.resolve();
|
|
151
|
+
function enqueuePersist(fn) {
|
|
152
|
+
const next = persistQueue.then(fn, fn);
|
|
153
|
+
persistQueue = next.then(() => { }, () => { });
|
|
154
|
+
return next;
|
|
155
|
+
}
|
|
156
|
+
async function readPersistedEntries() {
|
|
157
|
+
if (!dataFilePath)
|
|
158
|
+
return [];
|
|
159
|
+
try {
|
|
160
|
+
const raw = await fs.readFile(dataFilePath, 'utf-8');
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
if (!Array.isArray(parsed))
|
|
163
|
+
return [];
|
|
164
|
+
return parsed;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function writePersistedEntries(entries) {
|
|
171
|
+
if (!dataFilePath)
|
|
172
|
+
return;
|
|
173
|
+
const tmpPath = `${dataFilePath}.tmp.${process.pid}`;
|
|
174
|
+
await fs.writeFile(tmpPath, JSON.stringify(entries) + '\n', 'utf-8');
|
|
175
|
+
await fs.rename(tmpPath, dataFilePath);
|
|
176
|
+
}
|
|
177
|
+
function persistAdd(entry) {
|
|
178
|
+
return enqueuePersist(async () => {
|
|
179
|
+
if (!dataFilePath)
|
|
180
|
+
return;
|
|
181
|
+
const entries = await readPersistedEntries();
|
|
182
|
+
const key = `${entry.channelId}:${entry.messageId}`;
|
|
183
|
+
// Avoid duplicates.
|
|
184
|
+
if (!entries.some((e) => `${e.channelId}:${e.messageId}` === key)) {
|
|
185
|
+
entries.push(entry);
|
|
186
|
+
}
|
|
187
|
+
await writePersistedEntries(entries);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function persistRemove(entry) {
|
|
191
|
+
return enqueuePersist(async () => {
|
|
192
|
+
if (!dataFilePath)
|
|
193
|
+
return;
|
|
194
|
+
const entries = await readPersistedEntries();
|
|
195
|
+
const key = `${entry.channelId}:${entry.messageId}`;
|
|
196
|
+
const filtered = entries.filter((e) => `${e.channelId}:${e.messageId}` !== key);
|
|
197
|
+
await writePersistedEntries(filtered);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function persistClear() {
|
|
201
|
+
return enqueuePersist(async () => {
|
|
202
|
+
if (!dataFilePath)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
await fs.unlink(dataFilePath);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// File doesn't exist or inaccessible — fine.
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// --- Test helpers ---
|
|
213
|
+
/**
|
|
214
|
+
* Wait for all queued persistence operations to complete.
|
|
215
|
+
* Only for use in tests — avoids flaky setTimeout-based waits.
|
|
216
|
+
*/
|
|
217
|
+
export function _waitForPendingPersists() {
|
|
218
|
+
return persistQueue;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Reset module state. Only for use in tests.
|
|
222
|
+
*/
|
|
223
|
+
export function _resetForTest() {
|
|
224
|
+
registry.clear();
|
|
225
|
+
shuttingDown = false;
|
|
226
|
+
dataFilePath = null;
|
|
227
|
+
persistQueue = Promise.resolve();
|
|
228
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { registerInFlightReply, inFlightReplyCount, hasInFlightForChannel, isShuttingDown, drainInFlightReplies, loadOrphanedReplies, cleanupOrphanedReplies, setDataFilePath, _waitForPendingPersists, _resetForTest, } from './inflight-replies.js';
|
|
6
|
+
function mockReply() {
|
|
7
|
+
return { edit: vi.fn().mockResolvedValue(undefined) };
|
|
8
|
+
}
|
|
9
|
+
function mockLog() {
|
|
10
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
11
|
+
}
|
|
12
|
+
let tmpDir;
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
_resetForTest();
|
|
15
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inflight-test-'));
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
_resetForTest();
|
|
19
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
20
|
+
});
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Disposer pattern
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
describe('registerInFlightReply', () => {
|
|
25
|
+
it('returns a disposer function that unregisters the entry', () => {
|
|
26
|
+
const reply = mockReply();
|
|
27
|
+
const dispose = registerInFlightReply(reply, 'ch1', 'msg1', 'test');
|
|
28
|
+
expect(inFlightReplyCount()).toBe(1);
|
|
29
|
+
dispose();
|
|
30
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
it('double-dispose is a no-op', () => {
|
|
33
|
+
const reply = mockReply();
|
|
34
|
+
const dispose = registerInFlightReply(reply, 'ch1', 'msg1', 'test');
|
|
35
|
+
expect(inFlightReplyCount()).toBe(1);
|
|
36
|
+
dispose();
|
|
37
|
+
dispose(); // second call should not throw or change count
|
|
38
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
it('tracks multiple entries independently', () => {
|
|
41
|
+
const dispose1 = registerInFlightReply(mockReply(), 'ch1', 'msg1', 'a');
|
|
42
|
+
const dispose2 = registerInFlightReply(mockReply(), 'ch2', 'msg2', 'b');
|
|
43
|
+
expect(inFlightReplyCount()).toBe(2);
|
|
44
|
+
dispose1();
|
|
45
|
+
expect(inFlightReplyCount()).toBe(1);
|
|
46
|
+
dispose2();
|
|
47
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it('registration after drain immediately edits and returns no-op disposer', async () => {
|
|
50
|
+
await drainInFlightReplies();
|
|
51
|
+
expect(isShuttingDown()).toBe(true);
|
|
52
|
+
const reply = mockReply();
|
|
53
|
+
const dispose = registerInFlightReply(reply, 'ch1', 'msg1', 'late');
|
|
54
|
+
// Should have been immediately edited.
|
|
55
|
+
expect(reply.edit).toHaveBeenCalledOnce();
|
|
56
|
+
expect(reply.edit.mock.calls[0][0].content).toContain('Interrupted');
|
|
57
|
+
// Disposer is a no-op; count stays 0.
|
|
58
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
59
|
+
dispose();
|
|
60
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// hasInFlightForChannel
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
describe('hasInFlightForChannel', () => {
|
|
67
|
+
it('returns false with no registrations', () => {
|
|
68
|
+
expect(hasInFlightForChannel('ch1')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
it('returns true when a reply is registered for that channelId', () => {
|
|
71
|
+
registerInFlightReply(mockReply(), 'ch1', 'msg1', 'test');
|
|
72
|
+
expect(hasInFlightForChannel('ch1')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it('returns false for a different channelId', () => {
|
|
75
|
+
registerInFlightReply(mockReply(), 'ch1', 'msg1', 'test');
|
|
76
|
+
expect(hasInFlightForChannel('ch2')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('returns false after the disposer is called', () => {
|
|
79
|
+
const dispose = registerInFlightReply(mockReply(), 'ch1', 'msg1', 'test');
|
|
80
|
+
expect(hasInFlightForChannel('ch1')).toBe(true);
|
|
81
|
+
dispose();
|
|
82
|
+
expect(hasInFlightForChannel('ch1')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// drainInFlightReplies
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
describe('drainInFlightReplies', () => {
|
|
89
|
+
it('edits all registered replies with interrupted message', async () => {
|
|
90
|
+
const reply1 = mockReply();
|
|
91
|
+
const reply2 = mockReply();
|
|
92
|
+
registerInFlightReply(reply1, 'ch1', 'msg1', 'a');
|
|
93
|
+
registerInFlightReply(reply2, 'ch2', 'msg2', 'b');
|
|
94
|
+
expect(inFlightReplyCount()).toBe(2);
|
|
95
|
+
await drainInFlightReplies();
|
|
96
|
+
expect(reply1.edit).toHaveBeenCalledOnce();
|
|
97
|
+
expect(reply1.edit.mock.calls[0][0].content).toContain('Interrupted');
|
|
98
|
+
expect(reply1.edit.mock.calls[0][0].content).toContain('restarting');
|
|
99
|
+
expect(reply2.edit).toHaveBeenCalledOnce();
|
|
100
|
+
expect(reply2.edit.mock.calls[0][0].content).toContain('Interrupted');
|
|
101
|
+
});
|
|
102
|
+
it('sets isShuttingDown flag to true', async () => {
|
|
103
|
+
expect(isShuttingDown()).toBe(false);
|
|
104
|
+
await drainInFlightReplies();
|
|
105
|
+
expect(isShuttingDown()).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it('clears registry (second drain is a no-op)', async () => {
|
|
108
|
+
const reply = mockReply();
|
|
109
|
+
registerInFlightReply(reply, 'ch1', 'msg1', 'a');
|
|
110
|
+
await drainInFlightReplies();
|
|
111
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
112
|
+
expect(reply.edit).toHaveBeenCalledOnce();
|
|
113
|
+
// Second drain should not edit again.
|
|
114
|
+
await drainInFlightReplies();
|
|
115
|
+
expect(reply.edit).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
it('edit failures do not block other edits or throw', async () => {
|
|
118
|
+
const reply1 = { edit: vi.fn().mockRejectedValue(new Error('Discord error')) };
|
|
119
|
+
const reply2 = mockReply();
|
|
120
|
+
registerInFlightReply(reply1, 'ch1', 'msg1', 'a');
|
|
121
|
+
registerInFlightReply(reply2, 'ch2', 'msg2', 'b');
|
|
122
|
+
// Should not throw.
|
|
123
|
+
const log = mockLog();
|
|
124
|
+
await drainInFlightReplies({ log });
|
|
125
|
+
// reply2 should still have been edited.
|
|
126
|
+
expect(reply2.edit).toHaveBeenCalledOnce();
|
|
127
|
+
// The warning should have been logged.
|
|
128
|
+
expect(log.warn).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it('respects timeout (does not hang on slow edits)', async () => {
|
|
131
|
+
const slowReply = {
|
|
132
|
+
edit: vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 10_000))),
|
|
133
|
+
};
|
|
134
|
+
registerInFlightReply(slowReply, 'ch1', 'msg1', 'slow');
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
await drainInFlightReplies({ timeoutMs: 100 });
|
|
137
|
+
const elapsed = Date.now() - start;
|
|
138
|
+
// Should complete in roughly the timeout, not 10s.
|
|
139
|
+
expect(elapsed).toBeLessThan(2000);
|
|
140
|
+
expect(isShuttingDown()).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it('drain with empty registry is a no-op', async () => {
|
|
143
|
+
expect(inFlightReplyCount()).toBe(0);
|
|
144
|
+
await drainInFlightReplies();
|
|
145
|
+
expect(isShuttingDown()).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Persistent file
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
describe('persistent file', () => {
|
|
152
|
+
it('register writes entry, dispose removes it', async () => {
|
|
153
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
154
|
+
setDataFilePath(filePath);
|
|
155
|
+
const dispose = registerInFlightReply(mockReply(), 'ch1', 'msg1', 'test');
|
|
156
|
+
await _waitForPendingPersists();
|
|
157
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
158
|
+
const entries = JSON.parse(raw);
|
|
159
|
+
expect(entries).toHaveLength(1);
|
|
160
|
+
expect(entries[0]).toEqual({ channelId: 'ch1', messageId: 'msg1' });
|
|
161
|
+
dispose();
|
|
162
|
+
await _waitForPendingPersists();
|
|
163
|
+
const raw2 = await fs.readFile(filePath, 'utf-8');
|
|
164
|
+
const entries2 = JSON.parse(raw2);
|
|
165
|
+
expect(entries2).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
it('drain clears the persistent file', async () => {
|
|
168
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
169
|
+
setDataFilePath(filePath);
|
|
170
|
+
registerInFlightReply(mockReply(), 'ch1', 'msg1', 'test');
|
|
171
|
+
await _waitForPendingPersists();
|
|
172
|
+
// File should exist.
|
|
173
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
174
|
+
expect(stat).not.toBeNull();
|
|
175
|
+
await drainInFlightReplies();
|
|
176
|
+
// File should be removed.
|
|
177
|
+
const stat2 = await fs.stat(filePath).catch(() => null);
|
|
178
|
+
expect(stat2).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// loadOrphanedReplies
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
describe('loadOrphanedReplies', () => {
|
|
185
|
+
it('returns entries from a valid file', async () => {
|
|
186
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
187
|
+
await fs.writeFile(filePath, JSON.stringify([
|
|
188
|
+
{ channelId: 'ch1', messageId: 'msg1' },
|
|
189
|
+
{ channelId: 'ch2', messageId: 'msg2' },
|
|
190
|
+
]));
|
|
191
|
+
const result = await loadOrphanedReplies(filePath);
|
|
192
|
+
expect(result).toHaveLength(2);
|
|
193
|
+
expect(result[0]).toEqual({ channelId: 'ch1', messageId: 'msg1' });
|
|
194
|
+
});
|
|
195
|
+
it('returns empty array for missing file', async () => {
|
|
196
|
+
const result = await loadOrphanedReplies(path.join(tmpDir, 'nonexistent.json'));
|
|
197
|
+
expect(result).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
it('returns empty array for corrupt file', async () => {
|
|
200
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
201
|
+
await fs.writeFile(filePath, 'not json');
|
|
202
|
+
const result = await loadOrphanedReplies(filePath);
|
|
203
|
+
expect(result).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
it('filters out malformed entries', async () => {
|
|
206
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
207
|
+
await fs.writeFile(filePath, JSON.stringify([
|
|
208
|
+
{ channelId: 'ch1', messageId: 'msg1' },
|
|
209
|
+
{ bad: true },
|
|
210
|
+
null,
|
|
211
|
+
{ channelId: 'ch2' }, // missing messageId
|
|
212
|
+
]));
|
|
213
|
+
const result = await loadOrphanedReplies(filePath);
|
|
214
|
+
expect(result).toHaveLength(1);
|
|
215
|
+
expect(result[0]).toEqual({ channelId: 'ch1', messageId: 'msg1' });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// cleanupOrphanedReplies (cold-start recovery)
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
describe('cleanupOrphanedReplies', () => {
|
|
222
|
+
it('fetches and edits orphaned messages, then clears file', async () => {
|
|
223
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
224
|
+
await fs.writeFile(filePath, JSON.stringify([
|
|
225
|
+
{ channelId: 'ch1', messageId: 'msg1' },
|
|
226
|
+
]));
|
|
227
|
+
const editFn = vi.fn().mockResolvedValue(undefined);
|
|
228
|
+
const mockMessage = { edit: editFn };
|
|
229
|
+
const mockChannel = {
|
|
230
|
+
messages: { fetch: vi.fn().mockResolvedValue(mockMessage) },
|
|
231
|
+
};
|
|
232
|
+
const client = {
|
|
233
|
+
channels: { fetch: vi.fn().mockResolvedValue(mockChannel) },
|
|
234
|
+
};
|
|
235
|
+
const log = mockLog();
|
|
236
|
+
await cleanupOrphanedReplies({ client, dataFilePath: filePath, log });
|
|
237
|
+
expect(client.channels.fetch).toHaveBeenCalledWith('ch1');
|
|
238
|
+
expect(mockChannel.messages.fetch).toHaveBeenCalledWith('msg1');
|
|
239
|
+
expect(editFn).toHaveBeenCalledOnce();
|
|
240
|
+
expect(editFn.mock.calls[0][0].content).toContain('Interrupted');
|
|
241
|
+
expect(editFn.mock.calls[0][0].content).toContain('was restarted');
|
|
242
|
+
expect(log.info).toHaveBeenCalled();
|
|
243
|
+
// File should be deleted.
|
|
244
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
245
|
+
expect(stat).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
it('is a no-op when file is missing', async () => {
|
|
248
|
+
const filePath = path.join(tmpDir, 'nonexistent.json');
|
|
249
|
+
const client = {
|
|
250
|
+
channels: { fetch: vi.fn() },
|
|
251
|
+
};
|
|
252
|
+
const log = mockLog();
|
|
253
|
+
await cleanupOrphanedReplies({ client, dataFilePath: filePath, log });
|
|
254
|
+
expect(client.channels.fetch).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
it('handles stale/unfetchable entries gracefully', async () => {
|
|
257
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
258
|
+
await fs.writeFile(filePath, JSON.stringify([
|
|
259
|
+
{ channelId: 'ch1', messageId: 'msg1' },
|
|
260
|
+
{ channelId: 'ch2', messageId: 'msg2' },
|
|
261
|
+
]));
|
|
262
|
+
const editFn = vi.fn().mockResolvedValue(undefined);
|
|
263
|
+
const mockMessage = { edit: editFn };
|
|
264
|
+
const mockChannel = {
|
|
265
|
+
messages: { fetch: vi.fn().mockResolvedValue(mockMessage) },
|
|
266
|
+
};
|
|
267
|
+
const client = {
|
|
268
|
+
channels: {
|
|
269
|
+
fetch: vi.fn()
|
|
270
|
+
.mockResolvedValueOnce(mockChannel)
|
|
271
|
+
.mockRejectedValueOnce(new Error('Unknown Channel')),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
const log = mockLog();
|
|
275
|
+
await cleanupOrphanedReplies({ client, dataFilePath: filePath, log });
|
|
276
|
+
// First entry edited, second failed gracefully.
|
|
277
|
+
expect(editFn).toHaveBeenCalledOnce();
|
|
278
|
+
expect(log.warn).toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
it('respects timeout', async () => {
|
|
281
|
+
const filePath = path.join(tmpDir, 'inflight.json');
|
|
282
|
+
await fs.writeFile(filePath, JSON.stringify([
|
|
283
|
+
{ channelId: 'ch1', messageId: 'msg1' },
|
|
284
|
+
]));
|
|
285
|
+
const client = {
|
|
286
|
+
channels: {
|
|
287
|
+
fetch: vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 10_000))),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
await cleanupOrphanedReplies({ client, dataFilePath: filePath, timeoutMs: 100 });
|
|
292
|
+
const elapsed = Date.now() - start;
|
|
293
|
+
expect(elapsed).toBeLessThan(2000);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
function isAllowedStart(ch, opts) {
|
|
2
|
+
if (opts.arrayOnly)
|
|
3
|
+
return ch === '[';
|
|
4
|
+
if (opts.objectOnly)
|
|
5
|
+
return ch === '{';
|
|
6
|
+
return ch === '[' || ch === '{';
|
|
7
|
+
}
|
|
8
|
+
function parseBalancedJsonCandidate(raw, start) {
|
|
9
|
+
let depth = 0;
|
|
10
|
+
let inString = false;
|
|
11
|
+
let escaped = false;
|
|
12
|
+
for (let i = start; i < raw.length; i++) {
|
|
13
|
+
const ch = raw[i];
|
|
14
|
+
if (inString) {
|
|
15
|
+
if (escaped) {
|
|
16
|
+
escaped = false;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (ch === '\\') {
|
|
20
|
+
escaped = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '"') {
|
|
24
|
+
inString = false;
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (ch === '"') {
|
|
29
|
+
inString = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '{' || ch === '[') {
|
|
33
|
+
depth++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ch === '}' || ch === ']') {
|
|
37
|
+
depth--;
|
|
38
|
+
if (depth === 0) {
|
|
39
|
+
return raw.slice(start, i + 1);
|
|
40
|
+
}
|
|
41
|
+
if (depth < 0)
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function extractFromText(raw, opts) {
|
|
48
|
+
for (let i = 0; i < raw.length; i++) {
|
|
49
|
+
const ch = raw[i];
|
|
50
|
+
if (!isAllowedStart(ch, opts))
|
|
51
|
+
continue;
|
|
52
|
+
const candidate = parseBalancedJsonCandidate(raw, i);
|
|
53
|
+
if (!candidate)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(candidate);
|
|
57
|
+
if (opts.arrayOnly && !Array.isArray(parsed))
|
|
58
|
+
continue;
|
|
59
|
+
if (opts.objectOnly && (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object')) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Keep scanning for the next candidate.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function extractFencedBlocks(raw) {
|
|
71
|
+
const blocks = [];
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
while (cursor < raw.length) {
|
|
74
|
+
const open = raw.indexOf('```', cursor);
|
|
75
|
+
if (open === -1)
|
|
76
|
+
break;
|
|
77
|
+
const langLineEnd = raw.indexOf('\n', open + 3);
|
|
78
|
+
if (langLineEnd === -1)
|
|
79
|
+
break;
|
|
80
|
+
const lang = raw.slice(open + 3, langLineEnd).trim().toLowerCase();
|
|
81
|
+
const close = raw.indexOf('```', langLineEnd + 1);
|
|
82
|
+
if (close === -1)
|
|
83
|
+
break;
|
|
84
|
+
blocks.push({
|
|
85
|
+
lang,
|
|
86
|
+
body: raw.slice(langLineEnd + 1, close),
|
|
87
|
+
});
|
|
88
|
+
cursor = close + 3;
|
|
89
|
+
}
|
|
90
|
+
return blocks;
|
|
91
|
+
}
|
|
92
|
+
function isJsonFenceLanguage(lang) {
|
|
93
|
+
if (!lang)
|
|
94
|
+
return true;
|
|
95
|
+
return lang === 'json' || lang === 'jsonc' || lang === 'javascript' || lang === 'js';
|
|
96
|
+
}
|
|
97
|
+
export function extractFirstJsonValue(raw, opts = {}) {
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
if (!trimmed)
|
|
100
|
+
return null;
|
|
101
|
+
const fencedBlocks = extractFencedBlocks(trimmed);
|
|
102
|
+
for (const block of fencedBlocks) {
|
|
103
|
+
if (!isJsonFenceLanguage(block.lang))
|
|
104
|
+
continue;
|
|
105
|
+
const fromFence = extractFromText(block.body, opts);
|
|
106
|
+
if (fromFence)
|
|
107
|
+
return fromFence;
|
|
108
|
+
}
|
|
109
|
+
return extractFromText(trimmed, opts);
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class KeyedQueue {
|
|
2
|
+
tails = new Map();
|
|
3
|
+
async run(key, fn) {
|
|
4
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
5
|
+
let release;
|
|
6
|
+
const current = new Promise((r) => {
|
|
7
|
+
release = r;
|
|
8
|
+
});
|
|
9
|
+
const tail = prev.then(() => current, () => current);
|
|
10
|
+
this.tails.set(key, tail);
|
|
11
|
+
await prev;
|
|
12
|
+
try {
|
|
13
|
+
return await fn();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
release();
|
|
17
|
+
if (this.tails.get(key) === tail) {
|
|
18
|
+
this.tails.delete(key);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|