@cohortapp/agent-sdk 2.3.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/.claude/commands/init-agent.md +104 -0
- package/.claude/commands/init-maestro.md +1187 -0
- package/.claude/settings.json +161 -0
- package/.env.example +216 -0
- package/README.md +632 -0
- package/agents/browser-operator/agent.md +52 -0
- package/agents/calendar-ops/agent.md +50 -0
- package/agents/communications/agent.md +96 -0
- package/agents/decision-log/agent.md +65 -0
- package/agents/desktop-operator/agent.md +59 -0
- package/agents/gmail-operator/agent.md +62 -0
- package/agents/inbound-dispatcher/agent.md +66 -0
- package/agents/inbox-processor/agent.md +39 -0
- package/agents/pmo-execution/agent.md +60 -0
- package/agents/session-spawner/agent.md +64 -0
- package/agents/slack-operator/agent.md +60 -0
- package/agents/whatsapp-operator/agent.md +60 -0
- package/agents/workflow-automation/agent.md +61 -0
- package/archetypes/altitudes/c-suite.yaml +58 -0
- package/archetypes/altitudes/founder.yaml +68 -0
- package/archetypes/altitudes/senior-manager.yaml +63 -0
- package/archetypes/altitudes/svp.yaml +60 -0
- package/archetypes/altitudes/vp.yaml +50 -0
- package/archetypes/archetype.schema.json +77 -0
- package/archetypes/base.yaml +47 -0
- package/archetypes/capabilities/commercial-leader.yaml +159 -0
- package/archetypes/capabilities/compliance-officer.yaml +159 -0
- package/archetypes/capabilities/executive-operator.yaml +169 -0
- package/archetypes/capabilities/finance-leader.yaml +162 -0
- package/archetypes/capabilities/operations-leader.yaml +154 -0
- package/archetypes/capabilities/product-leader.yaml +148 -0
- package/archetypes/capabilities/technical-leader.yaml +146 -0
- package/archetypes/functions/commercial-leader.yaml +62 -0
- package/archetypes/functions/compliance-officer.yaml +64 -0
- package/archetypes/functions/executive-operator.yaml +70 -0
- package/archetypes/functions/finance-leader.yaml +70 -0
- package/archetypes/functions/operations-leader.yaml +62 -0
- package/archetypes/functions/product-leader.yaml +61 -0
- package/archetypes/functions/technical-leader.yaml +57 -0
- package/bin/cohort-mcp.mjs +81 -0
- package/bin/maestro.mjs +3516 -0
- package/bin/maestro.test.mjs +1015 -0
- package/desktop-control/README.md +56 -0
- package/desktop-control/app-profiles/gmail.yaml +120 -0
- package/desktop-control/app-profiles/slack.yaml +315 -0
- package/desktop-control/app-profiles/whatsapp.yaml +107 -0
- package/docs/architecture/agent-topology.md +2239 -0
- package/docs/architecture/archetype-agent-factory.md +110 -0
- package/docs/architecture/collective-memory-and-org-mesh.md +115 -0
- package/docs/architecture/continuous-monitoring.md +221 -0
- package/docs/architecture/mcp-capability-map.md +585 -0
- package/docs/architecture/system-architecture.md +1272 -0
- package/docs/company-context/README.md +40 -0
- package/docs/guides/agent-persona-setup.md +600 -0
- package/docs/guides/agents-observe-setup.md +64 -0
- package/docs/guides/billing-console-keys.md +88 -0
- package/docs/guides/ccxray-diagnostics.md +65 -0
- package/docs/guides/channel-bus.md +127 -0
- package/docs/guides/claude-mem-setup.md +79 -0
- package/docs/guides/claude-pace-setup.md +56 -0
- package/docs/guides/claudraband-sessions.md +98 -0
- package/docs/guides/clawteam-swarm.md +116 -0
- package/docs/guides/code-review-graph-setup.md +86 -0
- package/docs/guides/email-setup.md +431 -0
- package/docs/guides/mac-mini.md +119 -0
- package/docs/guides/media-generation-setup.md +349 -0
- package/docs/guides/model-routing.md +162 -0
- package/docs/guides/observability-otel.md +265 -0
- package/docs/guides/org-onboarding.md +132 -0
- package/docs/guides/outbound-governance-setup.md +437 -0
- package/docs/guides/pdf-generation-setup.md +315 -0
- package/docs/guides/poller-daemon-setup.md +563 -0
- package/docs/guides/rag-context-setup.md +459 -0
- package/docs/guides/self-optimization-pattern.md +82 -0
- package/docs/guides/setup-wizard.md +178 -0
- package/docs/guides/slack-setup.md +350 -0
- package/docs/guides/telegram-setup.md +227 -0
- package/docs/guides/twilio-subaccounts-setup.md +223 -0
- package/docs/guides/verification.md +128 -0
- package/docs/guides/voice-mode.md +188 -0
- package/docs/guides/voice-sms-setup.md +698 -0
- package/docs/guides/webhook-relay-setup.md +349 -0
- package/docs/guides/whatsapp-setup.md +288 -0
- package/docs/prompts/board-pack-cover-template.md +36 -0
- package/docs/prompts/decision-recommendation-template.md +88 -0
- package/docs/prompts/followup-message-template.md +141 -0
- package/docs/prompts/investor-letter-template.md +52 -0
- package/docs/prompts/morning-brief-template.md +82 -0
- package/docs/prompts/presentation-template.md +58 -0
- package/docs/prompts/weekly-strategic-memo-template.md +104 -0
- package/docs/research/hallucinated-tool-output-investigation.md +151 -0
- package/docs/runbooks/backup-restore.md +205 -0
- package/docs/runbooks/cohort-cutover.md +129 -0
- package/docs/runbooks/fleet-operations.md +200 -0
- package/docs/runbooks/incident-response.md +226 -0
- package/docs/runbooks/mac-mini-bootstrap.md +431 -0
- package/docs/runbooks/perpetual-operations.md +509 -0
- package/docs/runbooks/recovery-and-failover.md +260 -0
- package/framework-features.json +267 -0
- package/ingest/README.md +87 -0
- package/lib/action-executor.js +689 -0
- package/lib/action-executor.test.mjs +871 -0
- package/lib/agent-root.mjs +37 -0
- package/lib/archetype.mjs +236 -0
- package/lib/archetype.test.mjs +132 -0
- package/lib/autonomy.mjs +114 -0
- package/lib/autonomy.test.mjs +66 -0
- package/lib/backlog.mjs +358 -0
- package/lib/backlog.test.mjs +266 -0
- package/lib/budget-guard.mjs +279 -0
- package/lib/budget-guard.test.mjs +291 -0
- package/lib/cadence-bus-schedule.test.mjs +194 -0
- package/lib/cadence-bus.mjs +1120 -0
- package/lib/cadence-bus.test.mjs +720 -0
- package/lib/cadences.mjs +205 -0
- package/lib/cadences.test.mjs +125 -0
- package/lib/capability.mjs +154 -0
- package/lib/capability.test.mjs +78 -0
- package/lib/channels/base-adapter.mjs +719 -0
- package/lib/channels/base-adapter.test.mjs +590 -0
- package/lib/channels/channel.mjs +128 -0
- package/lib/channels/channels.test.mjs +371 -0
- package/lib/channels/contract.mjs +215 -0
- package/lib/channels/contract.test.mjs +137 -0
- package/lib/channels/conversation-resolver.mjs +95 -0
- package/lib/channels/gmail/adapter.mjs +87 -0
- package/lib/channels/inbox-item.mjs +255 -0
- package/lib/channels/inbox-item.test.mjs +335 -0
- package/lib/channels/index.mjs +94 -0
- package/lib/channels/orgmail/adapter.mjs +353 -0
- package/lib/channels/orgmail/adapter.test.mjs +311 -0
- package/lib/channels/pairing.mjs +363 -0
- package/lib/channels/pairing.test.mjs +270 -0
- package/lib/channels/registry.mjs +164 -0
- package/lib/channels/slack/adapter.mjs +317 -0
- package/lib/channels/slack-adapter.test.mjs +212 -0
- package/lib/channels/sms/adapter.mjs +43 -0
- package/lib/channels/telegram/adapter.mjs +432 -0
- package/lib/channels/telegram-adapter.test.mjs +306 -0
- package/lib/channels/voice/adapter.mjs +301 -0
- package/lib/channels/voice/adapter.test.mjs +278 -0
- package/lib/channels/whatsapp/adapter-baileys.mjs +587 -0
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +359 -0
- package/lib/channels/whatsapp/adapter-twilio.mjs +65 -0
- package/lib/channels/whatsapp/baileys-typing.test.mjs +154 -0
- package/lib/charter.mjs +256 -0
- package/lib/charter.test.mjs +89 -0
- package/lib/claude-bin.mjs +134 -0
- package/lib/claude-bin.test.mjs +75 -0
- package/lib/collective/capture.mjs +185 -0
- package/lib/collective/capture.test.mjs +121 -0
- package/lib/collective/cards.mjs +201 -0
- package/lib/collective/cards.test.mjs +114 -0
- package/lib/collective/config.mjs +186 -0
- package/lib/collective/config.test.mjs +123 -0
- package/lib/collective/global-config.mjs +113 -0
- package/lib/collective/global-config.test.mjs +75 -0
- package/lib/collective/presence.mjs +201 -0
- package/lib/collective/presence.test.mjs +95 -0
- package/lib/collective/recall.mjs +215 -0
- package/lib/collective/recall.test.mjs +116 -0
- package/lib/comms/send-gate.mjs +554 -0
- package/lib/comms/send-gate.test.mjs +577 -0
- package/lib/comms.mjs +67 -0
- package/lib/comms.test.mjs +41 -0
- package/lib/diagnostics/alerts.mjs +424 -0
- package/lib/diagnostics/alerts.test.mjs +318 -0
- package/lib/diagnostics/backup-freshness.mjs +188 -0
- package/lib/diagnostics/backup-freshness.test.mjs +185 -0
- package/lib/diagnostics/counters.mjs +269 -0
- package/lib/diagnostics/counters.test.mjs +206 -0
- package/lib/diagnostics/events.mjs +188 -0
- package/lib/diagnostics/events.test.mjs +290 -0
- package/lib/diagnostics/otel.mjs +237 -0
- package/lib/diagnostics/otel.test.mjs +196 -0
- package/lib/diagnostics/trace.mjs +216 -0
- package/lib/diagnostics/trace.test.mjs +251 -0
- package/lib/env-compat.mjs +74 -0
- package/lib/env-compat.test.mjs +104 -0
- package/lib/feature-init.mjs +331 -0
- package/lib/fs-atomic.mjs +112 -0
- package/lib/fs-atomic.test.mjs +72 -0
- package/lib/fs-ownership.mjs +111 -0
- package/lib/fs-ownership.test.mjs +158 -0
- package/lib/hooks/bus.mjs +347 -0
- package/lib/hooks/bus.test.mjs +387 -0
- package/lib/index.js +16 -0
- package/lib/learning/config.mjs +106 -0
- package/lib/learning/config.test.mjs +75 -0
- package/lib/learning/counters.mjs +156 -0
- package/lib/learning/counters.test.mjs +69 -0
- package/lib/learning/curator-consolidate.test.mjs +238 -0
- package/lib/learning/curator.mjs +453 -0
- package/lib/learning/curator.test.mjs +106 -0
- package/lib/learning/log.mjs +40 -0
- package/lib/learning/reflect.mjs +534 -0
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.mjs +352 -0
- package/lib/learning/session-index.test.mjs +125 -0
- package/lib/learning/skill-writer.mjs +474 -0
- package/lib/learning/skill-writer.test.mjs +210 -0
- package/lib/mcp/server.mjs +328 -0
- package/lib/mcp/server.test.mjs +400 -0
- package/lib/model-router/auth-profiles.mjs +758 -0
- package/lib/model-router/auth-profiles.test.mjs +580 -0
- package/lib/model-router/catalog/anthropic.yaml +153 -0
- package/lib/model-router/catalog/deepseek.yaml +86 -0
- package/lib/model-router/catalog/moonshot.yaml +81 -0
- package/lib/model-router/catalog/qwen.yaml +114 -0
- package/lib/model-router/catalog.mjs +925 -0
- package/lib/model-router/catalog.test.mjs +385 -0
- package/lib/model-router/economics.mjs +564 -0
- package/lib/model-router/economics.test.mjs +344 -0
- package/lib/model-router/failover.mjs +298 -0
- package/lib/model-router/failover.test.mjs +439 -0
- package/lib/model-router/health.mjs +453 -0
- package/lib/model-router/health.test.mjs +338 -0
- package/lib/model-router/integration-coverage.test.mjs +829 -0
- package/lib/model-router/integration.test.mjs +564 -0
- package/lib/model-router/ledger.mjs +402 -0
- package/lib/model-router/ledger.test.mjs +382 -0
- package/lib/model-router/llm-task.mjs +515 -0
- package/lib/model-router/llm-task.test.mjs +392 -0
- package/lib/model-router/org-credentials.mjs +260 -0
- package/lib/model-router/org-credentials.test.mjs +265 -0
- package/lib/model-router/pricing-refresh.mjs +463 -0
- package/lib/model-router/pricing-refresh.test.mjs +286 -0
- package/lib/model-router/reconcile.mjs +429 -0
- package/lib/model-router/reconcile.test.mjs +316 -0
- package/lib/model-router/repair.mjs +471 -0
- package/lib/model-router/repair.test.mjs +180 -0
- package/lib/model-router/resolve.mjs +1206 -0
- package/lib/model-router/spawn.mjs +497 -0
- package/lib/model-router/spawn.test.mjs +425 -0
- package/lib/model-router/taxonomy.mjs +893 -0
- package/lib/model-router/taxonomy.test.mjs +410 -0
- package/lib/model-router.mjs +677 -0
- package/lib/model-router.test.mjs +907 -0
- package/lib/org/activity.mjs +211 -0
- package/lib/org/activity.test.mjs +134 -0
- package/lib/org/approvals.mjs +448 -0
- package/lib/org/approvals.test.mjs +216 -0
- package/lib/org/awareness.mjs +222 -0
- package/lib/org/awareness.test.mjs +159 -0
- package/lib/org/board.mjs +229 -0
- package/lib/org/board.test.mjs +177 -0
- package/lib/org/bootstrap-context.mjs +169 -0
- package/lib/org/bootstrap-context.test.mjs +153 -0
- package/lib/org/client.mjs +1628 -0
- package/lib/org/client.test.mjs +1107 -0
- package/lib/org/cohort-client.mjs +67 -0
- package/lib/org/cohort-client.test.mjs +126 -0
- package/lib/org/cost-sync.mjs +227 -0
- package/lib/org/cost-sync.test.mjs +153 -0
- package/lib/org/doctor.mjs +212 -0
- package/lib/org/doctor.test.mjs +212 -0
- package/lib/org/handoff.mjs +293 -0
- package/lib/org/handoff.test.mjs +269 -0
- package/lib/org/integration-tools.mjs +182 -0
- package/lib/org/integration-tools.test.mjs +160 -0
- package/lib/org/keys.mjs +131 -0
- package/lib/org/keys.test.mjs +92 -0
- package/lib/org/knowledge.mjs +463 -0
- package/lib/org/knowledge.test.mjs +319 -0
- package/lib/org/leases.mjs +335 -0
- package/lib/org/leases.test.mjs +235 -0
- package/lib/org/mesh-integration.test.mjs +127 -0
- package/lib/org/mesh.mjs +459 -0
- package/lib/org/mesh.test.mjs +345 -0
- package/lib/org/messaging.mjs +503 -0
- package/lib/org/messaging.test.mjs +238 -0
- package/lib/org/policy.mjs +345 -0
- package/lib/org/policy.test.mjs +237 -0
- package/lib/org/protocol.checksum +1 -0
- package/lib/org/protocol.checksum.test.mjs +90 -0
- package/lib/org/protocol.mjs +967 -0
- package/lib/org/protocol.test.mjs +264 -0
- package/lib/org/registry.mjs +194 -0
- package/lib/org/registry.test.mjs +100 -0
- package/lib/org/tool-surface-integration.test.mjs +120 -0
- package/lib/org/tool-surface.mjs +2535 -0
- package/lib/org/tool-surface.test.mjs +589 -0
- package/lib/org/ui-parity.mjs +3236 -0
- package/lib/org/ui-parity.test.mjs +348 -0
- package/lib/org/verify.mjs +176 -0
- package/lib/org/verify.test.mjs +194 -0
- package/lib/rag/embed.mjs +188 -0
- package/lib/rag/indexer.mjs +425 -0
- package/lib/rag/rag.test.mjs +505 -0
- package/lib/rag/search.mjs +475 -0
- package/lib/rate-guard.mjs +246 -0
- package/lib/rate-guard.test.mjs +201 -0
- package/lib/render.mjs +112 -0
- package/lib/render.test.mjs +68 -0
- package/lib/resource-governor.mjs +297 -0
- package/lib/resource-governor.test.mjs +262 -0
- package/lib/scheduling/dynamic-jobs.mjs +675 -0
- package/lib/scheduling/dynamic-jobs.test.mjs +344 -0
- package/lib/scheduling/jitter.mjs +0 -0
- package/lib/scheduling/jitter.test.mjs +140 -0
- package/lib/secrets/broker.mjs +315 -0
- package/lib/secrets/broker.test.mjs +280 -0
- package/lib/secrets/providers.mjs +461 -0
- package/lib/secrets/providers.test.mjs +274 -0
- package/lib/security/audit-engine.mjs +684 -0
- package/lib/security/audit-engine.test.mjs +389 -0
- package/lib/security/coerce-args.mjs +552 -0
- package/lib/security/coerce-args.test.mjs +281 -0
- package/lib/security/dangerous-tools.mjs +97 -0
- package/lib/security/dangerous-tools.test.mjs +68 -0
- package/lib/security/external-content.mjs +145 -0
- package/lib/security/external-content.test.mjs +67 -0
- package/lib/security/redact.mjs +592 -0
- package/lib/security/redact.test.mjs +441 -0
- package/lib/security/secret-equal.mjs +73 -0
- package/lib/security/secret-equal.test.mjs +55 -0
- package/lib/session-permissions.mjs +101 -0
- package/lib/session-permissions.test.mjs +100 -0
- package/lib/setup/claude-probe.mjs +74 -0
- package/lib/setup/completeness.mjs +175 -0
- package/lib/setup/completeness.test.mjs +110 -0
- package/lib/setup/context-pack.mjs +173 -0
- package/lib/setup/context-pack.test.mjs +89 -0
- package/lib/setup/enrich.mjs +277 -0
- package/lib/setup/enrich.test.mjs +115 -0
- package/lib/setup/enroll-from-cohort.mjs +441 -0
- package/lib/setup/enroll-from-cohort.test.mjs +233 -0
- package/lib/setup/integration.test.mjs +162 -0
- package/lib/setup/io.mjs +360 -0
- package/lib/setup/io.test.mjs +77 -0
- package/lib/setup/run-generator.mjs +81 -0
- package/lib/setup/runner.mjs +244 -0
- package/lib/setup/runner.test.mjs +132 -0
- package/lib/setup/sections/comms.mjs +173 -0
- package/lib/setup/sections/company.mjs +120 -0
- package/lib/setup/sections/enrich.mjs +138 -0
- package/lib/setup/sections/identity.mjs +182 -0
- package/lib/setup/sections/identity.test.mjs +140 -0
- package/lib/setup/sections/learning.mjs +153 -0
- package/lib/setup/sections/learning.test.mjs +81 -0
- package/lib/setup/sections/messaging.mjs +219 -0
- package/lib/setup/sections/messaging.test.mjs +127 -0
- package/lib/setup/sections/model.mjs +102 -0
- package/lib/setup/sections/operating-model.mjs +78 -0
- package/lib/setup/sections/org.mjs +475 -0
- package/lib/setup/sections/org.test.mjs +313 -0
- package/lib/setup/sections/orgmail.mjs +173 -0
- package/lib/setup/sections/orgmail.test.mjs +118 -0
- package/lib/setup/sections/recovery.mjs +159 -0
- package/lib/setup/sections/recovery.test.mjs +98 -0
- package/lib/setup/sections/tools.mjs +132 -0
- package/lib/setup/sections/verify.mjs +97 -0
- package/lib/setup/sot.mjs +205 -0
- package/lib/setup/sot.test.mjs +81 -0
- package/lib/setup/state.mjs +151 -0
- package/lib/setup/state.test.mjs +92 -0
- package/lib/singleton.js +229 -0
- package/lib/singleton.test.mjs +135 -0
- package/lib/telemetry/alerts.mjs +216 -0
- package/lib/telemetry/alerts.test.mjs +109 -0
- package/lib/telemetry/collect.mjs +512 -0
- package/lib/telemetry/collect.test.mjs +202 -0
- package/lib/tool-definitions-integration.test.mjs +83 -0
- package/lib/tool-definitions.js +738 -0
- package/lib/tool-definitions.test.mjs +437 -0
- package/lib/util/fetch-timeout.mjs +136 -0
- package/lib/util/fetch-timeout.test.mjs +202 -0
- package/lib/util/reconnect.mjs +343 -0
- package/lib/util/reconnect.test.mjs +369 -0
- package/lib/util/unhandled.mjs +205 -0
- package/lib/util/unhandled.test.mjs +216 -0
- package/lib/voice/context-loader.mjs +466 -0
- package/lib/voice/index.mjs +100 -0
- package/lib/voice/openai-realtime.mjs +510 -0
- package/lib/voice/outbound.mjs +542 -0
- package/lib/voice/outbound.test.mjs +69 -0
- package/lib/voice/post-call-brief.mjs +428 -0
- package/lib/voice/provider.mjs +52 -0
- package/lib/voice/session-rotation.mjs +257 -0
- package/lib/voice/stt.mjs +161 -0
- package/lib/voice/stt.test.mjs +226 -0
- package/lib/voice/tool-bridge.mjs +370 -0
- package/lib/voice/tts.mjs +104 -0
- package/lib/voice/twilio-sip-bridge.mjs +288 -0
- package/lib/voice/voice.test.mjs +990 -0
- package/mcp/README.md +80 -0
- package/package.json +151 -0
- package/plugins/maestro-skills/plugin.json +139 -0
- package/plugins/maestro-skills/skills/agents-observe.md +110 -0
- package/plugins/maestro-skills/skills/board-deck.md +68 -0
- package/plugins/maestro-skills/skills/books-close.md +77 -0
- package/plugins/maestro-skills/skills/brand-steward.md +121 -0
- package/plugins/maestro-skills/skills/calendar-plan.md +57 -0
- package/plugins/maestro-skills/skills/call-working-sessions.md +124 -0
- package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
- package/plugins/maestro-skills/skills/claude-pace.md +61 -0
- package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
- package/plugins/maestro-skills/skills/crm-pipeline.md +65 -0
- package/plugins/maestro-skills/skills/decision-brief.md +89 -0
- package/plugins/maestro-skills/skills/directory-hygiene.md +125 -0
- package/plugins/maestro-skills/skills/draft-comms.md +84 -0
- package/plugins/maestro-skills/skills/evening-wrap.md +53 -0
- package/plugins/maestro-skills/skills/files-find.md +65 -0
- package/plugins/maestro-skills/skills/generative-ui.md +228 -0
- package/plugins/maestro-skills/skills/hiring-triage.md +74 -0
- package/plugins/maestro-skills/skills/inbox-triage.md +61 -0
- package/plugins/maestro-skills/skills/mail-triage.md +86 -0
- package/plugins/maestro-skills/skills/morning-brief.md +54 -0
- package/plugins/maestro-skills/skills/native-artifacts.md +157 -0
- package/plugins/maestro-skills/skills/org-board.md +133 -0
- package/plugins/maestro-skills/skills/org-credential.md +68 -0
- package/plugins/maestro-skills/skills/org-recall.md +81 -0
- package/plugins/maestro-skills/skills/pipeline-review.md +76 -0
- package/plugins/maestro-skills/skills/regulatory-status.md +81 -0
- package/plugins/maestro-skills/skills/router-why.md +78 -0
- package/plugins/maestro-skills/skills/schedule-meeting.md +91 -0
- package/plugins/maestro-skills/skills/session-search.md +71 -0
- package/plugins/maestro-skills/skills/set-reminder.md +93 -0
- package/plugins/maestro-skills/skills/slack-followup.md +64 -0
- package/plugins/maestro-skills/skills/team-activity.md +86 -0
- package/plugins/maestro-skills/skills/weekly-memo.md +70 -0
- package/policies/action-classification.yaml +114 -0
- package/policies/ai-disclosure.yaml +294 -0
- package/policies/communication-style.md +139 -0
- package/policies/information-barriers.yaml +118 -0
- package/policies/prompt-injection-defence.yaml +138 -0
- package/public/assets/icon-dark.png +0 -0
- package/public/assets/icon-dark.svg +9 -0
- package/public/assets/icon-light.svg +9 -0
- package/public/assets/logo-dark.svg +15 -0
- package/public/assets/logo-light.svg +15 -0
- package/scaffold/.mcp.json +7 -0
- package/scaffold/CLAUDE.md +368 -0
- package/scaffold/config/agent.json +55 -0
- package/scaffold/config/agent.ts +76 -0
- package/scaffold/config/agent.ts.example +89 -0
- package/scaffold/config/alerts.yaml +23 -0
- package/scaffold/config/allowlist.yaml.example +25 -0
- package/scaffold/config/caller-id-map.yaml +46 -0
- package/scaffold/config/collective.yaml +49 -0
- package/scaffold/config/company.json +20 -0
- package/scaffold/config/known-agents.json +6 -0
- package/scaffold/config/learning.yaml +55 -0
- package/scaffold/config/model-routing.yaml.example +104 -0
- package/scaffold/config/org.yaml +25 -0
- package/scaffold/config/orgmail.yaml.example +19 -0
- package/scaffold/config/recovery.yaml +72 -0
- package/scaffold/config/secrets.yaml +27 -0
- package/scaffold/config/slack.yaml.example +35 -0
- package/scaffold/config/telegram.yaml.example +38 -0
- package/scaffold/config/voice.yaml.example +89 -0
- package/scaffold/config/whatsapp.yaml.example +39 -0
- package/schedules/README.md +49 -0
- package/schedules/triggers/backlog-executor.md +102 -0
- package/schedules/triggers/brand-steward.md +72 -0
- package/schedules/triggers/daily-evening-wrap.md +159 -0
- package/schedules/triggers/daily-midday-sweep.md +58 -0
- package/schedules/triggers/daily-morning-brief.md +55 -0
- package/schedules/triggers/directory-hygiene.md +81 -0
- package/schedules/triggers/dynamic-jobs.md +40 -0
- package/schedules/triggers/inbox-processor.md +115 -0
- package/schedules/triggers/meeting-action-capture.md +60 -0
- package/schedules/triggers/meeting-prep.md +69 -0
- package/schedules/triggers/messaging-inbound.md +50 -0
- package/schedules/triggers/org-pulse.md +24 -0
- package/schedules/triggers/quarterly-self-assessment.md +54 -0
- package/schedules/triggers/weekly-engineering-health.md +37 -0
- package/schedules/triggers/weekly-execution.md +65 -0
- package/schedules/triggers/weekly-hiring.md +53 -0
- package/schedules/triggers/weekly-priorities.md +38 -0
- package/schedules/triggers/weekly-strategic-memo.md +124 -0
- package/scripts/archive-email.sh +55 -0
- package/scripts/cadence/cadence-status.mjs +36 -0
- package/scripts/cadence/enqueue-cadence-tick.mjs +174 -0
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +187 -0
- package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
- package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
- package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
- package/scripts/ci/check-docs-accuracy.mjs +493 -0
- package/scripts/ci/check-docs-accuracy.test.mjs +409 -0
- package/scripts/ci/check-exports-exist.mjs +140 -0
- package/scripts/ci/check-files-exist.mjs +107 -0
- package/scripts/ci/check-no-build-artifacts.mjs +111 -0
- package/scripts/ci/check-no-build-artifacts.test.mjs +71 -0
- package/scripts/ci/check-no-confidential.mjs +198 -0
- package/scripts/ci/check-no-conflict-markers.mjs +169 -0
- package/scripts/ci/check-no-residual-identity.mjs +163 -0
- package/scripts/ci/check-no-residual-identity.test.mjs +89 -0
- package/scripts/ci/check-tarball-fidelity.mjs +205 -0
- package/scripts/ci/check-unresolved-tokens.mjs +83 -0
- package/scripts/ci/check.mjs +109 -0
- package/scripts/ci/check.test.mjs +194 -0
- package/scripts/ci/run-coverage.mjs +82 -0
- package/scripts/ci/run-tests.mjs +71 -0
- package/scripts/cloud-relay/README.md +59 -0
- package/scripts/cloud-relay/index.mjs +233 -0
- package/scripts/cloud-relay/package.json +15 -0
- package/scripts/cloud-relay/railway.json +13 -0
- package/scripts/cloud-relay/voice/README.md +94 -0
- package/scripts/cloud-relay/voice/package-lock.json +39 -0
- package/scripts/cloud-relay/voice/package.json +16 -0
- package/scripts/cloud-relay/voice/railway.json +13 -0
- package/scripts/cloud-relay/voice/server.mjs +532 -0
- package/scripts/collective/hook-runner.mjs +211 -0
- package/scripts/collective/hook-runner.test.mjs +90 -0
- package/scripts/collective/org-pulse.mjs +72 -0
- package/scripts/collective/org-sync.mjs +61 -0
- package/scripts/collective/recall.mjs +45 -0
- package/scripts/collective/who.mjs +30 -0
- package/scripts/comms-monitor.sh +288 -0
- package/scripts/configure-whatsapp-sandbox.sh +201 -0
- package/scripts/continuous-monitor.sh +91 -0
- package/scripts/cost/fleet-digest.mjs +407 -0
- package/scripts/cost/fleet-digest.test.mjs +207 -0
- package/scripts/cost/track-claude-usage.mjs +169 -0
- package/scripts/daemon/agent-daemon.mjs +989 -0
- package/scripts/daemon/agent-daemon.test.mjs +525 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +220 -0
- package/scripts/daemon/cadence-consumer.mjs +1080 -0
- package/scripts/daemon/cadence-consumer.test.mjs +770 -0
- package/scripts/daemon/cadence-handlers.mjs +1121 -0
- package/scripts/daemon/cadence-handlers.test.mjs +617 -0
- package/scripts/daemon/classifier.mjs +704 -0
- package/scripts/daemon/classifier.test.mjs +238 -0
- package/scripts/daemon/classify-kind.mjs +54 -0
- package/scripts/daemon/classify-kind.test.mjs +40 -0
- package/scripts/daemon/context-compiler.mjs +605 -0
- package/scripts/daemon/context-compiler.test.mjs +300 -0
- package/scripts/daemon/dispatcher-cooldown.test.mjs +122 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +886 -0
- package/scripts/daemon/dispatcher.mjs +1516 -0
- package/scripts/daemon/health.mjs +72 -0
- package/scripts/daemon/inbox-deferral.mjs +210 -0
- package/scripts/daemon/inbox-deferral.test.mjs +242 -0
- package/scripts/daemon/integration.test.mjs +149 -0
- package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
- package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
- package/scripts/daemon/launchd-wrapper.sh +91 -0
- package/scripts/daemon/lib/session-router.mjs +274 -0
- package/scripts/daemon/lib/session-router.test.mjs +295 -0
- package/scripts/daemon/maestro-daemon.mjs +275 -0
- package/scripts/daemon/prompt-builder.mjs +685 -0
- package/scripts/daemon/prompt-builder.test.mjs +213 -0
- package/scripts/daemon/responder.mjs +854 -0
- package/scripts/daemon/session-lock.mjs +721 -0
- package/scripts/daemon/session-lock.test.mjs +252 -0
- package/scripts/daemon/session-outcomes.mjs +640 -0
- package/scripts/daemon/session-outcomes.test.mjs +533 -0
- package/scripts/daemon/typing-registry.mjs +90 -0
- package/scripts/daemon/typing-registry.test.mjs +77 -0
- package/scripts/daemon/voice-webhook-server.mjs +804 -0
- package/scripts/decisions/capture-decision.mjs +116 -0
- package/scripts/disclosure_assessment.py +873 -0
- package/scripts/disclosure_boundaries.py +562 -0
- package/scripts/email-signature-principal.html +52 -0
- package/scripts/email-signature.html +60 -0
- package/scripts/email_quote_thread.py +167 -0
- package/scripts/email_thread_dedup.py +362 -0
- package/scripts/emergency-stop.sh +81 -0
- package/scripts/healthcheck.sh +116 -0
- package/scripts/hooks/block-mcp-cohort-send.sh +15 -0
- package/scripts/hooks/block-mcp-slack-send.sh +7 -0
- package/scripts/hooks/post-action-log.sh +126 -0
- package/scripts/hooks/pre-send-audit.sh +174 -0
- package/scripts/hooks/pre-send-audit.test.mjs +215 -0
- package/scripts/hooks/session-end-log.sh +27 -0
- package/scripts/hooks/session-start-banner.sh +115 -0
- package/scripts/huddle/audio-bridge.mjs +664 -0
- package/scripts/huddle/boot-slack-cdp.sh +102 -0
- package/scripts/huddle/huddle-controller.mjs +942 -0
- package/scripts/huddle/huddle-server.mjs +1229 -0
- package/scripts/huddle/launch-slack.sh +232 -0
- package/scripts/huddle/openai-realtime-bridge.mjs +462 -0
- package/scripts/huddle/package-lock.json +62 -0
- package/scripts/huddle/package.json +22 -0
- package/scripts/huddle/setup-audio.sh +239 -0
- package/scripts/huddle/start-call.mjs +318 -0
- package/scripts/huddle/test-pipeline.mjs +263 -0
- package/scripts/learning/consolidate-skills.mjs +72 -0
- package/scripts/learning/session-search.mjs +125 -0
- package/scripts/llm_email_dedup.py +442 -0
- package/scripts/local-triggers/generate-plists.sh +432 -0
- package/scripts/local-triggers/generate-plists.test.mjs +413 -0
- package/scripts/local-triggers/install-all.sh +49 -0
- package/scripts/local-triggers/plists/.gitkeep +0 -0
- package/scripts/local-triggers/run-trigger.sh +63 -0
- package/scripts/local-triggers/templates/rag-reindex.plist.template +47 -0
- package/scripts/local-triggers/templates/voice-relay-poller.plist.template +54 -0
- package/scripts/local-triggers/templates/voice-tunnel.plist.template +55 -0
- package/scripts/local-triggers/templates/voice-webhook.plist.template +51 -0
- package/scripts/maintenance/backup-to-cloud.sh +124 -0
- package/scripts/maintenance/health-check.sh +377 -0
- package/scripts/media-generation/README.md +105 -0
- package/scripts/media-generation/gemini-image-client.mjs +173 -0
- package/scripts/media-generation/generate-assets.mjs +289 -0
- package/scripts/media-generation/veo-video-client.mjs +219 -0
- package/scripts/org/send-orgmail.mjs +227 -0
- package/scripts/outbound-dedup-cleanup.sh +43 -0
- package/scripts/outbound-dedup.sh +477 -0
- package/scripts/outbound_dedup.py +115 -0
- package/scripts/parse-voice-transcript.mjs +481 -0
- package/scripts/pdf-generation/README.md +63 -0
- package/scripts/pdf-generation/build-document.mjs +247 -0
- package/scripts/pdf-generation/templates/board-pack.latex +136 -0
- package/scripts/pdf-generation/templates/corporate-letter.latex +126 -0
- package/scripts/pdf-generation/templates/memo.latex +114 -0
- package/scripts/poll-slack-events.sh +35 -0
- package/scripts/poller/calendar-poller.mjs +12 -0
- package/scripts/poller/gmail-poller.mjs +192 -0
- package/scripts/poller/imap-client.mjs +289 -0
- package/scripts/poller/inbox-scan-poller.mjs +156 -0
- package/scripts/poller/inbox-scan-poller.test.mjs +231 -0
- package/scripts/poller/index.mjs +73 -0
- package/scripts/poller/intra-session-check.mjs +285 -0
- package/scripts/poller/lib/cloud-relay-dedup.mjs +88 -0
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +133 -0
- package/scripts/poller/lib/slash-command-handlers.mjs +177 -0
- package/scripts/poller/secondary-gmail-poller.mjs +132 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +368 -0
- package/scripts/poller/slack-poller.mjs +854 -0
- package/scripts/poller/slack-socket-mode.mjs +917 -0
- package/scripts/poller/slack-socket-mode.test.mjs +753 -0
- package/scripts/poller/trigger.mjs +75 -0
- package/scripts/poller/utils.mjs +371 -0
- package/scripts/poller/voice-cloud-relay-client.mjs +179 -0
- package/scripts/poller/voice-poller.mjs +236 -0
- package/scripts/poller-launchd/install.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +40 -0
- package/scripts/poller-launchd/whatsapp-handler.plist.template +39 -0
- package/scripts/post-interaction-indexer.py +1598 -0
- package/scripts/pre-draft-context.py +994 -0
- package/scripts/pre_draft_lookup.py +258 -0
- package/scripts/rag/build-index.mjs +47 -0
- package/scripts/rag/ingest.mjs +111 -0
- package/scripts/rag/search.mjs +119 -0
- package/scripts/rag-indexer.py +629 -0
- package/scripts/restore-from-backup.sh +248 -0
- package/scripts/restore-from-backup.test.mjs +178 -0
- package/scripts/resume-operations.sh +80 -0
- package/scripts/search-secondary-inbox.py +181 -0
- package/scripts/secondary-inbox-poller.py +437 -0
- package/scripts/self-optimization/compute-metrics.py +398 -0
- package/scripts/send-email-as-principal.py +369 -0
- package/scripts/send-email-threaded.py +392 -0
- package/scripts/send-email-with-attachment.py +377 -0
- package/scripts/send-email.sh +131 -0
- package/scripts/send-sms.sh +175 -0
- package/scripts/send-whatsapp.sh +292 -0
- package/scripts/session-start.sh +106 -0
- package/scripts/setup/boot-claude-session.sh +94 -0
- package/scripts/setup/configure-macos.sh +674 -0
- package/scripts/setup/configure-twilio-sip-trunk.mjs +207 -0
- package/scripts/setup/configure-voice-tunnel.mjs +182 -0
- package/scripts/setup/generate-agent-env.mjs +92 -0
- package/scripts/setup/generate-agent-package-json.mjs +222 -0
- package/scripts/setup/generate-agent-package-json.test.mjs +143 -0
- package/scripts/setup/generate-autonomy.mjs +60 -0
- package/scripts/setup/generate-backlog.mjs +101 -0
- package/scripts/setup/generate-cadences.mjs +92 -0
- package/scripts/setup/generate-capability.mjs +162 -0
- package/scripts/setup/generate-charter.mjs +76 -0
- package/scripts/setup/generate-comms.mjs +59 -0
- package/scripts/setup/generate-company.mjs +92 -0
- package/scripts/setup/init-agent.sh +547 -0
- package/scripts/setup/init-agent.test.mjs +151 -0
- package/scripts/setup/init-archetype.mjs +74 -0
- package/scripts/setup/init-backup.mjs +54 -0
- package/scripts/setup/init-cadence-bus.mjs +60 -0
- package/scripts/setup/init-channel-bus.mjs +46 -0
- package/scripts/setup/init-cost-tracking.mjs +45 -0
- package/scripts/setup/init-decision-capture.mjs +66 -0
- package/scripts/setup/init-known-agents.mjs +57 -0
- package/scripts/setup/init-learning.mjs +70 -0
- package/scripts/setup/init-memory-executive.mjs +45 -0
- package/scripts/setup/init-model-router.mjs +124 -0
- package/scripts/setup/init-rag.mjs +174 -0
- package/scripts/setup/init-session-router.mjs +38 -0
- package/scripts/setup/init-slack-socket-mode.mjs +260 -0
- package/scripts/setup/init-telegram.mjs +165 -0
- package/scripts/setup/init-voice-realtime.mjs +204 -0
- package/scripts/setup/init-whatsapp-baileys.mjs +77 -0
- package/scripts/setup/install-dev-tools.sh +150 -0
- package/scripts/setup/lib/install-plist.mjs +95 -0
- package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
- package/scripts/setup/render-environment-yaml.mjs +133 -0
- package/scripts/slack-events-ctl.sh +177 -0
- package/scripts/slack-events-server.mjs +1045 -0
- package/scripts/slack-react.mjs +89 -0
- package/scripts/slack-responded.sh +232 -0
- package/scripts/slack-send.sh +287 -0
- package/scripts/slack-typing.mjs +196 -0
- package/scripts/slack-upload-v2.py +95 -0
- package/scripts/sms-handler.mjs +450 -0
- package/scripts/spawn-session.sh +120 -0
- package/scripts/sync-protocol.mjs +217 -0
- package/scripts/system-verify.sh +184 -0
- package/scripts/test-email-thread-dedup.py +239 -0
- package/scripts/test-information-barriers.py +484 -0
- package/scripts/test-llm-email-dedup.py +251 -0
- package/scripts/test-pre-draft-integration.py +203 -0
- package/scripts/test-rag-phase2.sh +442 -0
- package/scripts/test-rag-search.sh +251 -0
- package/scripts/test-voice-parser.mjs +316 -0
- package/scripts/user-context-search.py +659 -0
- package/scripts/validate_outbound.py +1504 -0
- package/scripts/watchdog/ai.maestro.memory-watchdog.plist +41 -0
- package/scripts/watchdog/force-reboot.sh +157 -0
- package/scripts/watchdog/memory-watchdog.sh +473 -0
- package/scripts/whatsapp-handler.mjs +538 -0
- package/teams/desktop-operations.yaml +34 -0
- package/teams/executive-office.yaml +27 -0
- package/teams/legal-and-regulatory.yaml +24 -0
- package/teams/platform-and-engineering.yaml +23 -0
- package/teams/strategy-and-growth.yaml +29 -0
- package/workflows/continuous/backlog-executor.yaml +141 -0
- package/workflows/continuous/inbound-monitor.yaml +168 -0
- package/workflows/daily/applicant-triage.yaml +197 -0
- package/workflows/daily/comms-triage.yaml +80 -0
- package/workflows/daily/evening-wrap.yaml +105 -0
- package/workflows/daily/morning-brief.yaml +164 -0
- package/workflows/daily/slack-followup-sweep.yaml +87 -0
- package/workflows/event-driven/README.md +50 -0
- package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
- package/workflows/event-driven/pr-review.yaml +107 -0
- package/workflows/monthly/board-readiness.yaml +76 -0
- package/workflows/quarterly/strategic-scenario-analysis.yaml +85 -0
- package/workflows/session-protocol.md +171 -0
- package/workflows/weekly/engineering-health.yaml +154 -0
- package/workflows/weekly/hiring-review.yaml +169 -0
- package/workflows/weekly/rollup-pipeline-review.yaml +76 -0
- package/workflows/weekly/strategic-memo.yaml +79 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Cadence Bus
|
|
3
|
+
*
|
|
4
|
+
* Local, file-backed event queue that decouples scheduled cadence ticks
|
|
5
|
+
* (launchd, manual, daemon, init-maestro, upgrade) from the persistent main
|
|
6
|
+
* Maestro session that actually services them.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists
|
|
9
|
+
* ---------------
|
|
10
|
+
* Before the bus, every scheduled cadence tick (inbox-processor every 5m,
|
|
11
|
+
* backlog-executor every 10m, the daily/weekly/quarterly ones, …) was wired
|
|
12
|
+
* to launch a fresh `claude --print` session via run-trigger.sh. That meant
|
|
13
|
+
* dozens of full Claude Code spawns per day, each paying full auth/context/
|
|
14
|
+
* token overhead even when the tick had nothing to do. It also made
|
|
15
|
+
* emergency-stop, deduplication, rate-limiting, and quota gating ad-hoc.
|
|
16
|
+
*
|
|
17
|
+
* After the bus, launchd just enqueues a tiny JSON event onto a directory.
|
|
18
|
+
* The persistent daemon (maestro-daemon.mjs) consumes events in-process,
|
|
19
|
+
* handles lightweight scan/classify/route work inline, and only spawns a
|
|
20
|
+
* sub-session when the work genuinely warrants it.
|
|
21
|
+
*
|
|
22
|
+
* Storage layout (all paths relative to AGENT_ROOT)
|
|
23
|
+
* --------------------------------------------------
|
|
24
|
+
* state/cadence-bus/
|
|
25
|
+
* inbox/ — newly enqueued events (one JSON per event)
|
|
26
|
+
* claimed/ — events currently being processed
|
|
27
|
+
* processed/<YYYY-MM-DD>/ — successful completions, archived by date
|
|
28
|
+
* failed/ — last-known-bad event payloads (transient errors)
|
|
29
|
+
* dlq/ — events that exceeded retry budget (terminal)
|
|
30
|
+
* queue.jsonl — append-only fallback log (every enqueue lands
|
|
31
|
+
* here too, so no event is lost even if inbox/
|
|
32
|
+
* is unavailable on a given enqueue)
|
|
33
|
+
* health.json — heartbeat written by the consumer
|
|
34
|
+
*
|
|
35
|
+
* Concurrency model
|
|
36
|
+
* -----------------
|
|
37
|
+
* Atomic enqueue: write to a temp file in the same directory, then
|
|
38
|
+
* rename(tmp, final). rename(2) is atomic on macOS APFS
|
|
39
|
+
* when source and destination live on the same volume.
|
|
40
|
+
*
|
|
41
|
+
* Atomic claim: rename(inbox/<id>.json → claimed/<id>.json). If two
|
|
42
|
+
* consumers race for the same file, exactly one rename
|
|
43
|
+
* succeeds; the loser gets ENOENT and skips the event.
|
|
44
|
+
* No locks, no stale-lock pathology.
|
|
45
|
+
*
|
|
46
|
+
* Stale recovery: on consumer startup (and periodically while running),
|
|
47
|
+
* any file in claimed/ older than STALE_CLAIM_MS is
|
|
48
|
+
* either returned to inbox/ (under the retry budget) or
|
|
49
|
+
* moved to dlq/. This handles crashes mid-processing.
|
|
50
|
+
*
|
|
51
|
+
* Event payload schema
|
|
52
|
+
* --------------------
|
|
53
|
+
* {
|
|
54
|
+
* "id": "evt-2026-05-12T15:30:00.123Z-<random>",
|
|
55
|
+
* "type": "cadence_tick" | "manual_tick" | "internal",
|
|
56
|
+
* "source": "launchd" | "manual" | "daemon" | "init-maestro" | "upgrade",
|
|
57
|
+
* "ts": "2026-05-12T15:30:00.123Z",
|
|
58
|
+
* "cadence": "inbox-processor" | "backlog-executor" | "weekly-strategic-memo" | …,
|
|
59
|
+
* "workflow": "continuous/inbox-processor" (optional),
|
|
60
|
+
* "correlation_id": "<external id, optional>",
|
|
61
|
+
* "priority": "high" | "normal" | "low" (default "normal"),
|
|
62
|
+
* "metadata": { … free-form, kept for auditing },
|
|
63
|
+
* "attempts": 0 (auto-managed by consumer)
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* The bus does not interpret `cadence` itself — it only routes by it.
|
|
67
|
+
* Handler selection and execution live in scripts/daemon/cadence-handlers.mjs
|
|
68
|
+
* and scripts/daemon/cadence-consumer.mjs.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import {
|
|
72
|
+
appendFileSync,
|
|
73
|
+
closeSync,
|
|
74
|
+
existsSync,
|
|
75
|
+
fsyncSync,
|
|
76
|
+
mkdirSync,
|
|
77
|
+
openSync,
|
|
78
|
+
readFileSync,
|
|
79
|
+
readdirSync,
|
|
80
|
+
renameSync,
|
|
81
|
+
rmSync,
|
|
82
|
+
statSync,
|
|
83
|
+
truncateSync,
|
|
84
|
+
unlinkSync,
|
|
85
|
+
writeFileSync,
|
|
86
|
+
writeSync,
|
|
87
|
+
} from "node:fs";
|
|
88
|
+
import { join, resolve, dirname } from "node:path";
|
|
89
|
+
import { randomBytes } from "node:crypto";
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Constants (override-friendly via env for tests)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export const BUS_RELATIVE = "state/cadence-bus";
|
|
96
|
+
export const LOGS_RELATIVE = "logs/cadence-bus";
|
|
97
|
+
|
|
98
|
+
// Bus schema version. If we change the on-disk layout in a backwards-
|
|
99
|
+
// incompatible way, bump this and write a migration. Doctor reads this.
|
|
100
|
+
export const BUS_VERSION = "1";
|
|
101
|
+
|
|
102
|
+
// Stale claim threshold — claims older than this are recovered to inbox or
|
|
103
|
+
// the dlq. Defaults assume the longest live cadence handler runs well under
|
|
104
|
+
// 30 minutes; tune via env for long-running migrations.
|
|
105
|
+
const DEFAULT_STALE_CLAIM_MS = 30 * 60 * 1000;
|
|
106
|
+
|
|
107
|
+
// Maximum attempts before an event goes to dlq. Cadence ticks are mostly
|
|
108
|
+
// non-essential; we don't want a single stuck tick to clog the bus.
|
|
109
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
110
|
+
|
|
111
|
+
// Retention defaults (audit M8). On a 24/7 mini nothing prunes processed/,
|
|
112
|
+
// dlq/, failed/, queue.jsonl or the log dir, so they grow without bound.
|
|
113
|
+
// sweepRetention enforces these caps; all overridable via opts / env.
|
|
114
|
+
const DEFAULT_PROCESSED_RETENTION_DAYS = 14;
|
|
115
|
+
const DEFAULT_TERMINAL_KEEP = 500; // newest N files kept in dlq/ & failed/
|
|
116
|
+
const DEFAULT_QUEUE_MAX_BYTES = 5 * 1024 * 1024; // queue.jsonl rotate threshold (~5MB)
|
|
117
|
+
const DEFAULT_LOG_RETENTION_DAYS = 14; // logs/cadence-bus/<date>.jsonl
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Path resolution
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the agent root. Prefers explicit arg, then AGENT_ROOT env, then
|
|
125
|
+
* AGENT_DIR env, then process.cwd(). Returns an absolute path.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveAgentRoot(agentRoot) {
|
|
128
|
+
return resolve(
|
|
129
|
+
agentRoot ||
|
|
130
|
+
process.env.AGENT_ROOT ||
|
|
131
|
+
process.env.AGENT_DIR ||
|
|
132
|
+
process.cwd()
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Return all bus paths derived from an agent root.
|
|
138
|
+
*/
|
|
139
|
+
export function getBusPaths(agentRoot) {
|
|
140
|
+
const root = resolveAgentRoot(agentRoot);
|
|
141
|
+
const base = join(root, BUS_RELATIVE);
|
|
142
|
+
return {
|
|
143
|
+
agentRoot: root,
|
|
144
|
+
base,
|
|
145
|
+
inbox: join(base, "inbox"),
|
|
146
|
+
claimed: join(base, "claimed"),
|
|
147
|
+
processed: join(base, "processed"),
|
|
148
|
+
failed: join(base, "failed"),
|
|
149
|
+
dlq: join(base, "dlq"),
|
|
150
|
+
queueJsonl: join(base, "queue.jsonl"),
|
|
151
|
+
health: join(base, "health.json"),
|
|
152
|
+
version: join(base, "VERSION"),
|
|
153
|
+
// WS4 at-most-once cron state: per-cadence {lastRunAt,nextRunAt,intervalMs}.
|
|
154
|
+
schedule: join(base, "schedule.json"),
|
|
155
|
+
scheduleLock: join(base, "schedule.json.lock"),
|
|
156
|
+
logsDir: join(root, LOGS_RELATIVE),
|
|
157
|
+
emergencyStop: join(root, ".emergency-stop"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Idempotently create the cadence-bus directory tree. Safe to call on every
|
|
163
|
+
* enqueue/consume; no-op once present.
|
|
164
|
+
*/
|
|
165
|
+
export function ensureBusDirs(agentRoot) {
|
|
166
|
+
const paths = getBusPaths(agentRoot);
|
|
167
|
+
for (const dir of [paths.inbox, paths.claimed, paths.processed, paths.failed, paths.dlq, paths.logsDir]) {
|
|
168
|
+
mkdirSync(dir, { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
// Touch a version marker so doctor / upgrade can detect a freshly-created
|
|
171
|
+
// bus and reason about schema migrations later.
|
|
172
|
+
if (!existsSync(paths.version)) {
|
|
173
|
+
writeFileSync(paths.version, `${BUS_VERSION}\n`);
|
|
174
|
+
}
|
|
175
|
+
return paths;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Event id
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Generate a unique, sortable event id of the form:
|
|
184
|
+
* evt-2026-05-12T15:30:00.123Z-<8 hex>
|
|
185
|
+
* The ISO prefix makes inbox/ directory listings naturally sorted by time.
|
|
186
|
+
* The random suffix avoids collisions for events enqueued in the same ms.
|
|
187
|
+
*/
|
|
188
|
+
export function nextEventId(now = new Date()) {
|
|
189
|
+
const iso = now.toISOString();
|
|
190
|
+
const rand = randomBytes(4).toString("hex");
|
|
191
|
+
return `evt-${iso}-${rand}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Logging
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function todayUtc(now = new Date()) {
|
|
199
|
+
return now.toISOString().slice(0, 10);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Append a structured log line to logs/cadence-bus/<date>.jsonl. Best-effort;
|
|
204
|
+
* caller never blocks on log failures. Returns true on success, false on a
|
|
205
|
+
* caught I/O error.
|
|
206
|
+
*/
|
|
207
|
+
export function logBusEvent(agentRoot, entry) {
|
|
208
|
+
try {
|
|
209
|
+
const paths = getBusPaths(agentRoot);
|
|
210
|
+
mkdirSync(paths.logsDir, { recursive: true });
|
|
211
|
+
const file = join(paths.logsDir, `${todayUtc()}.jsonl`);
|
|
212
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
213
|
+
appendFileSync(file, line);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Atomic write
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Atomically write JSON to `targetPath`. Writes a sibling `.tmp` file, fsyncs
|
|
226
|
+
* the file's contents to disk, then renames it over the target. The fsync
|
|
227
|
+
* happens BEFORE the rename so the durable bytes are in place once the atomic
|
|
228
|
+
* rename publishes the name — a crash after the rename returns can't surface a
|
|
229
|
+
* zero-length or half-written file. (On macOS APFS rename(2) is atomic for
|
|
230
|
+
* same-volume source/dest; fsync flushes the data the rename then exposes.)
|
|
231
|
+
*
|
|
232
|
+
* Exported (audit M6) so the consumer's re-queue paths use the same
|
|
233
|
+
* write-temp-then-rename primitive instead of a bare writeFileSync that can
|
|
234
|
+
* leave a half-written inbox file if the process dies mid-write.
|
|
235
|
+
*/
|
|
236
|
+
export function writeJsonAtomic(targetPath, obj) {
|
|
237
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
238
|
+
const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(2).toString("hex")}`;
|
|
239
|
+
const data = JSON.stringify(obj, null, 2) + "\n";
|
|
240
|
+
// Write + fsync via an explicit fd so the contents are durable before the
|
|
241
|
+
// rename publishes the name. closeSync runs whether or not fsync succeeds.
|
|
242
|
+
const fd = openSync(tmp, "w");
|
|
243
|
+
try {
|
|
244
|
+
writeSync(fd, data);
|
|
245
|
+
fsyncSync(fd);
|
|
246
|
+
} finally {
|
|
247
|
+
closeSync(fd);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
renameSync(tmp, targetPath);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// Clean up the orphan tmp file if rename failed.
|
|
253
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Enqueue
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Append a one-line JSON record to the fallback queue.jsonl. Used both as a
|
|
264
|
+
* tamper-evident audit log of every enqueue and as a last-resort fallback
|
|
265
|
+
* when writing to inbox/ fails (e.g. disk full, permissions).
|
|
266
|
+
*/
|
|
267
|
+
function appendFallbackQueue(paths, event) {
|
|
268
|
+
try {
|
|
269
|
+
mkdirSync(dirname(paths.queueJsonl), { recursive: true });
|
|
270
|
+
appendFileSync(paths.queueJsonl, JSON.stringify(event) + "\n");
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// At-most-once cron gate (WS4)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// The plist's StartInterval/StartCalendarInterval is the wall-clock trigger,
|
|
282
|
+
// but launchd can double-fire (catch-up after sleep, overlapping loads) and,
|
|
283
|
+
// after a reboot/outage, it would otherwise replay every missed tick at once.
|
|
284
|
+
// When the caller passes `intervalMs` (derived by generate-plists.sh from the
|
|
285
|
+
// cadence's interval), enqueueTick consults a per-cadence schedule under an
|
|
286
|
+
// O_EXCL lock and admits AT MOST ONE tick per interval:
|
|
287
|
+
//
|
|
288
|
+
// • grace — a fire within graceMs of the boundary is the real tick:
|
|
289
|
+
// advance nextRunAt and admit. An early/duplicate fire is
|
|
290
|
+
// suppressed. graceMs = clamp(interval/2, 120s, 2h).
|
|
291
|
+
// • stale fast-fwd — if we're more than one whole interval past nextRunAt
|
|
292
|
+
// (we were asleep / powered off), jump nextRunAt to the
|
|
293
|
+
// NEXT future boundary and admit exactly ONE catch-up
|
|
294
|
+
// tick — never the burst of missed ones.
|
|
295
|
+
//
|
|
296
|
+
// When `intervalMs` is absent the gate is a no-op and enqueueTick behaves
|
|
297
|
+
// exactly as before (full back-compat — every existing caller/test).
|
|
298
|
+
|
|
299
|
+
const SCHEDULE_GRACE_MIN_MS = 120_000; // 2 min
|
|
300
|
+
const SCHEDULE_GRACE_MAX_MS = 2 * 60 * 60_000; // 2 h
|
|
301
|
+
const SCHEDULE_LOCK_STALE_MS = 30_000; // a held lock older than this is stale
|
|
302
|
+
|
|
303
|
+
function clampGrace(intervalMs) {
|
|
304
|
+
return Math.min(SCHEDULE_GRACE_MAX_MS, Math.max(SCHEDULE_GRACE_MIN_MS, Math.floor(intervalMs / 2)));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Acquire the schedule lock via O_EXCL (same atomic primitive as
|
|
309
|
+
* session-lock.mjs:89). Returns true on success. A stale lock (older than
|
|
310
|
+
* SCHEDULE_LOCK_STALE_MS) is reclaimed so a crash mid-update can't wedge the
|
|
311
|
+
* gate forever.
|
|
312
|
+
*/
|
|
313
|
+
function acquireScheduleLock(paths, now) {
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(paths.scheduleLock, JSON.stringify({ pid: process.pid, at: now }), { flag: "wx" });
|
|
316
|
+
return true;
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (err.code !== "EEXIST") return false;
|
|
319
|
+
// Reclaim if stale.
|
|
320
|
+
try {
|
|
321
|
+
const st = statSync(paths.scheduleLock);
|
|
322
|
+
if (now - st.mtimeMs >= SCHEDULE_LOCK_STALE_MS) {
|
|
323
|
+
writeFileSync(paths.scheduleLock, JSON.stringify({ pid: process.pid, at: now }));
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
} catch { /* vanished — try once more */ }
|
|
327
|
+
try {
|
|
328
|
+
writeFileSync(paths.scheduleLock, JSON.stringify({ pid: process.pid, at: now }), { flag: "wx" });
|
|
329
|
+
return true;
|
|
330
|
+
} catch { return false; }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function releaseScheduleLock(paths) {
|
|
335
|
+
try { unlinkSync(paths.scheduleLock); } catch { /* */ }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function readSchedule(paths) {
|
|
339
|
+
try { return JSON.parse(readFileSync(paths.schedule, "utf-8")) || {}; }
|
|
340
|
+
catch { return {}; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Decide whether to admit this cadence tick, mutating the on-disk schedule.
|
|
345
|
+
* Pure-ish: the only side effect is the schedule.json write (under the lock).
|
|
346
|
+
* Returns { admit: boolean, reason, kind }.
|
|
347
|
+
*
|
|
348
|
+
* If the lock can't be taken (rare contention) we FAIL OPEN — admit the tick
|
|
349
|
+
* (Invariant: never drop work; a duplicate is cheaper than a missed cadence).
|
|
350
|
+
*
|
|
351
|
+
* @param {object} paths
|
|
352
|
+
* @param {string} cadence
|
|
353
|
+
* @param {number} intervalMs
|
|
354
|
+
* @param {number} now
|
|
355
|
+
*/
|
|
356
|
+
function scheduleGate(paths, cadence, intervalMs, now) {
|
|
357
|
+
if (!(Number.isFinite(intervalMs) && intervalMs > 0)) {
|
|
358
|
+
return { admit: true, reason: "no-interval", kind: "passthrough" };
|
|
359
|
+
}
|
|
360
|
+
mkdirSync(paths.base, { recursive: true });
|
|
361
|
+
if (!acquireScheduleLock(paths, now)) {
|
|
362
|
+
return { admit: true, reason: "lock-contended-fail-open", kind: "admit" };
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const sched = readSchedule(paths);
|
|
366
|
+
const entry = sched[cadence];
|
|
367
|
+
const grace = clampGrace(intervalMs);
|
|
368
|
+
|
|
369
|
+
let result;
|
|
370
|
+
if (!entry || !Number.isFinite(Number(entry.nextRunAt))) {
|
|
371
|
+
// First time we've seen this cadence: admit now, schedule the next one.
|
|
372
|
+
sched[cadence] = { lastRunAt: now, nextRunAt: now + intervalMs, intervalMs };
|
|
373
|
+
result = { admit: true, reason: "first-tick", kind: "admit" };
|
|
374
|
+
} else {
|
|
375
|
+
const nextRunAt = Number(entry.nextRunAt);
|
|
376
|
+
if (now > nextRunAt + intervalMs) {
|
|
377
|
+
// STALE: we were asleep/off for more than a whole interval. Jump
|
|
378
|
+
// nextRunAt to the next FUTURE boundary and admit exactly one catch-up.
|
|
379
|
+
let boundary = nextRunAt;
|
|
380
|
+
// Advance in whole intervals until strictly in the future.
|
|
381
|
+
const periodsMissed = Math.floor((now - nextRunAt) / intervalMs) + 1;
|
|
382
|
+
boundary = nextRunAt + periodsMissed * intervalMs;
|
|
383
|
+
if (boundary <= now) boundary += intervalMs; // safety: ensure future
|
|
384
|
+
sched[cadence] = { lastRunAt: now, nextRunAt: boundary, intervalMs };
|
|
385
|
+
result = { admit: true, reason: "stale-fast-forward", kind: "catch-up", periodsMissed };
|
|
386
|
+
} else if (now >= nextRunAt - grace) {
|
|
387
|
+
// On-time (within grace of the boundary): the real tick. Advance.
|
|
388
|
+
sched[cadence] = { lastRunAt: now, nextRunAt: nextRunAt + intervalMs, intervalMs };
|
|
389
|
+
result = { admit: true, reason: "on-boundary", kind: "admit" };
|
|
390
|
+
} else {
|
|
391
|
+
// Too early / duplicate launchd fire — suppress.
|
|
392
|
+
result = { admit: false, reason: "before-grace-window", kind: "duplicate", nextRunAt };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (result.admit) {
|
|
397
|
+
try { writeJsonAtomic(paths.schedule, sched); }
|
|
398
|
+
catch { /* best-effort; on write failure we still admit (no work lost) */ }
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
401
|
+
} finally {
|
|
402
|
+
releaseScheduleLock(paths);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Enqueue a cadence event onto the bus.
|
|
408
|
+
*
|
|
409
|
+
* @param {object} input
|
|
410
|
+
* @param {string} input.cadence Cadence name (e.g. "inbox-processor"). Required.
|
|
411
|
+
* @param {string} [input.type] Event type, default "cadence_tick".
|
|
412
|
+
* @param {string} [input.source] Originator label, default "manual".
|
|
413
|
+
* @param {string} [input.workflow]
|
|
414
|
+
* @param {string} [input.correlation_id]
|
|
415
|
+
* @param {"high"|"normal"|"low"} [input.priority]
|
|
416
|
+
* @param {object} [input.metadata]
|
|
417
|
+
* @param {number} [input.intervalMs] Cadence interval (ms). When present,
|
|
418
|
+
* enables the at-most-once cron gate
|
|
419
|
+
* (grace + stale fast-forward). Absent →
|
|
420
|
+
* legacy behaviour, every call enqueues.
|
|
421
|
+
* @param {number} [input.now] Injectable clock (ms) for tests.
|
|
422
|
+
* @param {string} [input.agentRoot] Override AGENT_ROOT for tests.
|
|
423
|
+
* @returns {{ id: string, path: string, fallbackOnly: boolean, skipped?: string }}
|
|
424
|
+
*/
|
|
425
|
+
export function enqueueTick(input = {}) {
|
|
426
|
+
if (!input || typeof input !== "object") {
|
|
427
|
+
throw new TypeError("enqueueTick: input must be an object");
|
|
428
|
+
}
|
|
429
|
+
const cadence = String(input.cadence || "").trim();
|
|
430
|
+
if (!cadence) {
|
|
431
|
+
throw new Error("enqueueTick: cadence is required");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const paths = ensureBusDirs(input.agentRoot);
|
|
435
|
+
|
|
436
|
+
// Emergency-stop short-circuit. The bus still logs the event so we can
|
|
437
|
+
// see what would have run, but no file lands in inbox/.
|
|
438
|
+
if (existsSync(paths.emergencyStop)) {
|
|
439
|
+
const event = buildEvent(input, cadence);
|
|
440
|
+
appendFallbackQueue(paths, { ...event, _suppressed: "emergency-stop" });
|
|
441
|
+
logBusEvent(paths.agentRoot, {
|
|
442
|
+
level: "info",
|
|
443
|
+
stage: "enqueue_suppressed",
|
|
444
|
+
cadence,
|
|
445
|
+
reason: "emergency-stop",
|
|
446
|
+
id: event.id,
|
|
447
|
+
});
|
|
448
|
+
return { id: event.id, path: null, fallbackOnly: true, skipped: "emergency-stop" };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const event = buildEvent(input, cadence);
|
|
452
|
+
const target = join(paths.inbox, `${event.id}.json`);
|
|
453
|
+
|
|
454
|
+
// Dedup against replay (audit H12). When a caller supplies an explicit
|
|
455
|
+
// `input.id` (e.g. a poller re-delivering after a reconnect), writing
|
|
456
|
+
// straight to inbox/ would clobber an in-flight event — overwriting its
|
|
457
|
+
// bumped attempt count — or silently re-run a cadence already processed.
|
|
458
|
+
// Auto-generated ids are collision-resistant, so this only guards the
|
|
459
|
+
// explicit-id case.
|
|
460
|
+
if (input.id) {
|
|
461
|
+
const dup =
|
|
462
|
+
existsSync(join(paths.inbox, `${event.id}.json`)) ? "inbox" :
|
|
463
|
+
existsSync(join(paths.claimed, `${event.id}.json`)) ? "claimed" :
|
|
464
|
+
existsSync(join(paths.dlq, `${event.id}.json`)) ? "dlq" :
|
|
465
|
+
isProcessedId(paths.agentRoot, event.id) ? "processed" :
|
|
466
|
+
null;
|
|
467
|
+
if (dup) {
|
|
468
|
+
logBusEvent(paths.agentRoot, {
|
|
469
|
+
level: "info",
|
|
470
|
+
stage: "enqueue_deduped",
|
|
471
|
+
cadence,
|
|
472
|
+
id: event.id,
|
|
473
|
+
existing: dup,
|
|
474
|
+
});
|
|
475
|
+
return { id: event.id, path: null, fallbackOnly: false, deduped: dup };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// At-most-once cron gate (WS4). Only engages when intervalMs is supplied by
|
|
480
|
+
// the caller (plist). Suppressed ticks are still audited so we can see what
|
|
481
|
+
// launchd fired, but nothing lands in inbox/.
|
|
482
|
+
if (Number.isFinite(input.intervalMs) && input.intervalMs > 0) {
|
|
483
|
+
const nowMs = typeof input.now === "number" ? input.now : Date.now();
|
|
484
|
+
const gate = scheduleGate(paths, cadence, input.intervalMs, nowMs);
|
|
485
|
+
if (!gate.admit) {
|
|
486
|
+
appendFallbackQueue(paths, { ...event, _suppressed: gate.reason });
|
|
487
|
+
logBusEvent(paths.agentRoot, {
|
|
488
|
+
level: "info",
|
|
489
|
+
stage: "enqueue_suppressed",
|
|
490
|
+
cadence,
|
|
491
|
+
reason: gate.reason,
|
|
492
|
+
kind: gate.kind,
|
|
493
|
+
id: event.id,
|
|
494
|
+
});
|
|
495
|
+
return { id: event.id, path: null, fallbackOnly: true, skipped: gate.reason };
|
|
496
|
+
}
|
|
497
|
+
// Annotate the admitted event so the catch-up case is visible downstream.
|
|
498
|
+
if (gate.kind === "catch-up") {
|
|
499
|
+
event.metadata = { ...(event.metadata || {}), schedule_kind: "catch-up", periods_missed: gate.periodsMissed };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Always write the audit row first. If inbox write fails the event is
|
|
504
|
+
// still recoverable from queue.jsonl.
|
|
505
|
+
appendFallbackQueue(paths, event);
|
|
506
|
+
|
|
507
|
+
let fallbackOnly = false;
|
|
508
|
+
try {
|
|
509
|
+
writeJsonAtomic(target, event);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
fallbackOnly = true;
|
|
512
|
+
logBusEvent(paths.agentRoot, {
|
|
513
|
+
level: "error",
|
|
514
|
+
stage: "enqueue_inbox_failed",
|
|
515
|
+
cadence,
|
|
516
|
+
id: event.id,
|
|
517
|
+
error: err.message,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
logBusEvent(paths.agentRoot, {
|
|
522
|
+
level: "info",
|
|
523
|
+
stage: fallbackOnly ? "enqueued_fallback" : "enqueued",
|
|
524
|
+
cadence,
|
|
525
|
+
source: event.source,
|
|
526
|
+
priority: event.priority,
|
|
527
|
+
id: event.id,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return { id: event.id, path: fallbackOnly ? null : target, fallbackOnly };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildEvent(input, cadence) {
|
|
534
|
+
return {
|
|
535
|
+
id: input.id || nextEventId(),
|
|
536
|
+
type: input.type || "cadence_tick",
|
|
537
|
+
source: input.source || "manual",
|
|
538
|
+
ts: input.ts || new Date().toISOString(),
|
|
539
|
+
cadence,
|
|
540
|
+
workflow: input.workflow || null,
|
|
541
|
+
correlation_id: input.correlation_id || null,
|
|
542
|
+
priority: ["high", "normal", "low"].includes(input.priority) ? input.priority : "normal",
|
|
543
|
+
metadata: input.metadata && typeof input.metadata === "object" ? input.metadata : {},
|
|
544
|
+
attempts: typeof input.attempts === "number" ? input.attempts : 0,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Consume
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* List inbox event ids in chronological order. Returns ids without `.json`.
|
|
554
|
+
*/
|
|
555
|
+
export function listInbox(agentRoot) {
|
|
556
|
+
const paths = getBusPaths(agentRoot);
|
|
557
|
+
if (!existsSync(paths.inbox)) return [];
|
|
558
|
+
return readdirSync(paths.inbox)
|
|
559
|
+
.filter((n) => n.endsWith(".json") && !n.endsWith(".tmp"))
|
|
560
|
+
.sort()
|
|
561
|
+
.map((n) => n.slice(0, -5));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* List currently-claimed event ids. Used by stale-claim recovery.
|
|
566
|
+
*/
|
|
567
|
+
export function listClaimed(agentRoot) {
|
|
568
|
+
const paths = getBusPaths(agentRoot);
|
|
569
|
+
if (!existsSync(paths.claimed)) return [];
|
|
570
|
+
return readdirSync(paths.claimed)
|
|
571
|
+
.filter((n) => n.endsWith(".json") && !n.endsWith(".tmp"))
|
|
572
|
+
.sort()
|
|
573
|
+
.map((n) => n.slice(0, -5));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Atomically claim the oldest inbox event for processing. Returns the parsed
|
|
578
|
+
* event payload (with absolute paths attached), or null if the inbox was
|
|
579
|
+
* empty / all candidates lost the race.
|
|
580
|
+
*
|
|
581
|
+
* Other consumers racing for the same event will see ENOENT on rename and
|
|
582
|
+
* naturally move on.
|
|
583
|
+
*/
|
|
584
|
+
export function claimNextTick(agentRoot) {
|
|
585
|
+
const paths = ensureBusDirs(agentRoot);
|
|
586
|
+
const ids = listInbox(paths.agentRoot);
|
|
587
|
+
for (const id of ids) {
|
|
588
|
+
const src = join(paths.inbox, `${id}.json`);
|
|
589
|
+
const dst = join(paths.claimed, `${id}.json`);
|
|
590
|
+
try {
|
|
591
|
+
renameSync(src, dst);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (err.code === "ENOENT") continue; // someone else claimed it
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
// Load the event from its claimed path. If it's malformed, fail it and
|
|
597
|
+
// try the next one.
|
|
598
|
+
let event;
|
|
599
|
+
try {
|
|
600
|
+
event = JSON.parse(readFileSync(dst, "utf-8"));
|
|
601
|
+
} catch (err) {
|
|
602
|
+
logBusEvent(paths.agentRoot, {
|
|
603
|
+
level: "error",
|
|
604
|
+
stage: "claim_parse_failed",
|
|
605
|
+
id,
|
|
606
|
+
error: err.message,
|
|
607
|
+
});
|
|
608
|
+
failTick(paths.agentRoot, id, `parse-failed: ${err.message}`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
// Attempt accounting is single-sourced in failTick (audit M5). Claiming
|
|
612
|
+
// an event is NOT a failed attempt, so claim must not mutate `attempts`
|
|
613
|
+
// — otherwise a successful first run would have already burned one unit
|
|
614
|
+
// of retry budget, and a crash-then-recover would double-count it.
|
|
615
|
+
// Normalise the field so downstream readers always see a number.
|
|
616
|
+
if (typeof event.attempts !== "number") {
|
|
617
|
+
event.attempts = 0;
|
|
618
|
+
}
|
|
619
|
+
logBusEvent(paths.agentRoot, {
|
|
620
|
+
level: "info",
|
|
621
|
+
stage: "claimed",
|
|
622
|
+
id,
|
|
623
|
+
cadence: event.cadence,
|
|
624
|
+
attempts: event.attempts,
|
|
625
|
+
});
|
|
626
|
+
return { event, claimedPath: dst };
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Lifecycle transitions
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
function moveClaimed(paths, id, destDir, suffix = ".json") {
|
|
636
|
+
const src = join(paths.claimed, `${id}.json`);
|
|
637
|
+
mkdirSync(destDir, { recursive: true });
|
|
638
|
+
const dst = join(destDir, `${id}${suffix}`);
|
|
639
|
+
if (!existsSync(src)) return null;
|
|
640
|
+
renameSync(src, dst);
|
|
641
|
+
return dst;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Has an event id already been archived under processed/<date>/? Used by
|
|
646
|
+
* enqueueTick to suppress re-running a cadence that already completed when a
|
|
647
|
+
* producer replays an explicit id after a reconnect (H12). Scans the dated
|
|
648
|
+
* processed/ subdirectories for <id>.json.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} agentRoot
|
|
651
|
+
* @param {string} id
|
|
652
|
+
* @returns {boolean}
|
|
653
|
+
*/
|
|
654
|
+
export function isProcessedId(agentRoot, id) {
|
|
655
|
+
const paths = getBusPaths(agentRoot);
|
|
656
|
+
if (!existsSync(paths.processed)) return false;
|
|
657
|
+
let dateDirs;
|
|
658
|
+
try {
|
|
659
|
+
dateDirs = readdirSync(paths.processed, { withFileTypes: true });
|
|
660
|
+
} catch {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
for (const d of dateDirs) {
|
|
664
|
+
if (!d.isDirectory()) continue;
|
|
665
|
+
if (existsSync(join(paths.processed, d.name, `${id}.json`))) return true;
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Mark a claimed event as successfully processed. Moves it to
|
|
672
|
+
* processed/<YYYY-MM-DD>/<id>.json and stores the handler result alongside.
|
|
673
|
+
*/
|
|
674
|
+
export function completeTick(agentRoot, id, result = {}) {
|
|
675
|
+
const paths = ensureBusDirs(agentRoot);
|
|
676
|
+
const date = todayUtc();
|
|
677
|
+
const dst = moveClaimed(paths, id, join(paths.processed, date));
|
|
678
|
+
if (!dst) {
|
|
679
|
+
logBusEvent(paths.agentRoot, { level: "warn", stage: "complete_missing", id });
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
// Annotate the archived event with the result. Best-effort, but observable:
|
|
683
|
+
// this write carries the handler result + completed_at audit fields, so a
|
|
684
|
+
// silent failure would leave the processed/ record unannotated — log it.
|
|
685
|
+
try {
|
|
686
|
+
const event = JSON.parse(readFileSync(dst, "utf-8"));
|
|
687
|
+
event.result = result;
|
|
688
|
+
event.completed_at = new Date().toISOString();
|
|
689
|
+
writeJsonAtomic(dst, event);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
logBusEvent(paths.agentRoot, {
|
|
692
|
+
level: "warn",
|
|
693
|
+
stage: "best_effort_write_failed",
|
|
694
|
+
op: "complete_annotate",
|
|
695
|
+
id,
|
|
696
|
+
error: err.message,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
logBusEvent(paths.agentRoot, {
|
|
700
|
+
level: "info",
|
|
701
|
+
stage: "processed",
|
|
702
|
+
id,
|
|
703
|
+
cadence: result.cadence || null,
|
|
704
|
+
decision: result.decision || null,
|
|
705
|
+
duration_ms: result.duration_ms ?? null,
|
|
706
|
+
});
|
|
707
|
+
return dst;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Mark a claimed event as failed. If under the retry budget, returns it to
|
|
712
|
+
* inbox/ for another attempt; otherwise routes to dlq/.
|
|
713
|
+
*/
|
|
714
|
+
export function failTick(agentRoot, id, errorOrReason, opts = {}) {
|
|
715
|
+
const paths = ensureBusDirs(agentRoot);
|
|
716
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
717
|
+
const srcClaimed = join(paths.claimed, `${id}.json`);
|
|
718
|
+
let event = null;
|
|
719
|
+
if (existsSync(srcClaimed)) {
|
|
720
|
+
try { event = JSON.parse(readFileSync(srcClaimed, "utf-8")); } catch { /* */ }
|
|
721
|
+
}
|
|
722
|
+
if (!event) {
|
|
723
|
+
// Caller already moved or never had a claim; nothing to do.
|
|
724
|
+
logBusEvent(paths.agentRoot, { level: "warn", stage: "fail_missing", id });
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
event.last_error = typeof errorOrReason === "string"
|
|
728
|
+
? errorOrReason
|
|
729
|
+
: (errorOrReason?.message || String(errorOrReason));
|
|
730
|
+
event.failed_at = new Date().toISOString();
|
|
731
|
+
|
|
732
|
+
// Single source of truth for attempt accounting (audit M5). A failed
|
|
733
|
+
// attempt is the ONLY thing that increments `attempts`; claim/consumer
|
|
734
|
+
// never touch it. Increment first, then compare against the budget so the
|
|
735
|
+
// count reflects the attempt that just failed.
|
|
736
|
+
const attempts = (typeof event.attempts === "number" ? event.attempts : 0) + 1;
|
|
737
|
+
event.attempts = attempts;
|
|
738
|
+
|
|
739
|
+
if (attempts >= maxAttempts || opts.terminal === true) {
|
|
740
|
+
// Move to dlq/. Persist the bumped count + last_error onto the claimed
|
|
741
|
+
// file first so the archived dlq payload reflects the final attempt.
|
|
742
|
+
// Best-effort, but observable: this write carries the attempt count that
|
|
743
|
+
// justified the DLQ decision, so a silent failure would archive a stale
|
|
744
|
+
// payload — log it instead of swallowing.
|
|
745
|
+
try {
|
|
746
|
+
writeJsonAtomic(srcClaimed, event);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
logBusEvent(paths.agentRoot, {
|
|
749
|
+
level: "warn",
|
|
750
|
+
stage: "best_effort_write_failed",
|
|
751
|
+
op: "dlq_persist_attempts",
|
|
752
|
+
id,
|
|
753
|
+
cadence: event.cadence,
|
|
754
|
+
attempts,
|
|
755
|
+
error: err.message,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
moveClaimed(paths, id, paths.dlq);
|
|
759
|
+
logBusEvent(paths.agentRoot, {
|
|
760
|
+
level: "error",
|
|
761
|
+
stage: "dlq",
|
|
762
|
+
id,
|
|
763
|
+
cadence: event.cadence,
|
|
764
|
+
attempts,
|
|
765
|
+
reason: event.last_error,
|
|
766
|
+
});
|
|
767
|
+
return { destination: "dlq" };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Re-enqueue: write fresh in inbox/, drop the claim. Guarded (audit M7):
|
|
771
|
+
// if the inbox write fails (ENOSPC / EACCES), leave the claim in place so
|
|
772
|
+
// the next stale-claim recovery sweep retries it, rather than throwing out
|
|
773
|
+
// of failTick and losing the event entirely.
|
|
774
|
+
const newTarget = join(paths.inbox, `${id}.json`);
|
|
775
|
+
try {
|
|
776
|
+
writeJsonAtomic(newTarget, event);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
logBusEvent(paths.agentRoot, {
|
|
779
|
+
level: "error",
|
|
780
|
+
stage: "retry_requeue_failed",
|
|
781
|
+
id,
|
|
782
|
+
cadence: event.cadence,
|
|
783
|
+
attempts,
|
|
784
|
+
error: err.message,
|
|
785
|
+
});
|
|
786
|
+
return { destination: "claimed", error: err.message };
|
|
787
|
+
}
|
|
788
|
+
try { unlinkSync(srcClaimed); } catch { /* */ }
|
|
789
|
+
logBusEvent(paths.agentRoot, {
|
|
790
|
+
level: "warn",
|
|
791
|
+
stage: "retry_requeued",
|
|
792
|
+
id,
|
|
793
|
+
cadence: event.cadence,
|
|
794
|
+
attempts,
|
|
795
|
+
reason: event.last_error,
|
|
796
|
+
});
|
|
797
|
+
return { destination: "inbox" };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Atomically re-enqueue an already-loaded event object back onto inbox/ and
|
|
802
|
+
* drop its claim (audit M6). Used by the consumer's circuit-open / deferred
|
|
803
|
+
* re-queue paths so they go through the same write-temp-then-rename primitive
|
|
804
|
+
* as every other bus write instead of a bare writeFileSync.
|
|
805
|
+
*
|
|
806
|
+
* Does NOT touch `event.attempts` — re-queueing because of an upstream gate
|
|
807
|
+
* (circuit open, concurrency cap) is not a failed attempt, and attempt
|
|
808
|
+
* accounting is single-sourced in failTick (M5). The caller passes the event
|
|
809
|
+
* exactly as it wants it re-queued.
|
|
810
|
+
*
|
|
811
|
+
* Best-effort: on a write failure the claim is left in place so stale-claim
|
|
812
|
+
* recovery can retry it. Returns true if the event landed in inbox/.
|
|
813
|
+
*
|
|
814
|
+
* @param {string} agentRoot
|
|
815
|
+
* @param {object} event Must include `id`.
|
|
816
|
+
* @returns {boolean}
|
|
817
|
+
*/
|
|
818
|
+
export function requeueTick(agentRoot, event) {
|
|
819
|
+
if (!event || typeof event !== "object" || !event.id) {
|
|
820
|
+
throw new TypeError("requeueTick: event with an id is required");
|
|
821
|
+
}
|
|
822
|
+
const paths = ensureBusDirs(agentRoot);
|
|
823
|
+
const target = join(paths.inbox, `${event.id}.json`);
|
|
824
|
+
try {
|
|
825
|
+
writeJsonAtomic(target, event);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
logBusEvent(paths.agentRoot, {
|
|
828
|
+
level: "error",
|
|
829
|
+
stage: "requeue_failed",
|
|
830
|
+
id: event.id,
|
|
831
|
+
cadence: event.cadence,
|
|
832
|
+
error: err.message,
|
|
833
|
+
});
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
try { unlinkSync(join(paths.claimed, `${event.id}.json`)); } catch { /* */ }
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// Stale claim recovery
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Sweep claimed/ for entries older than `maxAgeMs`. Each stale claim is
|
|
846
|
+
* either returned to inbox/ (under the retry budget) or moved to dlq/.
|
|
847
|
+
* Safe to run on consumer startup *and* periodically while running, because
|
|
848
|
+
* the consumer is the only writer to claimed/.
|
|
849
|
+
*
|
|
850
|
+
* Returns the count of events recovered + count moved to dlq.
|
|
851
|
+
*/
|
|
852
|
+
export function recoverStaleClaims(agentRoot, maxAgeMs = DEFAULT_STALE_CLAIM_MS, now = Date.now()) {
|
|
853
|
+
const paths = ensureBusDirs(agentRoot);
|
|
854
|
+
const stats = { recovered: 0, dlq: 0, scanned: 0 };
|
|
855
|
+
if (!existsSync(paths.claimed)) return stats;
|
|
856
|
+
for (const name of readdirSync(paths.claimed)) {
|
|
857
|
+
if (!name.endsWith(".json")) continue;
|
|
858
|
+
stats.scanned++;
|
|
859
|
+
const claimedPath = join(paths.claimed, name);
|
|
860
|
+
let st;
|
|
861
|
+
try { st = statSync(claimedPath); } catch { continue; }
|
|
862
|
+
if (now - st.mtimeMs < maxAgeMs) continue;
|
|
863
|
+
|
|
864
|
+
const id = name.slice(0, -5);
|
|
865
|
+
const outcome = failTick(paths.agentRoot, id, "stale-claim-recovery", {
|
|
866
|
+
maxAttempts: DEFAULT_MAX_ATTEMPTS,
|
|
867
|
+
});
|
|
868
|
+
if (outcome?.destination === "dlq") stats.dlq++;
|
|
869
|
+
else if (outcome?.destination === "inbox") stats.recovered++;
|
|
870
|
+
}
|
|
871
|
+
if (stats.scanned > 0) {
|
|
872
|
+
logBusEvent(paths.agentRoot, {
|
|
873
|
+
level: "info",
|
|
874
|
+
stage: "stale_recovery_complete",
|
|
875
|
+
...stats,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return stats;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
// Retention sweep (audit M8)
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Best-effort: list directory entries, or [] on any error.
|
|
887
|
+
*/
|
|
888
|
+
function safeReaddir(dir, opts) {
|
|
889
|
+
try { return readdirSync(dir, opts); } catch { return []; }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Prune unbounded bus growth. Designed to run on the same periodic timer as
|
|
894
|
+
* recoverStaleClaims. EVERY step is wrapped so a single failure (permissions,
|
|
895
|
+
* a file vanishing mid-sweep, ENOSPC on a temp write) can never throw out of
|
|
896
|
+
* the sweep and kill the recovery loop.
|
|
897
|
+
*
|
|
898
|
+
* Steps:
|
|
899
|
+
* 1. Delete processed/<YYYY-MM-DD>/ dirs older than `processedDays`
|
|
900
|
+
* (default 14, env MAESTRO_RETENTION_DAYS, opts.processedDays).
|
|
901
|
+
* 2. Cap dlq/ and failed/ to the newest `terminalKeep` files (default 500,
|
|
902
|
+
* opts.terminalKeep). Oldest are unlinked first.
|
|
903
|
+
* 3. Rotate/truncate queue.jsonl if it exceeds `queueMaxBytes` (~5MB),
|
|
904
|
+
* keeping the tail so recent audit rows survive.
|
|
905
|
+
* 4. Delete logs/cadence-bus/<YYYY-MM-DD>.jsonl older than `logDays`.
|
|
906
|
+
*
|
|
907
|
+
* @param {string} agentRoot
|
|
908
|
+
* @param {object} [opts]
|
|
909
|
+
* @param {number} [opts.processedDays]
|
|
910
|
+
* @param {number} [opts.terminalKeep]
|
|
911
|
+
* @param {number} [opts.queueMaxBytes]
|
|
912
|
+
* @param {number} [opts.logDays]
|
|
913
|
+
* @param {number} [opts.now] injectable clock (ms) for tests
|
|
914
|
+
* @returns {{processedDirsDeleted:number, dlqDeleted:number, failedDeleted:number, queueRotated:boolean, logsDeleted:number, errors:number}}
|
|
915
|
+
*/
|
|
916
|
+
export function sweepRetention(agentRoot, opts = {}) {
|
|
917
|
+
const stats = {
|
|
918
|
+
processedDirsDeleted: 0,
|
|
919
|
+
dlqDeleted: 0,
|
|
920
|
+
failedDeleted: 0,
|
|
921
|
+
queueRotated: false,
|
|
922
|
+
logsDeleted: 0,
|
|
923
|
+
errors: 0,
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
let paths;
|
|
927
|
+
try {
|
|
928
|
+
paths = getBusPaths(agentRoot);
|
|
929
|
+
} catch {
|
|
930
|
+
stats.errors++;
|
|
931
|
+
return stats;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
935
|
+
const envDays = Number(process.env.MAESTRO_RETENTION_DAYS);
|
|
936
|
+
const processedDays =
|
|
937
|
+
Number.isFinite(opts.processedDays) ? opts.processedDays
|
|
938
|
+
: Number.isFinite(envDays) ? envDays
|
|
939
|
+
: DEFAULT_PROCESSED_RETENTION_DAYS;
|
|
940
|
+
const terminalKeep = Number.isFinite(opts.terminalKeep) ? opts.terminalKeep : DEFAULT_TERMINAL_KEEP;
|
|
941
|
+
const queueMaxBytes = Number.isFinite(opts.queueMaxBytes) ? opts.queueMaxBytes : DEFAULT_QUEUE_MAX_BYTES;
|
|
942
|
+
const logDays = Number.isFinite(opts.logDays) ? opts.logDays : DEFAULT_LOG_RETENTION_DAYS;
|
|
943
|
+
|
|
944
|
+
// --- 1. processed/<date>/ dirs older than processedDays --------------------
|
|
945
|
+
try {
|
|
946
|
+
const cutoff = now - processedDays * 24 * 60 * 60 * 1000;
|
|
947
|
+
for (const ent of safeReaddir(paths.processed, { withFileTypes: true })) {
|
|
948
|
+
if (!ent.isDirectory()) continue;
|
|
949
|
+
// Date dirs are named YYYY-MM-DD. Parse to a UTC midnight timestamp;
|
|
950
|
+
// fall back to mtime for anything that doesn't match the pattern.
|
|
951
|
+
let dirMs;
|
|
952
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ent.name);
|
|
953
|
+
if (m) {
|
|
954
|
+
dirMs = Date.parse(`${ent.name}T00:00:00.000Z`);
|
|
955
|
+
}
|
|
956
|
+
const full = join(paths.processed, ent.name);
|
|
957
|
+
if (!Number.isFinite(dirMs)) {
|
|
958
|
+
try { dirMs = statSync(full).mtimeMs; } catch { continue; }
|
|
959
|
+
}
|
|
960
|
+
if (dirMs < cutoff) {
|
|
961
|
+
try {
|
|
962
|
+
rmSync(full, { recursive: true, force: true });
|
|
963
|
+
stats.processedDirsDeleted++;
|
|
964
|
+
} catch { stats.errors++; }
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
} catch { stats.errors++; }
|
|
968
|
+
|
|
969
|
+
// --- 2. cap dlq/ and failed/ to newest terminalKeep ------------------------
|
|
970
|
+
for (const [dir, key] of [[paths.dlq, "dlqDeleted"], [paths.failed, "failedDeleted"]]) {
|
|
971
|
+
try {
|
|
972
|
+
const files = safeReaddir(dir).filter((n) => n.endsWith(".json"));
|
|
973
|
+
if (files.length <= terminalKeep) continue;
|
|
974
|
+
// Sort by mtime ascending (oldest first); delete the overflow.
|
|
975
|
+
const withTimes = [];
|
|
976
|
+
for (const n of files) {
|
|
977
|
+
const full = join(dir, n);
|
|
978
|
+
let mtime = 0;
|
|
979
|
+
try { mtime = statSync(full).mtimeMs; } catch { continue; }
|
|
980
|
+
withTimes.push({ full, mtime });
|
|
981
|
+
}
|
|
982
|
+
withTimes.sort((a, b) => a.mtime - b.mtime);
|
|
983
|
+
const toDelete = withTimes.length - terminalKeep;
|
|
984
|
+
for (let i = 0; i < toDelete; i++) {
|
|
985
|
+
try {
|
|
986
|
+
unlinkSync(withTimes[i].full);
|
|
987
|
+
stats[key]++;
|
|
988
|
+
} catch { stats.errors++; }
|
|
989
|
+
}
|
|
990
|
+
} catch { stats.errors++; }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// --- 3. rotate/truncate queue.jsonl if oversized ---------------------------
|
|
994
|
+
try {
|
|
995
|
+
if (existsSync(paths.queueJsonl)) {
|
|
996
|
+
let size = 0;
|
|
997
|
+
try { size = statSync(paths.queueJsonl).size; } catch { size = 0; }
|
|
998
|
+
if (size > queueMaxBytes) {
|
|
999
|
+
// Keep the tail (most recent rows). Read the last ~half of the cap so
|
|
1000
|
+
// the file shrinks well below the threshold and recent audit context
|
|
1001
|
+
// survives. Best-effort; on read failure fall back to truncating.
|
|
1002
|
+
let tail = "";
|
|
1003
|
+
try {
|
|
1004
|
+
const buf = readFileSync(paths.queueJsonl, "utf-8");
|
|
1005
|
+
const keepBytes = Math.floor(queueMaxBytes / 2);
|
|
1006
|
+
tail = buf.slice(-keepBytes);
|
|
1007
|
+
// Drop a partial leading line so the file stays valid JSONL.
|
|
1008
|
+
const nl = tail.indexOf("\n");
|
|
1009
|
+
if (nl >= 0) tail = tail.slice(nl + 1);
|
|
1010
|
+
} catch { tail = ""; }
|
|
1011
|
+
try {
|
|
1012
|
+
if (tail) {
|
|
1013
|
+
writeFileSync(paths.queueJsonl, tail);
|
|
1014
|
+
} else {
|
|
1015
|
+
truncateSync(paths.queueJsonl, 0);
|
|
1016
|
+
}
|
|
1017
|
+
stats.queueRotated = true;
|
|
1018
|
+
} catch { stats.errors++; }
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
} catch { stats.errors++; }
|
|
1022
|
+
|
|
1023
|
+
// --- 4. logs/cadence-bus/<date>.jsonl older than logDays -------------------
|
|
1024
|
+
try {
|
|
1025
|
+
const cutoff = now - logDays * 24 * 60 * 60 * 1000;
|
|
1026
|
+
for (const n of safeReaddir(paths.logsDir)) {
|
|
1027
|
+
const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(n);
|
|
1028
|
+
if (!m) continue;
|
|
1029
|
+
const fileMs = Date.parse(`${m[1]}T00:00:00.000Z`);
|
|
1030
|
+
if (!Number.isFinite(fileMs)) continue;
|
|
1031
|
+
if (fileMs < cutoff) {
|
|
1032
|
+
try {
|
|
1033
|
+
unlinkSync(join(paths.logsDir, n));
|
|
1034
|
+
stats.logsDeleted++;
|
|
1035
|
+
} catch { stats.errors++; }
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} catch { stats.errors++; }
|
|
1039
|
+
|
|
1040
|
+
// Audit row — best-effort, never throws (logBusEvent swallows its own I/O).
|
|
1041
|
+
if (
|
|
1042
|
+
stats.processedDirsDeleted || stats.dlqDeleted || stats.failedDeleted ||
|
|
1043
|
+
stats.queueRotated || stats.logsDeleted || stats.errors
|
|
1044
|
+
) {
|
|
1045
|
+
logBusEvent(paths.agentRoot, { level: "info", stage: "retention_sweep", ...stats });
|
|
1046
|
+
}
|
|
1047
|
+
return stats;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
// Heartbeat
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Write the consumer's heartbeat state. Doctor / healthcheck reads this to
|
|
1056
|
+
* confirm the persistent main session is alive.
|
|
1057
|
+
*/
|
|
1058
|
+
export function writeHealth(agentRoot, state = {}) {
|
|
1059
|
+
const paths = ensureBusDirs(agentRoot);
|
|
1060
|
+
const payload = {
|
|
1061
|
+
version: BUS_VERSION,
|
|
1062
|
+
ts: new Date().toISOString(),
|
|
1063
|
+
pid: process.pid,
|
|
1064
|
+
...state,
|
|
1065
|
+
};
|
|
1066
|
+
try {
|
|
1067
|
+
writeJsonAtomic(paths.health, payload);
|
|
1068
|
+
} catch {
|
|
1069
|
+
/* best-effort */
|
|
1070
|
+
}
|
|
1071
|
+
return payload;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Read the consumer's heartbeat state. Returns null if missing or unreadable.
|
|
1076
|
+
*/
|
|
1077
|
+
export function readHealth(agentRoot) {
|
|
1078
|
+
const paths = getBusPaths(agentRoot);
|
|
1079
|
+
if (!existsSync(paths.health)) return null;
|
|
1080
|
+
try {
|
|
1081
|
+
return JSON.parse(readFileSync(paths.health, "utf-8"));
|
|
1082
|
+
} catch {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Inspect bus depth — counts of events at each stage. Used by doctor and
|
|
1089
|
+
* tests; cheap enough to call frequently.
|
|
1090
|
+
*/
|
|
1091
|
+
export function busDepth(agentRoot) {
|
|
1092
|
+
const paths = getBusPaths(agentRoot);
|
|
1093
|
+
const dirCount = (d) => existsSync(d) ? readdirSync(d).filter((n) => n.endsWith(".json")).length : 0;
|
|
1094
|
+
return {
|
|
1095
|
+
inbox: dirCount(paths.inbox),
|
|
1096
|
+
claimed: dirCount(paths.claimed),
|
|
1097
|
+
dlq: dirCount(paths.dlq),
|
|
1098
|
+
failed: dirCount(paths.failed),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
// CLI usability
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Touch each bus directory so a fresh checkout/install has the expected
|
|
1108
|
+
* shape on disk. Equivalent to calling ensureBusDirs + readHealth probe
|
|
1109
|
+
* + writing a `.gitkeep` per dir so empty-bus repos still track structure.
|
|
1110
|
+
*/
|
|
1111
|
+
export function bootstrapBus(agentRoot) {
|
|
1112
|
+
const paths = ensureBusDirs(agentRoot);
|
|
1113
|
+
for (const dir of [paths.inbox, paths.claimed, paths.processed, paths.failed, paths.dlq, paths.logsDir]) {
|
|
1114
|
+
const keep = join(dir, ".gitkeep");
|
|
1115
|
+
if (!existsSync(keep)) {
|
|
1116
|
+
try { closeSync(openSync(keep, "a")); } catch { /* ignore */ }
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return paths;
|
|
1120
|
+
}
|