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,171 @@
|
|
|
1
|
+
import { withTaskLifecycleLock } from './task-lifecycle.js';
|
|
2
|
+
import { closeTaskThread, createTaskThread, ensureUnarchived, findExistingThreadForTask, isTaskThreadAlreadyClosed, isThreadArchived, updateTaskStarterMessage, updateTaskThreadName, updateTaskThreadTags, } from './thread-ops.js';
|
|
3
|
+
import { getThreadIdFromTask } from './thread-helpers.js';
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
const n = ms ?? 0;
|
|
6
|
+
if (n <= 0)
|
|
7
|
+
return Promise.resolve();
|
|
8
|
+
return new Promise((r) => setTimeout(r, n));
|
|
9
|
+
}
|
|
10
|
+
async function applyPhase1CreateMissingThreads(ctx, tasksById, plannedTaskIds) {
|
|
11
|
+
for (const taskId of plannedTaskIds) {
|
|
12
|
+
const task = tasksById.get(taskId);
|
|
13
|
+
if (!task)
|
|
14
|
+
continue;
|
|
15
|
+
await withTaskLifecycleLock(task.id, async () => {
|
|
16
|
+
const latestTask = ctx.store.get(task.id) ?? task;
|
|
17
|
+
if (getThreadIdFromTask(latestTask) ||
|
|
18
|
+
latestTask.status === 'closed' ||
|
|
19
|
+
(latestTask.labels ?? []).includes('no-thread')) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const existing = await findExistingThreadForTask(ctx.forum, latestTask.id, { archivedLimit: ctx.archivedDedupeLimit });
|
|
24
|
+
if (existing) {
|
|
25
|
+
try {
|
|
26
|
+
ctx.taskService.update(latestTask.id, { externalRef: `discord:${existing}` });
|
|
27
|
+
ctx.log?.info({ taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfilled');
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
ctx.log?.warn({ err, taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfill failed');
|
|
31
|
+
ctx.counters.warnings++;
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const threadId = await createTaskThread(ctx.forum, latestTask, ctx.tagMap, ctx.mentionUserId);
|
|
36
|
+
try {
|
|
37
|
+
ctx.taskService.update(latestTask.id, { externalRef: `discord:${threadId}` });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 external-ref update failed');
|
|
41
|
+
ctx.counters.warnings++;
|
|
42
|
+
}
|
|
43
|
+
ctx.counters.threadsCreated++;
|
|
44
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase1 thread created');
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 failed');
|
|
48
|
+
ctx.counters.warnings++;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
await sleep(ctx.throttleMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function applyPhase2FixBlockedStatus(ctx, tasksById, plannedTaskIds) {
|
|
55
|
+
for (const taskId of plannedTaskIds) {
|
|
56
|
+
const task = tasksById.get(taskId);
|
|
57
|
+
if (!task)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
ctx.taskService.update(task.id, { status: 'blocked' });
|
|
61
|
+
task.status = 'blocked';
|
|
62
|
+
ctx.counters.statusesUpdated++;
|
|
63
|
+
ctx.log?.info({ taskId: task.id }, 'task-sync:phase2 status updated to blocked');
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
ctx.log?.warn({ err, taskId: task.id }, 'task-sync:phase2 failed');
|
|
67
|
+
ctx.counters.warnings++;
|
|
68
|
+
}
|
|
69
|
+
await sleep(ctx.throttleMs);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function applyPhase3SyncActiveThreads(ctx, tasksById, plannedTaskIds) {
|
|
73
|
+
for (const taskId of plannedTaskIds) {
|
|
74
|
+
const task = tasksById.get(taskId);
|
|
75
|
+
if (!task)
|
|
76
|
+
continue;
|
|
77
|
+
await withTaskLifecycleLock(task.id, async () => {
|
|
78
|
+
const latestTask = ctx.store.get(task.id) ?? task;
|
|
79
|
+
const threadId = getThreadIdFromTask(latestTask);
|
|
80
|
+
if (!threadId || latestTask.status === 'closed') {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (await isThreadArchived(ctx.client, threadId)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await ensureUnarchived(ctx.client, threadId);
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
try {
|
|
91
|
+
const changed = await updateTaskThreadName(ctx.client, threadId, latestTask);
|
|
92
|
+
if (changed) {
|
|
93
|
+
ctx.counters.emojisUpdated++;
|
|
94
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 name updated');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 failed');
|
|
99
|
+
ctx.counters.warnings++;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const starterChanged = await updateTaskStarterMessage(ctx.client, threadId, latestTask, ctx.mentionUserId);
|
|
103
|
+
if (starterChanged) {
|
|
104
|
+
ctx.counters.starterMessagesUpdated++;
|
|
105
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 starter updated');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 starter update failed');
|
|
110
|
+
ctx.counters.warnings++;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const tagChanged = await updateTaskThreadTags(ctx.client, threadId, latestTask, ctx.tagMap);
|
|
114
|
+
if (tagChanged) {
|
|
115
|
+
ctx.counters.tagsUpdated++;
|
|
116
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 tags updated');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 tag update failed');
|
|
121
|
+
ctx.counters.warnings++;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
await sleep(ctx.throttleMs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function applyPhase4ArchiveClosedThreads(ctx, tasksById, plannedTaskIds) {
|
|
128
|
+
for (const taskId of plannedTaskIds) {
|
|
129
|
+
const task = tasksById.get(taskId);
|
|
130
|
+
if (!task)
|
|
131
|
+
continue;
|
|
132
|
+
await withTaskLifecycleLock(task.id, async () => {
|
|
133
|
+
const latestTask = ctx.store.get(task.id) ?? task;
|
|
134
|
+
const threadId = getThreadIdFromTask(latestTask);
|
|
135
|
+
if (!threadId || latestTask.status !== 'closed') {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (await isTaskThreadAlreadyClosed(ctx.client, threadId, latestTask, ctx.tagMap)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (ctx.hasInFlightForChannel(threadId)) {
|
|
143
|
+
ctx.counters.closesDeferred++;
|
|
144
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 close deferred (in-flight reply active)');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await closeTaskThread(ctx.client, threadId, latestTask, ctx.tagMap, ctx.log);
|
|
148
|
+
ctx.counters.threadsArchived++;
|
|
149
|
+
ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 archived');
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase4 failed');
|
|
153
|
+
ctx.counters.warnings++;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
await sleep(ctx.throttleMs);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const PHASE_EXECUTORS = {
|
|
160
|
+
phase1: applyPhase1CreateMissingThreads,
|
|
161
|
+
phase2: applyPhase2FixBlockedStatus,
|
|
162
|
+
phase3: applyPhase3SyncActiveThreads,
|
|
163
|
+
phase4: applyPhase4ArchiveClosedThreads,
|
|
164
|
+
};
|
|
165
|
+
export async function applyTaskSyncExecutionPlan(ctx, applyPlan) {
|
|
166
|
+
for (const phasePlan of applyPlan.phasePlans) {
|
|
167
|
+
if (phasePlan.taskIds.length === 0)
|
|
168
|
+
continue;
|
|
169
|
+
await PHASE_EXECUTORS[phasePlan.phase](ctx, applyPlan.tasksById, phasePlan.taskIds);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildTasksByShortIdMap, buildTasksByThreadIdMap, ingestTaskThreadSnapshots, ingestTaskSyncSnapshot, planTaskSyncApplyExecution, planTaskReconcileFromThreadSources, planTaskReconcileFromSnapshots, planTaskReconcileOperations, planTaskApplyPhases, operationTaskIdList, normalizeTaskSyncBuckets, operationTaskIdSet, planTaskSyncOperations, } from './task-sync-pipeline.js';
|
|
3
|
+
function task(overrides) {
|
|
4
|
+
return {
|
|
5
|
+
id: overrides.id,
|
|
6
|
+
title: overrides.title,
|
|
7
|
+
status: overrides.status,
|
|
8
|
+
labels: overrides.labels ?? [],
|
|
9
|
+
external_ref: overrides.external_ref,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('task-sync pipeline helpers', () => {
|
|
13
|
+
it('ingest copies list shape without cloning task objects', () => {
|
|
14
|
+
const originalTask = task({ id: 'ws-001', title: 'A', status: 'open' });
|
|
15
|
+
const input = [originalTask];
|
|
16
|
+
const snapshot = ingestTaskSyncSnapshot(input);
|
|
17
|
+
expect(snapshot).not.toBe(input);
|
|
18
|
+
expect(snapshot[0]).toBe(originalTask);
|
|
19
|
+
});
|
|
20
|
+
it('normalizes tasks into expected phase buckets', () => {
|
|
21
|
+
const allTasks = [
|
|
22
|
+
task({ id: 'ws-001', title: 'Missing ref', status: 'open' }),
|
|
23
|
+
task({ id: 'ws-002', title: 'No thread', status: 'open', labels: ['no-thread'] }),
|
|
24
|
+
task({ id: 'ws-003', title: 'Blocked label', status: 'open', labels: ['blocked-api'] }),
|
|
25
|
+
task({ id: 'ws-004', title: 'Has ref', status: 'in_progress', external_ref: 'discord:123' }),
|
|
26
|
+
task({ id: 'ws-005', title: 'Closed ref', status: 'closed', external_ref: 'discord:124' }),
|
|
27
|
+
];
|
|
28
|
+
const buckets = normalizeTaskSyncBuckets(allTasks);
|
|
29
|
+
expect(buckets.tasksMissingRef.map((t) => t.id)).toEqual(['ws-001', 'ws-003']);
|
|
30
|
+
expect(buckets.needsBlockedTasks.map((t) => t.id)).toEqual(['ws-003']);
|
|
31
|
+
expect(buckets.tasksWithRef.map((t) => t.id)).toEqual(['ws-004']);
|
|
32
|
+
expect(buckets.closedTasks.map((t) => t.id)).toEqual(['ws-005']);
|
|
33
|
+
});
|
|
34
|
+
it('builds deterministic idempotent operation plans and phase id sets', () => {
|
|
35
|
+
const buckets = {
|
|
36
|
+
tasksMissingRef: [
|
|
37
|
+
task({ id: 'ws-001', title: 'A', status: 'open' }),
|
|
38
|
+
task({ id: 'ws-002', title: 'B', status: 'open' }),
|
|
39
|
+
],
|
|
40
|
+
needsBlockedTasks: [
|
|
41
|
+
task({ id: 'ws-003', title: 'C', status: 'open', labels: ['blocked-db'] }),
|
|
42
|
+
],
|
|
43
|
+
tasksWithRef: [
|
|
44
|
+
task({ id: 'ws-004', title: 'D', status: 'in_progress', external_ref: 'discord:11' }),
|
|
45
|
+
],
|
|
46
|
+
closedTasks: [
|
|
47
|
+
task({ id: 'ws-005', title: 'E', status: 'closed', external_ref: 'discord:12' }),
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const operations = planTaskSyncOperations(buckets);
|
|
51
|
+
expect(operations.map((op) => op.key)).toEqual([
|
|
52
|
+
'task-sync:phase1:ws-001',
|
|
53
|
+
'task-sync:phase1:ws-002',
|
|
54
|
+
'task-sync:phase2:ws-003',
|
|
55
|
+
'task-sync:phase3:ws-004',
|
|
56
|
+
'task-sync:phase4:ws-005',
|
|
57
|
+
]);
|
|
58
|
+
expect(operationTaskIdSet(operations, 'phase1')).toEqual(new Set(['ws-001', 'ws-002']));
|
|
59
|
+
expect(operationTaskIdSet(operations, 'phase4')).toEqual(new Set(['ws-005']));
|
|
60
|
+
expect(operationTaskIdList(operations, 'phase1')).toEqual(['ws-001', 'ws-002']);
|
|
61
|
+
expect(operationTaskIdList(operations, 'phase3')).toEqual(['ws-004']);
|
|
62
|
+
});
|
|
63
|
+
it('operationTaskIdList preserves in-plan order for the selected phase', () => {
|
|
64
|
+
const operations = [
|
|
65
|
+
{ phase: 'phase2', taskId: 'ws-010', key: 'task-sync:phase2:ws-010' },
|
|
66
|
+
{ phase: 'phase1', taskId: 'ws-001', key: 'task-sync:phase1:ws-001' },
|
|
67
|
+
{ phase: 'phase3', taskId: 'ws-020', key: 'task-sync:phase3:ws-020' },
|
|
68
|
+
{ phase: 'phase1', taskId: 'ws-002', key: 'task-sync:phase1:ws-002' },
|
|
69
|
+
];
|
|
70
|
+
expect(operationTaskIdList(operations, 'phase1')).toEqual(['ws-001', 'ws-002']);
|
|
71
|
+
});
|
|
72
|
+
it('builds ordered apply-phase plans from the diff operation list', () => {
|
|
73
|
+
const operations = [
|
|
74
|
+
{ phase: 'phase3', taskId: 'ws-020', key: 'task-sync:phase3:ws-020' },
|
|
75
|
+
{ phase: 'phase1', taskId: 'ws-001', key: 'task-sync:phase1:ws-001' },
|
|
76
|
+
{ phase: 'phase4', taskId: 'ws-030', key: 'task-sync:phase4:ws-030' },
|
|
77
|
+
{ phase: 'phase1', taskId: 'ws-002', key: 'task-sync:phase1:ws-002' },
|
|
78
|
+
];
|
|
79
|
+
const phasePlans = planTaskApplyPhases(operations);
|
|
80
|
+
expect(phasePlans).toEqual([
|
|
81
|
+
{ phase: 'phase1', taskIds: ['ws-001', 'ws-002'] },
|
|
82
|
+
{ phase: 'phase2', taskIds: [] },
|
|
83
|
+
{ phase: 'phase3', taskIds: ['ws-020'] },
|
|
84
|
+
{ phase: 'phase4', taskIds: ['ws-030'] },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
it('composes stage2-4 apply execution plan from a task snapshot', () => {
|
|
88
|
+
const allTasks = [
|
|
89
|
+
task({ id: 'ws-001', title: 'Missing ref', status: 'open' }),
|
|
90
|
+
task({ id: 'ws-003', title: 'Blocked label', status: 'open', labels: ['blocked-api'] }),
|
|
91
|
+
task({ id: 'ws-004', title: 'Has ref', status: 'in_progress', external_ref: 'discord:123' }),
|
|
92
|
+
task({ id: 'ws-005', title: 'Closed ref', status: 'closed', external_ref: 'discord:124' }),
|
|
93
|
+
];
|
|
94
|
+
const plan = planTaskSyncApplyExecution(allTasks);
|
|
95
|
+
expect(plan.operations.map((op) => op.key)).toEqual([
|
|
96
|
+
'task-sync:phase1:ws-001',
|
|
97
|
+
'task-sync:phase1:ws-003',
|
|
98
|
+
'task-sync:phase2:ws-003',
|
|
99
|
+
'task-sync:phase3:ws-004',
|
|
100
|
+
'task-sync:phase4:ws-005',
|
|
101
|
+
]);
|
|
102
|
+
expect(plan.phasePlans).toEqual([
|
|
103
|
+
{ phase: 'phase1', taskIds: ['ws-001', 'ws-003'] },
|
|
104
|
+
{ phase: 'phase2', taskIds: ['ws-003'] },
|
|
105
|
+
{ phase: 'phase3', taskIds: ['ws-004'] },
|
|
106
|
+
{ phase: 'phase4', taskIds: ['ws-005'] },
|
|
107
|
+
]);
|
|
108
|
+
expect(plan.tasksById.get('ws-004')).toBe(allTasks[2]);
|
|
109
|
+
});
|
|
110
|
+
it('builds a short-id lookup map for reconciliation', () => {
|
|
111
|
+
const map = buildTasksByShortIdMap([
|
|
112
|
+
task({ id: 'ws-001', title: 'A', status: 'open' }),
|
|
113
|
+
task({ id: 'dev-001', title: 'B', status: 'open' }),
|
|
114
|
+
task({ id: 'ws-002', title: 'C', status: 'closed' }),
|
|
115
|
+
], (id) => id.split('-')[1] ?? id);
|
|
116
|
+
expect(map.get('001')?.map((t) => t.id)).toEqual(['ws-001', 'dev-001']);
|
|
117
|
+
expect(map.get('002')?.map((t) => t.id)).toEqual(['ws-002']);
|
|
118
|
+
});
|
|
119
|
+
it('builds a thread-id lookup map for reconciliation', () => {
|
|
120
|
+
const map = buildTasksByThreadIdMap([
|
|
121
|
+
task({ id: 'ws-001', title: 'A', status: 'closed', external_ref: 'discord:thread-1' }),
|
|
122
|
+
task({ id: 'ws-002', title: 'B', status: 'open', external_ref: 'discord:thread-2' }),
|
|
123
|
+
task({ id: 'ws-003', title: 'C', status: 'open' }),
|
|
124
|
+
], (t) => {
|
|
125
|
+
const ref = t.external_ref ?? '';
|
|
126
|
+
return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
|
|
127
|
+
});
|
|
128
|
+
expect(map.get('thread-1')?.map((t) => t.id)).toEqual(['ws-001']);
|
|
129
|
+
expect(map.get('thread-2')?.map((t) => t.id)).toEqual(['ws-002']);
|
|
130
|
+
expect(map.get('thread-3')).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
it('normalizes and merges phase5 thread snapshots with active-over-archived precedence', () => {
|
|
133
|
+
const snapshots = ingestTaskThreadSnapshots([
|
|
134
|
+
{ id: 'thread-1', name: 'Archived One', archived: true },
|
|
135
|
+
{ id: 'shared', name: 'Archived Shared', archived: true },
|
|
136
|
+
], [
|
|
137
|
+
{ id: 'shared', name: 'Active Shared', archived: false },
|
|
138
|
+
{ id: 2, name: null, archived: null },
|
|
139
|
+
]);
|
|
140
|
+
expect(snapshots).toEqual([
|
|
141
|
+
{ id: 'thread-1', name: 'Archived One', archived: true },
|
|
142
|
+
{ id: 'shared', name: 'Active Shared', archived: false },
|
|
143
|
+
{ id: '2', name: '', archived: false },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
it('plans phase5 reconcile operations from thread snapshots', () => {
|
|
147
|
+
const tasksByShortId = buildTasksByShortIdMap([
|
|
148
|
+
task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
|
|
149
|
+
task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
|
|
150
|
+
task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
|
|
151
|
+
task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
|
|
152
|
+
], (id) => id.split('-')[1] ?? id);
|
|
153
|
+
const ops = planTaskReconcileOperations({
|
|
154
|
+
threads: [
|
|
155
|
+
{ id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
|
|
156
|
+
{ id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
|
|
157
|
+
{ id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
|
|
158
|
+
{ id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
|
|
159
|
+
{ id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
|
|
160
|
+
],
|
|
161
|
+
tasksByShortId,
|
|
162
|
+
shortIdFromThreadName: (name) => {
|
|
163
|
+
const match = name.match(/\[(\d+)\]/);
|
|
164
|
+
return match ? match[1] : null;
|
|
165
|
+
},
|
|
166
|
+
threadIdFromTask: (t) => {
|
|
167
|
+
const ref = t.external_ref ?? '';
|
|
168
|
+
return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(ops.map((op) => op.action)).toEqual([
|
|
172
|
+
'orphan',
|
|
173
|
+
'collision',
|
|
174
|
+
'skip_external_ref_mismatch',
|
|
175
|
+
'archive_active_closed',
|
|
176
|
+
'reconcile_archived_closed',
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
it('plans phase5 reconcile operations directly from task and thread snapshots', () => {
|
|
180
|
+
const ops = planTaskReconcileFromSnapshots({
|
|
181
|
+
tasks: [
|
|
182
|
+
task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
|
|
183
|
+
task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
|
|
184
|
+
task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
|
|
185
|
+
task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
|
|
186
|
+
],
|
|
187
|
+
threads: [
|
|
188
|
+
{ id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
|
|
189
|
+
{ id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
|
|
190
|
+
{ id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
|
|
191
|
+
{ id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
|
|
192
|
+
{ id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
|
|
193
|
+
],
|
|
194
|
+
shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
|
|
195
|
+
shortIdFromThreadName: (name) => {
|
|
196
|
+
const match = name.match(/\[(\d+)\]/);
|
|
197
|
+
return match ? match[1] : null;
|
|
198
|
+
},
|
|
199
|
+
threadIdFromTask: (t) => {
|
|
200
|
+
const ref = t.external_ref ?? '';
|
|
201
|
+
return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
expect(ops.map((op) => op.action)).toEqual([
|
|
205
|
+
'orphan',
|
|
206
|
+
'collision',
|
|
207
|
+
'skip_external_ref_mismatch',
|
|
208
|
+
'archive_active_closed',
|
|
209
|
+
'reconcile_archived_closed',
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
it('prefers thread-id mapping before thread-name parsing in reconcile planning', () => {
|
|
213
|
+
const ops = planTaskReconcileFromSnapshots({
|
|
214
|
+
tasks: [
|
|
215
|
+
task({ id: 'ws-010', title: 'Closed mapped', status: 'closed', external_ref: 'discord:thread-linked' }),
|
|
216
|
+
],
|
|
217
|
+
threads: [
|
|
218
|
+
{ id: 'thread-linked', name: 'General thread without token', archived: false },
|
|
219
|
+
],
|
|
220
|
+
shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
|
|
221
|
+
shortIdFromThreadName: () => null,
|
|
222
|
+
threadIdFromTask: (t) => {
|
|
223
|
+
const ref = t.external_ref ?? '';
|
|
224
|
+
return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
expect(ops.map((op) => op.action)).toEqual(['archive_active_closed']);
|
|
228
|
+
expect(ops[0]?.task?.id).toBe('ws-010');
|
|
229
|
+
});
|
|
230
|
+
it('plans phase5 reconcile operations directly from archived and active thread sources', () => {
|
|
231
|
+
const ops = planTaskReconcileFromThreadSources({
|
|
232
|
+
tasks: [
|
|
233
|
+
task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
|
|
234
|
+
task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
|
|
235
|
+
task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
|
|
236
|
+
task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
|
|
237
|
+
],
|
|
238
|
+
archivedThreads: [
|
|
239
|
+
{ id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
|
|
240
|
+
],
|
|
241
|
+
activeThreads: [
|
|
242
|
+
{ id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
|
|
243
|
+
{ id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
|
|
244
|
+
{ id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
|
|
245
|
+
{ id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
|
|
246
|
+
],
|
|
247
|
+
shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
|
|
248
|
+
shortIdFromThreadName: (name) => {
|
|
249
|
+
const match = name.match(/\[(\d+)\]/);
|
|
250
|
+
return match ? match[1] : null;
|
|
251
|
+
},
|
|
252
|
+
threadIdFromTask: (t) => {
|
|
253
|
+
const ref = t.external_ref ?? '';
|
|
254
|
+
return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
expect(ops.map((op) => op.action)).toEqual([
|
|
258
|
+
'reconcile_archived_closed',
|
|
259
|
+
'orphan',
|
|
260
|
+
'collision',
|
|
261
|
+
'skip_external_ref_mismatch',
|
|
262
|
+
'archive_active_closed',
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export function buildTasksByShortIdMap(allTasks, shortIdOf) {
|
|
2
|
+
const tasksByShortId = new Map();
|
|
3
|
+
for (const task of allTasks) {
|
|
4
|
+
const shortId = shortIdOf(task.id);
|
|
5
|
+
const existing = tasksByShortId.get(shortId);
|
|
6
|
+
if (existing)
|
|
7
|
+
existing.push(task);
|
|
8
|
+
else
|
|
9
|
+
tasksByShortId.set(shortId, [task]);
|
|
10
|
+
}
|
|
11
|
+
return tasksByShortId;
|
|
12
|
+
}
|
|
13
|
+
export function buildTasksByThreadIdMap(allTasks, threadIdFromTask) {
|
|
14
|
+
const tasksByThreadId = new Map();
|
|
15
|
+
for (const task of allTasks) {
|
|
16
|
+
const threadId = threadIdFromTask(task);
|
|
17
|
+
if (!threadId)
|
|
18
|
+
continue;
|
|
19
|
+
const existing = tasksByThreadId.get(threadId);
|
|
20
|
+
if (existing)
|
|
21
|
+
existing.push(task);
|
|
22
|
+
else
|
|
23
|
+
tasksByThreadId.set(threadId, [task]);
|
|
24
|
+
}
|
|
25
|
+
return tasksByThreadId;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Stage: ingest (phase 5)
|
|
29
|
+
* Merge archived+active thread sources into a normalized snapshot list.
|
|
30
|
+
* When a thread ID appears in both sources, the active source wins.
|
|
31
|
+
*/
|
|
32
|
+
export function ingestTaskThreadSnapshots(archivedThreads, activeThreads) {
|
|
33
|
+
const byThreadId = new Map();
|
|
34
|
+
const push = (thread) => {
|
|
35
|
+
const id = String(thread.id);
|
|
36
|
+
byThreadId.set(id, {
|
|
37
|
+
id,
|
|
38
|
+
name: String(thread.name ?? ''),
|
|
39
|
+
archived: Boolean(thread.archived),
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
for (const thread of archivedThreads)
|
|
43
|
+
push(thread);
|
|
44
|
+
for (const thread of activeThreads)
|
|
45
|
+
push(thread);
|
|
46
|
+
return [...byThreadId.values()];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Stage: diff (phase 5)
|
|
50
|
+
* Plan reconciliation operations for forum threads vs local task snapshot.
|
|
51
|
+
*/
|
|
52
|
+
export function planTaskReconcileOperations(opts) {
|
|
53
|
+
const operations = [];
|
|
54
|
+
const tasksByThreadId = opts.tasksByThreadId ?? new Map();
|
|
55
|
+
for (const thread of opts.threads) {
|
|
56
|
+
const linkedTasks = tasksByThreadId.get(thread.id) ?? [];
|
|
57
|
+
if (linkedTasks.length > 1) {
|
|
58
|
+
operations.push({
|
|
59
|
+
action: 'collision',
|
|
60
|
+
key: `task-sync:phase5:collision:${thread.id}`,
|
|
61
|
+
thread,
|
|
62
|
+
shortId: opts.shortIdFromThreadName(thread.name) ?? '',
|
|
63
|
+
collisionCount: linkedTasks.length,
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (linkedTasks.length === 1) {
|
|
68
|
+
const task = linkedTasks[0];
|
|
69
|
+
const existingThreadId = opts.threadIdFromTask(task);
|
|
70
|
+
if (task.status === 'closed' && !thread.archived) {
|
|
71
|
+
operations.push({
|
|
72
|
+
action: 'archive_active_closed',
|
|
73
|
+
key: `task-sync:phase5:archive:${thread.id}`,
|
|
74
|
+
thread,
|
|
75
|
+
shortId: opts.shortIdFromThreadName(thread.name) ?? '',
|
|
76
|
+
task,
|
|
77
|
+
existingThreadId: existingThreadId ?? undefined,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (task.status === 'closed' && thread.archived) {
|
|
82
|
+
operations.push({
|
|
83
|
+
action: 'reconcile_archived_closed',
|
|
84
|
+
key: `task-sync:phase5:reconcile:${thread.id}`,
|
|
85
|
+
thread,
|
|
86
|
+
shortId: opts.shortIdFromThreadName(thread.name) ?? '',
|
|
87
|
+
task,
|
|
88
|
+
existingThreadId: existingThreadId ?? undefined,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Fallback for legacy threads missing task external_ref mapping.
|
|
94
|
+
const shortId = opts.shortIdFromThreadName(thread.name);
|
|
95
|
+
if (!shortId)
|
|
96
|
+
continue;
|
|
97
|
+
const tasks = opts.tasksByShortId.get(shortId);
|
|
98
|
+
if (!tasks || tasks.length === 0) {
|
|
99
|
+
operations.push({
|
|
100
|
+
action: 'orphan',
|
|
101
|
+
key: `task-sync:phase5:orphan:${thread.id}`,
|
|
102
|
+
thread,
|
|
103
|
+
shortId,
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (tasks.length > 1) {
|
|
108
|
+
operations.push({
|
|
109
|
+
action: 'collision',
|
|
110
|
+
key: `task-sync:phase5:collision:${thread.id}`,
|
|
111
|
+
thread,
|
|
112
|
+
shortId,
|
|
113
|
+
collisionCount: tasks.length,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const task = tasks[0];
|
|
118
|
+
const existingThreadId = opts.threadIdFromTask(task);
|
|
119
|
+
if (existingThreadId && existingThreadId !== thread.id) {
|
|
120
|
+
operations.push({
|
|
121
|
+
action: 'skip_external_ref_mismatch',
|
|
122
|
+
key: `task-sync:phase5:skip-mismatch:${thread.id}`,
|
|
123
|
+
thread,
|
|
124
|
+
shortId,
|
|
125
|
+
task,
|
|
126
|
+
existingThreadId,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (task.status === 'closed' && !thread.archived) {
|
|
131
|
+
operations.push({
|
|
132
|
+
action: 'archive_active_closed',
|
|
133
|
+
key: `task-sync:phase5:archive:${thread.id}`,
|
|
134
|
+
thread,
|
|
135
|
+
shortId,
|
|
136
|
+
task,
|
|
137
|
+
existingThreadId: existingThreadId ?? undefined,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (task.status === 'closed' && thread.archived) {
|
|
142
|
+
operations.push({
|
|
143
|
+
action: 'reconcile_archived_closed',
|
|
144
|
+
key: `task-sync:phase5:reconcile:${thread.id}`,
|
|
145
|
+
thread,
|
|
146
|
+
shortId,
|
|
147
|
+
task,
|
|
148
|
+
existingThreadId: existingThreadId ?? undefined,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return operations;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Stage: diff (phase 5)
|
|
156
|
+
* Build phase-5 reconcile operations directly from task+thread snapshots.
|
|
157
|
+
*/
|
|
158
|
+
export function planTaskReconcileFromSnapshots(opts) {
|
|
159
|
+
const tasksByShortId = buildTasksByShortIdMap(opts.tasks, opts.shortIdOfTaskId);
|
|
160
|
+
const tasksByThreadId = buildTasksByThreadIdMap(opts.tasks, opts.threadIdFromTask);
|
|
161
|
+
return planTaskReconcileOperations({
|
|
162
|
+
threads: opts.threads,
|
|
163
|
+
tasksByShortId,
|
|
164
|
+
tasksByThreadId,
|
|
165
|
+
shortIdFromThreadName: opts.shortIdFromThreadName,
|
|
166
|
+
threadIdFromTask: opts.threadIdFromTask,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Stage: diff (phase 5)
|
|
171
|
+
* Compose thread-source ingest + reconcile diff planning.
|
|
172
|
+
*/
|
|
173
|
+
export function planTaskReconcileFromThreadSources(opts) {
|
|
174
|
+
const threads = ingestTaskThreadSnapshots(opts.archivedThreads, opts.activeThreads);
|
|
175
|
+
return planTaskReconcileFromSnapshots({
|
|
176
|
+
tasks: opts.tasks,
|
|
177
|
+
threads,
|
|
178
|
+
shortIdOfTaskId: opts.shortIdOfTaskId,
|
|
179
|
+
shortIdFromThreadName: opts.shortIdFromThreadName,
|
|
180
|
+
threadIdFromTask: opts.threadIdFromTask,
|
|
181
|
+
});
|
|
182
|
+
}
|