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,417 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { TaskStore } from './store.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function makeStore(prefix = 'ws') {
|
|
7
|
+
return new TaskStore({ prefix });
|
|
8
|
+
}
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ID generation
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
describe('TaskStore — ID generation', () => {
|
|
13
|
+
it('generates sequential IDs with the given prefix', () => {
|
|
14
|
+
const store = makeStore();
|
|
15
|
+
const a = store.create({ title: 'First' });
|
|
16
|
+
const b = store.create({ title: 'Second' });
|
|
17
|
+
expect(a.id).toBe('ws-001');
|
|
18
|
+
expect(b.id).toBe('ws-002');
|
|
19
|
+
});
|
|
20
|
+
it('pads counter to at least 3 digits', () => {
|
|
21
|
+
const store = makeStore();
|
|
22
|
+
const t = store.create({ title: 'T' });
|
|
23
|
+
expect(t.id).toMatch(/^ws-\d{3}$/);
|
|
24
|
+
});
|
|
25
|
+
it('uses default prefix "t" when none provided', () => {
|
|
26
|
+
const store = new TaskStore();
|
|
27
|
+
const t = store.create({ title: 'T' });
|
|
28
|
+
expect(t.id).toMatch(/^t-\d{3}$/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// create
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
describe('TaskStore — create', () => {
|
|
35
|
+
let store;
|
|
36
|
+
beforeEach(() => { store = makeStore(); });
|
|
37
|
+
it('stores the task with status "open"', () => {
|
|
38
|
+
const t = store.create({ title: 'My task' });
|
|
39
|
+
expect(t.status).toBe('open');
|
|
40
|
+
expect(store.get(t.id)).toBe(t);
|
|
41
|
+
});
|
|
42
|
+
it('stores optional fields', () => {
|
|
43
|
+
const t = store.create({
|
|
44
|
+
title: 'T',
|
|
45
|
+
description: 'desc',
|
|
46
|
+
priority: 1,
|
|
47
|
+
issueType: 'bug',
|
|
48
|
+
owner: 'alice',
|
|
49
|
+
labels: ['tag:feature'],
|
|
50
|
+
});
|
|
51
|
+
expect(t.description).toBe('desc');
|
|
52
|
+
expect(t.priority).toBe(1);
|
|
53
|
+
expect(t.issue_type).toBe('bug');
|
|
54
|
+
expect(t.owner).toBe('alice');
|
|
55
|
+
expect(t.labels).toEqual(['tag:feature']);
|
|
56
|
+
});
|
|
57
|
+
it('sets created_at and updated_at', () => {
|
|
58
|
+
const t = store.create({ title: 'T' });
|
|
59
|
+
expect(t.created_at).toBeDefined();
|
|
60
|
+
expect(t.updated_at).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
it('emits "created" event synchronously', () => {
|
|
63
|
+
const emitted = [];
|
|
64
|
+
store.on('created', (b) => emitted.push(b));
|
|
65
|
+
const t = store.create({ title: 'T' });
|
|
66
|
+
expect(emitted).toHaveLength(1);
|
|
67
|
+
expect(emitted[0]).toBe(t);
|
|
68
|
+
});
|
|
69
|
+
it('does not share labels array with caller', () => {
|
|
70
|
+
const labels = ['a', 'b'];
|
|
71
|
+
const t = store.create({ title: 'T', labels });
|
|
72
|
+
labels.push('c');
|
|
73
|
+
expect(t.labels).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// get
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
describe('TaskStore — get', () => {
|
|
80
|
+
it('returns undefined for unknown id', () => {
|
|
81
|
+
const store = makeStore();
|
|
82
|
+
expect(store.get('ws-999')).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
it('returns the stored task', () => {
|
|
85
|
+
const store = makeStore();
|
|
86
|
+
const t = store.create({ title: 'T' });
|
|
87
|
+
expect(store.get(t.id)).toBe(t);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// list
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
describe('TaskStore — list', () => {
|
|
94
|
+
let store;
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
store = makeStore();
|
|
97
|
+
store.create({ title: 'Open A' });
|
|
98
|
+
store.create({ title: 'Open B' });
|
|
99
|
+
const c = store.create({ title: 'Closed C' });
|
|
100
|
+
store.close(c.id);
|
|
101
|
+
});
|
|
102
|
+
it('excludes closed tasks by default', () => {
|
|
103
|
+
const results = store.list();
|
|
104
|
+
expect(results).toHaveLength(2);
|
|
105
|
+
expect(results.every((b) => b.status !== 'closed')).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it('includes all tasks when status is "all"', () => {
|
|
108
|
+
const results = store.list({ status: 'all' });
|
|
109
|
+
expect(results).toHaveLength(3);
|
|
110
|
+
});
|
|
111
|
+
it('filters by status', () => {
|
|
112
|
+
const t = store.create({ title: 'IP' });
|
|
113
|
+
store.update(t.id, { status: 'in_progress' });
|
|
114
|
+
const results = store.list({ status: 'in_progress' });
|
|
115
|
+
expect(results.every((b) => b.status === 'in_progress')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it('filters by label', () => {
|
|
118
|
+
const t = store.create({ title: 'Labeled' });
|
|
119
|
+
store.addLabel(t.id, 'plan');
|
|
120
|
+
const results = store.list({ label: 'plan' });
|
|
121
|
+
expect(results).toHaveLength(1);
|
|
122
|
+
expect(results[0].id).toBe(t.id);
|
|
123
|
+
});
|
|
124
|
+
it('respects limit', () => {
|
|
125
|
+
const results = store.list({ limit: 1 });
|
|
126
|
+
expect(results).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
it('limit 0 means no cap', () => {
|
|
129
|
+
const results = store.list({ limit: 0 });
|
|
130
|
+
expect(results).toHaveLength(2); // the two open tasks
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// findByTitle
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
describe('TaskStore — findByTitle', () => {
|
|
137
|
+
let store;
|
|
138
|
+
beforeEach(() => { store = makeStore(); });
|
|
139
|
+
it('returns matching non-closed task (case-insensitive, trimmed)', () => {
|
|
140
|
+
const t = store.create({ title: ' Fix The Bug ' });
|
|
141
|
+
const found = store.findByTitle('fix the bug');
|
|
142
|
+
expect(found?.id).toBe(t.id);
|
|
143
|
+
});
|
|
144
|
+
it('returns null when no title matches', () => {
|
|
145
|
+
store.create({ title: 'Something else' });
|
|
146
|
+
expect(store.findByTitle('Fix the bug')).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
it('skips closed tasks', () => {
|
|
149
|
+
const t = store.create({ title: 'Fix the bug' });
|
|
150
|
+
store.close(t.id);
|
|
151
|
+
expect(store.findByTitle('Fix the bug')).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
it('returns null for empty or whitespace-only title', () => {
|
|
154
|
+
expect(store.findByTitle('')).toBeNull();
|
|
155
|
+
expect(store.findByTitle(' ')).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
it('filters by label when provided', () => {
|
|
158
|
+
const a = store.create({ title: 'Shared title' });
|
|
159
|
+
store.addLabel(a.id, 'plan');
|
|
160
|
+
const b = store.create({ title: 'Shared title' });
|
|
161
|
+
// Without label filter — first match (insertion order)
|
|
162
|
+
expect(store.findByTitle('Shared title')?.id).toBe(a.id);
|
|
163
|
+
// With label filter — only the labelled one matches
|
|
164
|
+
expect(store.findByTitle('Shared title', { label: 'plan' })?.id).toBe(a.id);
|
|
165
|
+
// Label filter excludes the unlabelled task
|
|
166
|
+
expect(store.findByTitle('Shared title', { label: 'nope' })).toBeNull();
|
|
167
|
+
void b; // suppress unused-variable warning
|
|
168
|
+
});
|
|
169
|
+
it('returns first match when multiple tasks have the same title', () => {
|
|
170
|
+
const a = store.create({ title: 'Dup' });
|
|
171
|
+
store.create({ title: 'Dup' });
|
|
172
|
+
expect(store.findByTitle('Dup')?.id).toBe(a.id);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// update
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
describe('TaskStore — update', () => {
|
|
179
|
+
let store;
|
|
180
|
+
beforeEach(() => { store = makeStore(); });
|
|
181
|
+
it('updates fields and reflects them in the store', () => {
|
|
182
|
+
const t = store.create({ title: 'Old title' });
|
|
183
|
+
const updated = store.update(t.id, { title: 'New title', priority: 1 });
|
|
184
|
+
expect(updated.title).toBe('New title');
|
|
185
|
+
expect(updated.priority).toBe(1);
|
|
186
|
+
expect(store.get(t.id)?.title).toBe('New title');
|
|
187
|
+
});
|
|
188
|
+
it('sets updated_at', () => {
|
|
189
|
+
const t = store.create({ title: 'T' });
|
|
190
|
+
const updated = store.update(t.id, { title: 'New' });
|
|
191
|
+
expect(updated.updated_at).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
it('emits "updated" with next bead and previous bead', () => {
|
|
194
|
+
const t = store.create({ title: 'T' });
|
|
195
|
+
const events = [];
|
|
196
|
+
store.on('updated', (b, prev) => events.push([b, prev]));
|
|
197
|
+
store.update(t.id, { title: 'Updated' });
|
|
198
|
+
expect(events).toHaveLength(1);
|
|
199
|
+
expect(events[0][0].title).toBe('Updated');
|
|
200
|
+
expect(events[0][1].title).toBe('T');
|
|
201
|
+
});
|
|
202
|
+
it('does not mutate the previous snapshot passed to the event', () => {
|
|
203
|
+
const t = store.create({ title: 'T' });
|
|
204
|
+
let capturedPrev;
|
|
205
|
+
store.on('updated', (_, prev) => { capturedPrev = prev; });
|
|
206
|
+
store.update(t.id, { title: 'Updated' });
|
|
207
|
+
expect(capturedPrev?.title).toBe('T');
|
|
208
|
+
});
|
|
209
|
+
it('throws for unknown id', () => {
|
|
210
|
+
expect(() => store.update('ws-999', { title: 'x' })).toThrow('task not found');
|
|
211
|
+
});
|
|
212
|
+
it('updates externalRef', () => {
|
|
213
|
+
const t = store.create({ title: 'T' });
|
|
214
|
+
const updated = store.update(t.id, { externalRef: 'discord:123' });
|
|
215
|
+
expect(updated.external_ref).toBe('discord:123');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// close
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
describe('TaskStore — close', () => {
|
|
222
|
+
let store;
|
|
223
|
+
beforeEach(() => { store = makeStore(); });
|
|
224
|
+
it('sets status to "closed" and records closed_at', () => {
|
|
225
|
+
const t = store.create({ title: 'T' });
|
|
226
|
+
const closed = store.close(t.id);
|
|
227
|
+
expect(closed.status).toBe('closed');
|
|
228
|
+
expect(closed.closed_at).toBeDefined();
|
|
229
|
+
});
|
|
230
|
+
it('records close_reason when provided', () => {
|
|
231
|
+
const t = store.create({ title: 'T' });
|
|
232
|
+
const closed = store.close(t.id, 'done');
|
|
233
|
+
expect(closed.close_reason).toBe('done');
|
|
234
|
+
});
|
|
235
|
+
it('omits close_reason when not provided', () => {
|
|
236
|
+
const t = store.create({ title: 'T' });
|
|
237
|
+
const closed = store.close(t.id);
|
|
238
|
+
expect(closed.close_reason).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
it('emits "closed" event synchronously', () => {
|
|
241
|
+
const t = store.create({ title: 'T' });
|
|
242
|
+
const events = [];
|
|
243
|
+
store.on('closed', (b) => events.push(b));
|
|
244
|
+
store.close(t.id);
|
|
245
|
+
expect(events).toHaveLength(1);
|
|
246
|
+
expect(events[0].status).toBe('closed');
|
|
247
|
+
});
|
|
248
|
+
it('reflects closed status in subsequent list calls', () => {
|
|
249
|
+
const t = store.create({ title: 'T' });
|
|
250
|
+
store.close(t.id);
|
|
251
|
+
expect(store.list().find((b) => b.id === t.id)).toBeUndefined();
|
|
252
|
+
expect(store.list({ status: 'all' }).find((b) => b.id === t.id)).toBeDefined();
|
|
253
|
+
});
|
|
254
|
+
it('throws for unknown id', () => {
|
|
255
|
+
expect(() => store.close('ws-999')).toThrow('task not found');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// addLabel
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
describe('TaskStore — addLabel', () => {
|
|
262
|
+
let store;
|
|
263
|
+
beforeEach(() => { store = makeStore(); });
|
|
264
|
+
it('adds a label to a task', () => {
|
|
265
|
+
const t = store.create({ title: 'T' });
|
|
266
|
+
const updated = store.addLabel(t.id, 'plan');
|
|
267
|
+
expect(updated.labels).toContain('plan');
|
|
268
|
+
expect(store.get(t.id)?.labels).toContain('plan');
|
|
269
|
+
});
|
|
270
|
+
it('is idempotent — does not duplicate labels', () => {
|
|
271
|
+
const t = store.create({ title: 'T' });
|
|
272
|
+
store.addLabel(t.id, 'plan');
|
|
273
|
+
const result = store.addLabel(t.id, 'plan');
|
|
274
|
+
expect(result.labels?.filter((l) => l === 'plan')).toHaveLength(1);
|
|
275
|
+
});
|
|
276
|
+
it('returns the same reference without mutation when label already present', () => {
|
|
277
|
+
const t = store.create({ title: 'T' });
|
|
278
|
+
store.addLabel(t.id, 'plan');
|
|
279
|
+
const after = store.get(t.id);
|
|
280
|
+
const result = store.addLabel(t.id, 'plan');
|
|
281
|
+
expect(result).toBe(after); // no copy made
|
|
282
|
+
});
|
|
283
|
+
it('emits "labeled" event with the bead and label', () => {
|
|
284
|
+
const t = store.create({ title: 'T' });
|
|
285
|
+
const events = [];
|
|
286
|
+
store.on('labeled', (b, label) => events.push([b, label]));
|
|
287
|
+
store.addLabel(t.id, 'plan');
|
|
288
|
+
expect(events).toHaveLength(1);
|
|
289
|
+
expect(events[0][1]).toBe('plan');
|
|
290
|
+
expect(events[0][0].labels).toContain('plan');
|
|
291
|
+
});
|
|
292
|
+
it('does not emit when label already present', () => {
|
|
293
|
+
const t = store.create({ title: 'T' });
|
|
294
|
+
store.addLabel(t.id, 'plan');
|
|
295
|
+
const events = [];
|
|
296
|
+
store.on('labeled', () => events.push(null));
|
|
297
|
+
store.addLabel(t.id, 'plan');
|
|
298
|
+
expect(events).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
it('throws for unknown id', () => {
|
|
301
|
+
expect(() => store.addLabel('ws-999', 'plan')).toThrow('task not found');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// removeLabel
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
describe('TaskStore — removeLabel', () => {
|
|
308
|
+
let store;
|
|
309
|
+
beforeEach(() => { store = makeStore(); });
|
|
310
|
+
it('removes an existing label', () => {
|
|
311
|
+
const t = store.create({ title: 'T', labels: ['plan', 'bug'] });
|
|
312
|
+
const updated = store.removeLabel(t.id, 'plan');
|
|
313
|
+
expect(updated.labels).not.toContain('plan');
|
|
314
|
+
expect(updated.labels).toContain('bug');
|
|
315
|
+
});
|
|
316
|
+
it('is a no-op and returns the same reference when label is absent', () => {
|
|
317
|
+
const t = store.create({ title: 'T', labels: ['bug'] });
|
|
318
|
+
const result = store.removeLabel(t.id, 'plan');
|
|
319
|
+
expect(result).toBe(t);
|
|
320
|
+
});
|
|
321
|
+
it('emits "updated" when a label is removed', () => {
|
|
322
|
+
const t = store.create({ title: 'T', labels: ['plan'] });
|
|
323
|
+
const events = [];
|
|
324
|
+
store.on('updated', (b, prev) => events.push([b, prev]));
|
|
325
|
+
store.removeLabel(t.id, 'plan');
|
|
326
|
+
expect(events).toHaveLength(1);
|
|
327
|
+
expect(events[0][1].labels).toContain('plan');
|
|
328
|
+
expect(events[0][0].labels).not.toContain('plan');
|
|
329
|
+
});
|
|
330
|
+
it('does not emit when label is absent', () => {
|
|
331
|
+
const t = store.create({ title: 'T' });
|
|
332
|
+
const events = [];
|
|
333
|
+
store.on('updated', () => events.push(null));
|
|
334
|
+
store.removeLabel(t.id, 'plan');
|
|
335
|
+
expect(events).toHaveLength(0);
|
|
336
|
+
});
|
|
337
|
+
it('throws for unknown id', () => {
|
|
338
|
+
expect(() => store.removeLabel('ws-999', 'plan')).toThrow('task not found');
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// size
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
describe('TaskStore — size', () => {
|
|
345
|
+
it('returns 0 for an empty store', () => {
|
|
346
|
+
const store = makeStore();
|
|
347
|
+
expect(store.size()).toBe(0);
|
|
348
|
+
});
|
|
349
|
+
it('counts all tasks including closed ones', () => {
|
|
350
|
+
const store = makeStore();
|
|
351
|
+
store.create({ title: 'A' });
|
|
352
|
+
const b = store.create({ title: 'B' });
|
|
353
|
+
store.close(b.id);
|
|
354
|
+
expect(store.size()).toBe(2);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Persistence (JSONL)
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
describe('TaskStore — persistence', () => {
|
|
361
|
+
it('saves and loads tasks from a JSONL file', async () => {
|
|
362
|
+
const fsp = await import('node:fs/promises');
|
|
363
|
+
const path = '/tmp/discoclaw-test-store.jsonl';
|
|
364
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
365
|
+
const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
366
|
+
store1.create({ title: 'Alpha' });
|
|
367
|
+
store1.create({ title: 'Beta' });
|
|
368
|
+
await store1.flush();
|
|
369
|
+
const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
370
|
+
await store2.load();
|
|
371
|
+
expect(store2.size()).toBe(2);
|
|
372
|
+
expect(store2.list({ status: 'all' }).map((b) => b.title).sort()).toEqual(['Alpha', 'Beta']);
|
|
373
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
374
|
+
});
|
|
375
|
+
it('resumes the ID counter from the highest loaded ID', async () => {
|
|
376
|
+
const fsp = await import('node:fs/promises');
|
|
377
|
+
const path = '/tmp/discoclaw-test-store-counter.jsonl';
|
|
378
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
379
|
+
const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
380
|
+
store1.create({ title: 'A' }); // ws-001
|
|
381
|
+
store1.create({ title: 'B' }); // ws-002
|
|
382
|
+
await store1.flush();
|
|
383
|
+
const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
384
|
+
await store2.load();
|
|
385
|
+
const c = store2.create({ title: 'C' });
|
|
386
|
+
expect(c.id).toBe('ws-003');
|
|
387
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
388
|
+
});
|
|
389
|
+
it('handles a missing file gracefully (ENOENT)', async () => {
|
|
390
|
+
const store = new TaskStore({ prefix: 'ws', persistPath: '/tmp/no-such-file-99999.jsonl' });
|
|
391
|
+
await expect(store.load()).resolves.toBeUndefined();
|
|
392
|
+
expect(store.size()).toBe(0);
|
|
393
|
+
});
|
|
394
|
+
it('persists updates and closes', async () => {
|
|
395
|
+
const fsp = await import('node:fs/promises');
|
|
396
|
+
const path = '/tmp/discoclaw-test-store-updates.jsonl';
|
|
397
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
398
|
+
const store1 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
399
|
+
const t = store1.create({ title: 'T' });
|
|
400
|
+
store1.update(t.id, { title: 'Updated T' });
|
|
401
|
+
store1.close(t.id, 'done');
|
|
402
|
+
await store1.flush();
|
|
403
|
+
const store2 = new TaskStore({ prefix: 'ws', persistPath: path });
|
|
404
|
+
await store2.load();
|
|
405
|
+
const loaded = store2.get(t.id);
|
|
406
|
+
expect(loaded.status).toBe('closed');
|
|
407
|
+
expect(loaded.title).toBe('Updated T');
|
|
408
|
+
expect(loaded.close_reason).toBe('done');
|
|
409
|
+
await fsp.default.unlink(path).catch(() => { });
|
|
410
|
+
});
|
|
411
|
+
it('is a no-op when no persistPath is configured', async () => {
|
|
412
|
+
const store = new TaskStore({ prefix: 'ws' });
|
|
413
|
+
store.create({ title: 'T' });
|
|
414
|
+
await expect(store.flush()).resolves.toBeUndefined();
|
|
415
|
+
await expect(store.load()).resolves.toBeUndefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task sync contract
|
|
3
|
+
*
|
|
4
|
+
* This module is the canonical source for task-thread lifecycle ownership:
|
|
5
|
+
* - Which TaskStore mutation events trigger coordinator sync.
|
|
6
|
+
* - Which task actions perform direct thread lifecycle operations.
|
|
7
|
+
*
|
|
8
|
+
* Keep this contract behavior-only; avoid importing Discord/runtime modules.
|
|
9
|
+
*/
|
|
10
|
+
export const TASK_STORE_MUTATION_EVENTS = ['created', 'updated', 'closed', 'labeled'];
|
|
11
|
+
export const TASK_SYNC_TRIGGER_EVENTS = ['updated', 'closed', 'labeled'];
|
|
12
|
+
const TASK_SYNC_TRIGGER_EVENT_SET = new Set(TASK_SYNC_TRIGGER_EVENTS);
|
|
13
|
+
export function shouldTriggerTaskSyncForStoreEvent(event) {
|
|
14
|
+
return TASK_SYNC_TRIGGER_EVENT_SET.has(event);
|
|
15
|
+
}
|
|
16
|
+
export const TASK_DIRECT_THREAD_ACTIONS = ['taskCreate', 'taskUpdate', 'taskClose'];
|
|
17
|
+
const TASK_DIRECT_THREAD_ACTION_SET = new Set(TASK_DIRECT_THREAD_ACTIONS);
|
|
18
|
+
/**
|
|
19
|
+
* Direct thread lifecycle ownership (create/update/close) remains in action flow
|
|
20
|
+
* for these actions; other actions rely on coordinator-only sync.
|
|
21
|
+
*/
|
|
22
|
+
export function shouldActionUseDirectThreadLifecycle(actionType) {
|
|
23
|
+
return TASK_DIRECT_THREAD_ACTION_SET.has(actionType);
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TASK_DIRECT_THREAD_ACTIONS, TASK_STORE_MUTATION_EVENTS, TASK_SYNC_TRIGGER_EVENTS, shouldActionUseDirectThreadLifecycle, shouldTriggerTaskSyncForStoreEvent, } from './sync-contract.js';
|
|
3
|
+
describe('task sync contract', () => {
|
|
4
|
+
it('defines a stable TaskStore mutation universe', () => {
|
|
5
|
+
expect(TASK_STORE_MUTATION_EVENTS).toEqual(['created', 'updated', 'closed', 'labeled']);
|
|
6
|
+
});
|
|
7
|
+
it('triggers sync only for updated/closed/labeled mutations', () => {
|
|
8
|
+
for (const event of TASK_SYNC_TRIGGER_EVENTS) {
|
|
9
|
+
expect(shouldTriggerTaskSyncForStoreEvent(event)).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
expect(shouldTriggerTaskSyncForStoreEvent('created')).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it('marks direct thread lifecycle ownership for create/update/close actions', () => {
|
|
14
|
+
expect(TASK_DIRECT_THREAD_ACTIONS).toEqual(['taskCreate', 'taskUpdate', 'taskClose']);
|
|
15
|
+
expect(shouldActionUseDirectThreadLifecycle('taskCreate')).toBe(true);
|
|
16
|
+
expect(shouldActionUseDirectThreadLifecycle('taskUpdate')).toBe(true);
|
|
17
|
+
expect(shouldActionUseDirectThreadLifecycle('taskClose')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('keeps non-lifecycle actions out of direct thread ownership', () => {
|
|
20
|
+
expect(shouldActionUseDirectThreadLifecycle('taskShow')).toBe(false);
|
|
21
|
+
expect(shouldActionUseDirectThreadLifecycle('taskList')).toBe(false);
|
|
22
|
+
expect(shouldActionUseDirectThreadLifecycle('taskSync')).toBe(false);
|
|
23
|
+
expect(shouldActionUseDirectThreadLifecycle('tagMapReload')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function classifySyncError(message) {
|
|
2
|
+
const msg = String(message ?? '').toLowerCase();
|
|
3
|
+
if (!msg)
|
|
4
|
+
return 'unknown';
|
|
5
|
+
if (msg.includes('timed out'))
|
|
6
|
+
return 'timeout';
|
|
7
|
+
if (msg.includes('missing permissions') || msg.includes('missing access'))
|
|
8
|
+
return 'discord_permissions';
|
|
9
|
+
if (msg.includes('unauthorized') || msg.includes('auth'))
|
|
10
|
+
return 'auth';
|
|
11
|
+
if (msg.includes('stream stall'))
|
|
12
|
+
return 'stream_stall';
|
|
13
|
+
return 'other';
|
|
14
|
+
}
|
|
15
|
+
function incrementIfPositive(metrics, name, value) {
|
|
16
|
+
const count = Number(value ?? 0);
|
|
17
|
+
if (count > 0)
|
|
18
|
+
metrics.increment(name, count);
|
|
19
|
+
}
|
|
20
|
+
export function recordSyncSuccessMetrics(metrics, result, durationMs) {
|
|
21
|
+
metrics.increment('tasks.sync.succeeded');
|
|
22
|
+
metrics.increment('tasks.sync.duration_ms.total', Math.max(0, durationMs));
|
|
23
|
+
metrics.increment('tasks.sync.duration_ms.samples');
|
|
24
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.threads_created', result.threadsCreated);
|
|
25
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.thread_names_updated', result.emojisUpdated);
|
|
26
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.starter_messages_updated', result.starterMessagesUpdated);
|
|
27
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.threads_archived', result.threadsArchived);
|
|
28
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.statuses_updated', result.statusesUpdated);
|
|
29
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.tags_updated', result.tagsUpdated);
|
|
30
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.threads_reconciled', result.threadsReconciled);
|
|
31
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.orphan_threads_found', result.orphanThreadsFound);
|
|
32
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.closes_deferred', result.closesDeferred);
|
|
33
|
+
incrementIfPositive(metrics, 'tasks.sync.transition.warnings', result.warnings);
|
|
34
|
+
}
|
|
35
|
+
export function recordSyncFailureMetrics(metrics, error, durationMs) {
|
|
36
|
+
metrics.increment('tasks.sync.failed');
|
|
37
|
+
metrics.increment('tasks.sync.duration_ms.total', Math.max(0, durationMs));
|
|
38
|
+
metrics.increment('tasks.sync.duration_ms.samples');
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
40
|
+
metrics.increment(`tasks.sync.error_class.${classifySyncError(message)}`);
|
|
41
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { classifySyncError } from './sync-coordinator-metrics.js';
|
|
2
|
+
export function createTaskSyncRetryState() {
|
|
3
|
+
return {
|
|
4
|
+
failureRetryPending: false,
|
|
5
|
+
deferredCloseRetryPending: false,
|
|
6
|
+
failureRetryTimeout: null,
|
|
7
|
+
deferredCloseRetryTimeout: null,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function scheduleFailureRetry(ctrl) {
|
|
11
|
+
if (ctrl.enableFailureRetry === false) {
|
|
12
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.disabled');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (ctrl.state.failureRetryPending) {
|
|
16
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.coalesced');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
ctrl.state.failureRetryPending = true;
|
|
20
|
+
const delayMs = ctrl.failureRetryDelayMs ?? 30_000;
|
|
21
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.scheduled');
|
|
22
|
+
ctrl.log?.info({ delayMs }, 'tasks:coordinator scheduling retry after sync failure');
|
|
23
|
+
ctrl.state.failureRetryTimeout = setTimeout(() => {
|
|
24
|
+
ctrl.state.failureRetryPending = false;
|
|
25
|
+
ctrl.state.failureRetryTimeout = null;
|
|
26
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.triggered');
|
|
27
|
+
ctrl.runSync().catch((err) => {
|
|
28
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.failed');
|
|
29
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
30
|
+
ctrl.metrics.increment(`tasks.sync.failure_retry.error_class.${classifySyncError(message)}`);
|
|
31
|
+
ctrl.log?.warn({ err }, 'tasks:coordinator failure retry sync failed');
|
|
32
|
+
});
|
|
33
|
+
}, delayMs);
|
|
34
|
+
}
|
|
35
|
+
export function cancelFailureRetry(ctrl) {
|
|
36
|
+
if (!ctrl.state.failureRetryPending || !ctrl.state.failureRetryTimeout)
|
|
37
|
+
return;
|
|
38
|
+
clearTimeout(ctrl.state.failureRetryTimeout);
|
|
39
|
+
ctrl.state.failureRetryTimeout = null;
|
|
40
|
+
ctrl.state.failureRetryPending = false;
|
|
41
|
+
ctrl.metrics.increment('tasks.sync.failure_retry.canceled');
|
|
42
|
+
}
|
|
43
|
+
export function scheduleDeferredCloseRetry(ctrl, closesDeferred) {
|
|
44
|
+
if (ctrl.state.deferredCloseRetryPending) {
|
|
45
|
+
ctrl.metrics.increment('tasks.sync.retry.coalesced');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
ctrl.state.deferredCloseRetryPending = true;
|
|
49
|
+
const delayMs = ctrl.deferredRetryDelayMs ?? 30_000;
|
|
50
|
+
ctrl.metrics.increment('tasks.sync.retry.scheduled');
|
|
51
|
+
ctrl.log?.info({ closesDeferred, delayMs }, 'tasks:coordinator scheduling retry for deferred closes');
|
|
52
|
+
ctrl.state.deferredCloseRetryTimeout = setTimeout(() => {
|
|
53
|
+
ctrl.state.deferredCloseRetryPending = false;
|
|
54
|
+
ctrl.state.deferredCloseRetryTimeout = null;
|
|
55
|
+
ctrl.metrics.increment('tasks.sync.retry.triggered');
|
|
56
|
+
ctrl.runSync().catch((err) => {
|
|
57
|
+
ctrl.metrics.increment('tasks.sync.retry.failed');
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
59
|
+
ctrl.metrics.increment(`tasks.sync.retry.error_class.${classifySyncError(message)}`);
|
|
60
|
+
ctrl.log?.warn({ err }, 'tasks:coordinator deferred-close retry failed');
|
|
61
|
+
});
|
|
62
|
+
}, delayMs);
|
|
63
|
+
}
|
|
64
|
+
export function cancelDeferredCloseRetry(ctrl) {
|
|
65
|
+
if (!ctrl.state.deferredCloseRetryPending || !ctrl.state.deferredCloseRetryTimeout)
|
|
66
|
+
return;
|
|
67
|
+
clearTimeout(ctrl.state.deferredCloseRetryTimeout);
|
|
68
|
+
ctrl.state.deferredCloseRetryTimeout = null;
|
|
69
|
+
ctrl.state.deferredCloseRetryPending = false;
|
|
70
|
+
ctrl.metrics.increment('tasks.sync.retry.canceled');
|
|
71
|
+
}
|