crewly 1.20.193 → 1.20.195
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/config/roles/auditor/prompt.md +2 -2
- package/config/roles/orchestrator/fragments/communication.md +1 -1
- package/config/roles/orchestrator/prompt.md +15 -505
- package/config/skills/agent/computer-use/SKILL.md +6 -0
- package/config/skills/agent/core/experiment-card/SKILL.md +101 -0
- package/config/skills/agent/core/experiment-card/execute.sh +149 -0
- package/config/skills/agent/core/experiment-card/execute.test.sh +97 -0
- package/config/skills/agent/desktop-app-control/execute.sh +5 -2
- package/config/skills/orchestrator/send-message/execute.sh +19 -2
- package/config/skills/orchestrator/send-message/execute.test.sh +80 -0
- package/config/skills/registry.json +7 -5
- package/config/skills/team-leader/signal-digest/SKILL.md +110 -0
- package/config/skills/team-leader/signal-digest/execute.sh +173 -0
- package/config/skills/team-leader/signal-digest/execute.test.sh +123 -0
- package/config/skills/team-leader/signal-digest/signal-digest.config.example.json +23 -0
- package/config/skills/team-leader/signal-digest/signal_digest.py +572 -0
- package/config/skills/team-leader/signal-digest/signal_digest.test.py +319 -0
- package/dist/backend/backend/src/constants.d.ts +191 -1
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +194 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +6 -0
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +3 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts +27 -0
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.js +96 -0
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.js.map +1 -0
- package/dist/backend/backend/src/controllers/marketplace/marketplace.routes.d.ts +1 -0
- package/dist/backend/backend/src/controllers/marketplace/marketplace.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/marketplace/marketplace.routes.js +1 -0
- package/dist/backend/backend/src/controllers/marketplace/marketplace.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +5 -2
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/request/request.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/request/request.controller.js +5 -1
- package/dist/backend/backend/src/controllers/request/request.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/settings/settings.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/settings/settings.controller.js +18 -2
- package/dist/backend/backend/src/controllers/settings/settings.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/signal-digest/signal-digest.controller.d.ts +26 -0
- package/dist/backend/backend/src/controllers/signal-digest/signal-digest.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/signal-digest/signal-digest.controller.js +100 -0
- package/dist/backend/backend/src/controllers/signal-digest/signal-digest.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/trace/trace.controller.d.ts +47 -0
- package/dist/backend/backend/src/controllers/trace/trace.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/trace/trace.controller.js +169 -0
- package/dist/backend/backend/src/controllers/trace/trace.controller.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +66 -20
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +14 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +6 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js +3 -0
- package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.d.ts +1 -1
- package/dist/backend/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts +4 -2
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.js +22 -6
- package/dist/backend/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/communication.module.js +2 -2
- package/dist/backend/backend/src/services/ai/prompt-modules/communication.module.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts +6 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.js +7 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.js.map +1 -1
- package/dist/backend/backend/src/services/boot/boot-announce.service.d.ts +1 -1
- package/dist/backend/backend/src/services/boot/boot-announce.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/canva/canva-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/canva/canva-token.service.js +5 -0
- package/dist/backend/backend/src/services/canva/canva-token.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat/chat.service.d.ts +78 -2
- package/dist/backend/backend/src/services/chat/chat.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat/chat.service.js +210 -26
- package/dist/backend/backend/src/services/chat/chat.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +14 -3
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +28 -4
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +4 -3
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +7 -4
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-store.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-store.js +5 -1
- package/dist/backend/backend/src/services/decisions/decision-store.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts +10 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.js +20 -9
- package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/experiments/experiment-verdict.d.ts +88 -0
- package/dist/backend/backend/src/services/experiments/experiment-verdict.d.ts.map +1 -0
- package/dist/backend/backend/src/services/experiments/experiment-verdict.js +226 -0
- package/dist/backend/backend/src/services/experiments/experiment-verdict.js.map +1 -0
- package/dist/backend/backend/src/services/experiments/experiment.service.d.ts +350 -0
- package/dist/backend/backend/src/services/experiments/experiment.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/experiments/experiment.service.js +908 -0
- package/dist/backend/backend/src/services/experiments/experiment.service.js.map +1 -0
- package/dist/backend/backend/src/services/experiments/experiment.wiring.d.ts +88 -0
- package/dist/backend/backend/src/services/experiments/experiment.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/experiments/experiment.wiring.js +162 -0
- package/dist/backend/backend/src/services/experiments/experiment.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.d.ts +67 -0
- package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.d.ts.map +1 -0
- package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.js +133 -0
- package/dist/backend/backend/src/services/experiments/seo-ops-metric.fetcher.js.map +1 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js +5 -0
- package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js +7 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts +11 -3
- package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js +24 -7
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/backend/backend/src/services/microsoft/microsoft-token.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/microsoft/microsoft-token.service.js +5 -0
- package/dist/backend/backend/src/services/microsoft/microsoft-token.service.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/token-usage.service.d.ts +7 -0
- package/dist/backend/backend/src/services/monitoring/token-usage.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/monitoring/token-usage.service.js +4 -0
- package/dist/backend/backend/src/services/monitoring/token-usage.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-status-router.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-status-router.service.js +12 -3
- package/dist/backend/backend/src/services/orc/orc-status-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +16 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/people/acting-for.service.d.ts +42 -5
- package/dist/backend/backend/src/services/people/acting-for.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/people/acting-for.service.js +80 -5
- package/dist/backend/backend/src/services/people/acting-for.service.js.map +1 -1
- package/dist/backend/backend/src/services/people/grant-sharing.d.ts +5 -4
- package/dist/backend/backend/src/services/people/grant-sharing.d.ts.map +1 -1
- package/dist/backend/backend/src/services/people/grant-sharing.js +12 -4
- package/dist/backend/backend/src/services/people/grant-sharing.js.map +1 -1
- package/dist/backend/backend/src/services/people/people-directory.service.d.ts +71 -13
- package/dist/backend/backend/src/services/people/people-directory.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/people/people-directory.service.js +164 -27
- package/dist/backend/backend/src/services/people/people-directory.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/project-ticket.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/project-ticket.service.js +2 -0
- package/dist/backend/backend/src/services/project-tickets/project-ticket.service.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js +15 -0
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/session/session-handoff.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/session-handoff.service.js +8 -5
- package/dist/backend/backend/src/services/session/session-handoff.service.js.map +1 -1
- package/dist/backend/backend/src/services/signal-digest/signal-digest-card.d.ts +75 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-card.d.ts.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-card.js +187 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-card.js.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-contract.d.ts +86 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-contract.d.ts.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-contract.js +307 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-contract.js.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-store.d.ts +85 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-store.js +185 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest-store.js.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.service.d.ts +272 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.service.js +570 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.service.js.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.wiring.d.ts +51 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.wiring.js +116 -0
- package/dist/backend/backend/src/services/signal-digest/signal-digest.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/slack/notify-reconciliation.service.d.ts +5 -1
- package/dist/backend/backend/src/services/slack/notify-reconciliation.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/notify-reconciliation.service.js +6 -2
- package/dist/backend/backend/src/services/slack/notify-reconciliation.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-bot-ids.d.ts +33 -0
- package/dist/backend/backend/src/services/slack/slack-bot-ids.d.ts.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-bot-ids.js +56 -0
- package/dist/backend/backend/src/services/slack/slack-bot-ids.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +30 -10
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts +1 -1
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts +31 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.js +52 -7
- package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/backend/backend/src/services/system/auto-update.service.d.ts +2 -2
- package/dist/backend/backend/src/services/system/auto-update.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/pool-storage.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/pool-storage.js +4 -0
- package/dist/backend/backend/src/services/task-pool/pool-storage.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +2 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/trace/trace-context.service.d.ts +195 -0
- package/dist/backend/backend/src/services/trace/trace-context.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-context.service.js +432 -0
- package/dist/backend/backend/src/services/trace/trace-context.service.js.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-http.middleware.d.ts +42 -0
- package/dist/backend/backend/src/services/trace/trace-http.middleware.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-http.middleware.js +146 -0
- package/dist/backend/backend/src/services/trace/trace-http.middleware.js.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-markers.d.ts +93 -0
- package/dist/backend/backend/src/services/trace/trace-markers.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-markers.js +162 -0
- package/dist/backend/backend/src/services/trace/trace-markers.js.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-recorder.d.ts +290 -0
- package/dist/backend/backend/src/services/trace/trace-recorder.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-recorder.js +669 -0
- package/dist/backend/backend/src/services/trace/trace-recorder.js.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-store.d.ts +269 -0
- package/dist/backend/backend/src/services/trace/trace-store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace-store.js +602 -0
- package/dist/backend/backend/src/services/trace/trace-store.js.map +1 -0
- package/dist/backend/backend/src/services/trace/trace.types.d.ts +118 -0
- package/dist/backend/backend/src/services/trace/trace.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/trace/trace.types.js +85 -0
- package/dist/backend/backend/src/services/trace/trace.types.js.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.d.ts +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.js +4 -0
- package/dist/backend/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js +22 -2
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.js +3 -0
- package/dist/backend/backend/src/services/workflow/message-scheduler.service.js.map +1 -1
- package/dist/backend/backend/src/services/workflow/scheduler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/workflow/scheduler.service.js +3 -0
- package/dist/backend/backend/src/services/workflow/scheduler.service.js.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +3 -0
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +7 -0
- package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
- package/dist/backend/backend/src/types/experiment.types.d.ts +155 -0
- package/dist/backend/backend/src/types/experiment.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/experiment.types.js +32 -0
- package/dist/backend/backend/src/types/experiment.types.js.map +1 -0
- package/dist/backend/backend/src/types/messaging.types.d.ts +6 -0
- package/dist/backend/backend/src/types/messaging.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/messaging.types.js.map +1 -1
- package/dist/backend/backend/src/types/signal-digest.types.d.ts +185 -0
- package/dist/backend/backend/src/types/signal-digest.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/signal-digest.types.js +21 -0
- package/dist/backend/backend/src/types/signal-digest.types.js.map +1 -0
- package/dist/backend/backend/src/types/v2/request.types.d.ts +4 -0
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts +4 -0
- package/dist/backend/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/work-item.types.js +1 -0
- package/dist/backend/backend/src/types/v2/work-item.types.js.map +1 -1
- package/dist/backend/backend/src/utils/security.d.ts +2 -0
- package/dist/backend/backend/src/utils/security.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/security.js +3 -1
- package/dist/backend/backend/src/utils/security.js.map +1 -1
- package/dist/backend/backend/src/websocket/chat.gateway.d.ts +9 -0
- package/dist/backend/backend/src/websocket/chat.gateway.d.ts.map +1 -1
- package/dist/backend/backend/src/websocket/chat.gateway.js +26 -0
- package/dist/backend/backend/src/websocket/chat.gateway.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +191 -1
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +194 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +6 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js +3 -0
- package/dist/cli/backend/src/services/agent/auditor-scheduler.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts +1 -1
- package/dist/cli/backend/src/services/agent/oauth-relogin-monitor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts +4 -2
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.js +22 -6
- package/dist/cli/backend/src/services/agent/runtime-session-recovery.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.js +2 -2
- package/dist/cli/backend/src/services/ai/prompt-modules/communication.module.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts +6 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/backup/backup-archive.service.js +7 -1
- package/dist/cli/backend/src/services/backup/backup-archive.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat/chat.service.d.ts +185 -0
- package/dist/cli/backend/src/services/chat/chat.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat/chat.service.js +558 -0
- package/dist/cli/backend/src/services/chat/chat.service.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +14 -3
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +28 -4
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +4 -3
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +7 -4
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision-store.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision-store.js +5 -1
- package/dist/cli/backend/src/services/decisions/decision-store.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js +7 -0
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts +11 -3
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js +24 -7
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts +7 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/monitoring/token-usage.service.js +4 -0
- package/dist/cli/backend/src/services/monitoring/token-usage.service.js.map +1 -1
- package/dist/cli/backend/src/services/people/acting-for.service.d.ts +42 -5
- package/dist/cli/backend/src/services/people/acting-for.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/people/acting-for.service.js +80 -5
- package/dist/cli/backend/src/services/people/acting-for.service.js.map +1 -1
- package/dist/cli/backend/src/services/people/people-directory.service.d.ts +71 -13
- package/dist/cli/backend/src/services/people/people-directory.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/people/people-directory.service.js +164 -27
- package/dist/cli/backend/src/services/people/people-directory.service.js.map +1 -1
- package/dist/cli/backend/src/services/project-tickets/project-ticket.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/project-tickets/project-ticket.service.js +2 -0
- package/dist/cli/backend/src/services/project-tickets/project-ticket.service.js.map +1 -1
- package/dist/cli/backend/src/services/session/session-handoff.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/session/session-handoff.service.js +8 -5
- package/dist/cli/backend/src/services/session/session-handoff.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +30 -10
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +31 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.js +52 -7
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/pool-storage.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/pool-storage.js +4 -0
- package/dist/cli/backend/src/services/task-pool/pool-storage.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +2 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/services/trace/trace-context.service.d.ts +195 -0
- package/dist/cli/backend/src/services/trace/trace-context.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-context.service.js +432 -0
- package/dist/cli/backend/src/services/trace/trace-context.service.js.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-markers.d.ts +93 -0
- package/dist/cli/backend/src/services/trace/trace-markers.d.ts.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-markers.js +162 -0
- package/dist/cli/backend/src/services/trace/trace-markers.js.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-recorder.d.ts +290 -0
- package/dist/cli/backend/src/services/trace/trace-recorder.d.ts.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-recorder.js +669 -0
- package/dist/cli/backend/src/services/trace/trace-recorder.js.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-store.d.ts +269 -0
- package/dist/cli/backend/src/services/trace/trace-store.d.ts.map +1 -0
- package/dist/cli/backend/src/services/trace/trace-store.js +602 -0
- package/dist/cli/backend/src/services/trace/trace-store.js.map +1 -0
- package/dist/cli/backend/src/services/trace/trace.types.d.ts +118 -0
- package/dist/cli/backend/src/services/trace/trace.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/trace/trace.types.js +85 -0
- package/dist/cli/backend/src/services/trace/trace.types.js.map +1 -0
- package/dist/cli/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/request.service.js +4 -0
- package/dist/cli/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
- package/dist/cli/backend/src/types/messaging.types.d.ts +6 -0
- package/dist/cli/backend/src/types/messaging.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/messaging.types.js.map +1 -1
- package/dist/cli/backend/src/types/v2/request.types.d.ts +4 -0
- package/dist/cli/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts +4 -0
- package/dist/cli/backend/src/types/v2/work-item.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/work-item.types.js +1 -0
- package/dist/cli/backend/src/types/v2/work-item.types.js.map +1 -1
- package/dist/cli/backend/src/utils/security.d.ts +196 -0
- package/dist/cli/backend/src/utils/security.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/security.js +538 -0
- package/dist/cli/backend/src/utils/security.js.map +1 -0
- package/dist/cli/backend/src/websocket/chat.gateway.d.ts +9 -0
- package/dist/cli/backend/src/websocket/chat.gateway.d.ts.map +1 -1
- package/dist/cli/backend/src/websocket/chat.gateway.js +26 -0
- package/dist/cli/backend/src/websocket/chat.gateway.js.map +1 -1
- package/frontend/dist/assets/{index-465d1f8f.js → index-800118ca.js} +274 -274
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experiment cards (issue #986, epic #982).
|
|
3
|
+
*
|
|
4
|
+
* An optimisation ticket carries an experiment: a hypothesis ("change X →
|
|
5
|
+
* metric Y from a to b"), a metric (Search Console / GA4 through the seo-ops
|
|
6
|
+
* skill), a baseline captured when the change ships and an observation
|
|
7
|
+
* window (default 14 days). When the window has settled the harness fetches
|
|
8
|
+
* the metric, labels the result worked / didn't / inconclusive (simple
|
|
9
|
+
* significance rules plus a minimum volume, see experiment-verdict.ts),
|
|
10
|
+
* resolves the agent's prediction, appends the result to the wiki
|
|
11
|
+
* experiment log and tells the owner.
|
|
12
|
+
*
|
|
13
|
+
* The experiment is a trace root: `traceId` (a run trace, `tr-…`; the ticket's
|
|
14
|
+
* trace when its ticket already has one) names the run, and
|
|
15
|
+
* the timeline records every step (created, shipped, baseline, each fetch
|
|
16
|
+
* failure, measured, logged, reported), so the whole run can be reviewed.
|
|
17
|
+
*
|
|
18
|
+
* State: `~/.crewly/experiments.json`. A tick (every 15 min) moves
|
|
19
|
+
* experiments forward, so restarts lose nothing.
|
|
20
|
+
*
|
|
21
|
+
* specs/experiment-cards.md
|
|
22
|
+
*
|
|
23
|
+
* @module services/experiments/experiment.service
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs } from 'fs';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { EXPERIMENT_CONSTANTS } from '../../constants.js';
|
|
28
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
29
|
+
import { isExperimentSource, isExperimentStatus, } from '../../types/experiment.types.js';
|
|
30
|
+
import { decideVerdict, defaultDirection, experimentWindows, formatValue } from './experiment-verdict.js';
|
|
31
|
+
import { startExperimentTrace, traceExperimentEvent } from '../trace/trace-recorder.js';
|
|
32
|
+
/** An error with an HTTP status. */
|
|
33
|
+
export class ExperimentError extends Error {
|
|
34
|
+
status;
|
|
35
|
+
/**
|
|
36
|
+
* @param status - HTTP status
|
|
37
|
+
* @param message - What went wrong
|
|
38
|
+
*/
|
|
39
|
+
constructor(status, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.name = 'ExperimentError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A ship time: a valid ISO instant that is not in the future.
|
|
47
|
+
*
|
|
48
|
+
* @param raw - Value
|
|
49
|
+
* @param now - Current time
|
|
50
|
+
* @returns ISO time
|
|
51
|
+
* @throws ExperimentError(400)
|
|
52
|
+
*/
|
|
53
|
+
function parseShipTime(raw, now) {
|
|
54
|
+
const t = Date.parse(raw);
|
|
55
|
+
if (Number.isNaN(t))
|
|
56
|
+
throw new ExperimentError(400, 'shippedAt must be an ISO time');
|
|
57
|
+
if (t > now.getTime())
|
|
58
|
+
throw new ExperimentError(400, 'shippedAt is in the future');
|
|
59
|
+
return new Date(t).toISOString();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Error text.
|
|
63
|
+
*
|
|
64
|
+
* @param err - Anything thrown
|
|
65
|
+
* @returns Message
|
|
66
|
+
*/
|
|
67
|
+
function errText(err) {
|
|
68
|
+
return err instanceof Error ? err.message : String(err);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Trimmed, length-capped string, or undefined.
|
|
72
|
+
*
|
|
73
|
+
* @param v - Value
|
|
74
|
+
* @returns Text
|
|
75
|
+
*/
|
|
76
|
+
function text(v) {
|
|
77
|
+
if (typeof v !== 'string')
|
|
78
|
+
return undefined;
|
|
79
|
+
const t = v.trim();
|
|
80
|
+
return t ? t.slice(0, EXPERIMENT_CONSTANTS.MAX_TEXT_LENGTH) : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A GA4 landing-page filter as a path: GA4 reports landing pages as paths, so
|
|
84
|
+
* a full URL is reduced to its path and any query string / fragment dropped
|
|
85
|
+
* (`https://site/x/?utm=1` → `/x/`).
|
|
86
|
+
*
|
|
87
|
+
* @param page - Page as given
|
|
88
|
+
* @returns The path
|
|
89
|
+
* @throws ExperimentError(400) when it is a URL that cannot be read
|
|
90
|
+
*/
|
|
91
|
+
export function ga4PagePath(page) {
|
|
92
|
+
let p = page.trim();
|
|
93
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) {
|
|
94
|
+
try {
|
|
95
|
+
p = new URL(p).pathname;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new ExperimentError(400, `metric.page is not a valid URL: ${page} (for ga4 give the landing page path, e.g. /h1b-guide)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
p = p.split('#')[0].split('?')[0];
|
|
102
|
+
if (!p.startsWith('/'))
|
|
103
|
+
p = `/${p}`;
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validate a metric definition.
|
|
108
|
+
*
|
|
109
|
+
* @param raw - Body
|
|
110
|
+
* @returns Metric
|
|
111
|
+
* @throws ExperimentError(400) with what is wrong
|
|
112
|
+
*/
|
|
113
|
+
export function validateMetric(raw) {
|
|
114
|
+
if (!raw || typeof raw !== 'object')
|
|
115
|
+
throw new ExperimentError(400, 'metric is required: {source, measure, config, …}');
|
|
116
|
+
const m = raw;
|
|
117
|
+
if (!isExperimentSource(m.source))
|
|
118
|
+
throw new ExperimentError(400, 'metric.source must be gsc or ga4');
|
|
119
|
+
const measures = EXPERIMENT_CONSTANTS.SOURCE_MEASURES[m.source] ?? [];
|
|
120
|
+
if (typeof m.measure !== 'string' || !measures.includes(m.measure)) {
|
|
121
|
+
throw new ExperimentError(400, `metric.measure for ${m.source} must be one of: ${measures.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
const config = text(m.config);
|
|
124
|
+
if (!config || !path.isAbsolute(config))
|
|
125
|
+
throw new ExperimentError(400, 'metric.config must be the absolute path of the seo-ops site config');
|
|
126
|
+
const out = { source: m.source, measure: m.measure, config };
|
|
127
|
+
const page = text(m.page);
|
|
128
|
+
if (page)
|
|
129
|
+
out.page = m.source === 'ga4' ? ga4PagePath(page) : page;
|
|
130
|
+
if (m.pageMatch !== undefined) {
|
|
131
|
+
if (m.pageMatch !== 'exact' && m.pageMatch !== 'contains')
|
|
132
|
+
throw new ExperimentError(400, 'metric.pageMatch must be exact or contains');
|
|
133
|
+
out.pageMatch = m.pageMatch;
|
|
134
|
+
}
|
|
135
|
+
const query = text(m.query);
|
|
136
|
+
if (query) {
|
|
137
|
+
if (m.source !== 'gsc')
|
|
138
|
+
throw new ExperimentError(400, 'metric.query is a Search Console filter (source gsc)');
|
|
139
|
+
out.query = query;
|
|
140
|
+
}
|
|
141
|
+
if (m.queryMatch !== undefined) {
|
|
142
|
+
if (m.queryMatch !== 'exact' && m.queryMatch !== 'contains')
|
|
143
|
+
throw new ExperimentError(400, 'metric.queryMatch must be exact or contains');
|
|
144
|
+
out.queryMatch = m.queryMatch;
|
|
145
|
+
}
|
|
146
|
+
const event = text(m.event);
|
|
147
|
+
if (out.measure === 'events' && !event)
|
|
148
|
+
throw new ExperimentError(400, 'metric.event is required for ga4 events (e.g. generate_lead); for every key event use measure conversions');
|
|
149
|
+
if (event)
|
|
150
|
+
out.event = event;
|
|
151
|
+
const channel = text(m.channel);
|
|
152
|
+
if (channel) {
|
|
153
|
+
if (m.source !== 'ga4')
|
|
154
|
+
throw new ExperimentError(400, 'metric.channel is a GA4 filter (source ga4)');
|
|
155
|
+
out.channel = channel;
|
|
156
|
+
}
|
|
157
|
+
const label = text(m.label);
|
|
158
|
+
if (label)
|
|
159
|
+
out.label = label;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Validate a ticket link.
|
|
164
|
+
*
|
|
165
|
+
* @param raw - Body
|
|
166
|
+
* @returns Link or undefined
|
|
167
|
+
* @throws ExperimentError(400)
|
|
168
|
+
*/
|
|
169
|
+
export function validateTicketLink(raw) {
|
|
170
|
+
if (raw === undefined || raw === null || raw === '')
|
|
171
|
+
return undefined;
|
|
172
|
+
if (typeof raw !== 'object')
|
|
173
|
+
throw new ExperimentError(400, 'ticket must be {kind: "project", project, id} or {kind: "harness", id}');
|
|
174
|
+
const t = raw;
|
|
175
|
+
const id = text(t.id);
|
|
176
|
+
if (!id)
|
|
177
|
+
throw new ExperimentError(400, 'ticket.id is required');
|
|
178
|
+
if (t.kind === 'harness')
|
|
179
|
+
return { kind: 'harness', id };
|
|
180
|
+
if (t.kind === 'project') {
|
|
181
|
+
const project = text(t.project);
|
|
182
|
+
if (!project)
|
|
183
|
+
throw new ExperimentError(400, 'ticket.project is required for a project ticket');
|
|
184
|
+
return { kind: 'project', project, id };
|
|
185
|
+
}
|
|
186
|
+
throw new ExperimentError(400, 'ticket.kind must be project or harness');
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* How a ticket link reads.
|
|
190
|
+
*
|
|
191
|
+
* @param link - Link
|
|
192
|
+
* @returns e.g. "ce-site/T-12" or "TKT-40"
|
|
193
|
+
*/
|
|
194
|
+
export function ticketLabel(link) {
|
|
195
|
+
return link.kind === 'project' ? `${link.project}/${link.id}` : link.id;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The verdict word for people.
|
|
199
|
+
*
|
|
200
|
+
* @param e - Experiment
|
|
201
|
+
* @returns worked / didn't work / inconclusive
|
|
202
|
+
*/
|
|
203
|
+
function verdictWord(e) {
|
|
204
|
+
return e.verdict === 'worked' ? 'worked' : e.verdict === 'didnt' ? "didn't work" : 'inconclusive';
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The metric as one line.
|
|
208
|
+
*
|
|
209
|
+
* @param m - Metric
|
|
210
|
+
* @returns e.g. "gsc clicks · page https://… · query visa"
|
|
211
|
+
*/
|
|
212
|
+
export function metricLabel(m) {
|
|
213
|
+
const parts = [m.label ?? `${m.source} ${m.measure}`];
|
|
214
|
+
if (m.page)
|
|
215
|
+
parts.push(`page ${m.page}`);
|
|
216
|
+
if (m.query)
|
|
217
|
+
parts.push(`query ${m.query}`);
|
|
218
|
+
if (m.event)
|
|
219
|
+
parts.push(`event ${m.event}`);
|
|
220
|
+
return parts.join(' · ');
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The result as markdown (wiki log entry and owner message share it).
|
|
224
|
+
*
|
|
225
|
+
* @param e - A measured experiment
|
|
226
|
+
* @returns Markdown
|
|
227
|
+
*/
|
|
228
|
+
export function resultSummary(e) {
|
|
229
|
+
const lines = [
|
|
230
|
+
`**${e.id} ${verdictWord(e)}: ${e.title}**`,
|
|
231
|
+
`Hypothesis: ${e.hypothesis}`,
|
|
232
|
+
`Metric: ${metricLabel(e.metric)}`,
|
|
233
|
+
`Result: ${e.verdictReason ?? 'n/a'}`,
|
|
234
|
+
];
|
|
235
|
+
if (e.expected?.to !== undefined && e.result) {
|
|
236
|
+
const reached = e.direction === 'increase' ? (e.result.total ?? -Infinity) >= e.expected.to : (e.result.total ?? Infinity) <= e.expected.to;
|
|
237
|
+
lines.push(`Target ${formatValue(e.expected.to, e.metric.measure)}: ${reached ? 'reached' : 'not reached'}`);
|
|
238
|
+
}
|
|
239
|
+
if (e.baseline && e.result)
|
|
240
|
+
lines.push(`Baseline ${e.baseline.start}..${e.baseline.end} · Result ${e.result.start}..${e.result.end}`);
|
|
241
|
+
if (e.ticket)
|
|
242
|
+
lines.push(`Ticket: ${ticketLabel(e.ticket)}`);
|
|
243
|
+
return lines.join('\n');
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Experiment cards: store, lifecycle and the measuring tick.
|
|
247
|
+
*/
|
|
248
|
+
export class ExperimentService {
|
|
249
|
+
deps;
|
|
250
|
+
static instance = null;
|
|
251
|
+
logger;
|
|
252
|
+
now;
|
|
253
|
+
chain = Promise.resolve();
|
|
254
|
+
timer = null;
|
|
255
|
+
/** A tick is running (fetches can be slow; ticks never overlap) */
|
|
256
|
+
ticking = false;
|
|
257
|
+
/** Experiments being measured right now (a measureNow racing a tick fetches once) */
|
|
258
|
+
measuring = new Set();
|
|
259
|
+
/**
|
|
260
|
+
* @param deps - Dependencies
|
|
261
|
+
*/
|
|
262
|
+
constructor(deps) {
|
|
263
|
+
this.deps = deps;
|
|
264
|
+
this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('ExperimentService');
|
|
265
|
+
this.now = deps.now ?? (() => new Date());
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The installed service (null before boot).
|
|
269
|
+
*
|
|
270
|
+
* @returns Service or null
|
|
271
|
+
*/
|
|
272
|
+
static getInstance() {
|
|
273
|
+
return ExperimentService.instance;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Install the process service.
|
|
277
|
+
*
|
|
278
|
+
* @param service - Service, or null to clear
|
|
279
|
+
*/
|
|
280
|
+
static setInstance(service) {
|
|
281
|
+
ExperimentService.instance = service;
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Lifecycle
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
/**
|
|
287
|
+
* Start the periodic tick (runs once right away).
|
|
288
|
+
*
|
|
289
|
+
* @param intervalMs - Interval (0 = no timer; tests call {@link tick})
|
|
290
|
+
*/
|
|
291
|
+
start(intervalMs = EXPERIMENT_CONSTANTS.TICK_INTERVAL_MS) {
|
|
292
|
+
if (intervalMs > 0 && !this.timer) {
|
|
293
|
+
const run = () => {
|
|
294
|
+
void this.tick().catch((err) => this.logger.warn('Experiment tick failed (non-fatal)', { error: errText(err) }));
|
|
295
|
+
};
|
|
296
|
+
this.timer = setInterval(run, intervalMs);
|
|
297
|
+
this.timer.unref?.();
|
|
298
|
+
run();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Stop the tick. */
|
|
302
|
+
stop() {
|
|
303
|
+
if (this.timer)
|
|
304
|
+
clearInterval(this.timer);
|
|
305
|
+
this.timer = null;
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Store
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/**
|
|
311
|
+
* Run one store operation at a time.
|
|
312
|
+
*
|
|
313
|
+
* @param fn - Operation
|
|
314
|
+
* @returns Its result
|
|
315
|
+
*/
|
|
316
|
+
serial(fn) {
|
|
317
|
+
const next = this.chain.then(fn, fn);
|
|
318
|
+
this.chain = next.catch(() => undefined);
|
|
319
|
+
return next;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Read the store.
|
|
323
|
+
*
|
|
324
|
+
* @returns Store data (empty when missing)
|
|
325
|
+
*/
|
|
326
|
+
async load() {
|
|
327
|
+
try {
|
|
328
|
+
const data = JSON.parse(await fs.readFile(this.deps.storeFile, 'utf-8'));
|
|
329
|
+
return { version: 1, nextNumber: data.nextNumber ?? 1, experiments: Array.isArray(data.experiments) ? data.experiments : [] };
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (err.code !== 'ENOENT')
|
|
333
|
+
this.logger.warn('Experiment store unreadable; starting empty', { error: errText(err) });
|
|
334
|
+
return { version: 1, nextNumber: 1, experiments: [] };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Write the store atomically.
|
|
339
|
+
*
|
|
340
|
+
* @param data - Store data
|
|
341
|
+
*/
|
|
342
|
+
async save(data) {
|
|
343
|
+
await fs.mkdir(path.dirname(this.deps.storeFile), { recursive: true });
|
|
344
|
+
const tmp = `${this.deps.storeFile}.${process.pid}.tmp`;
|
|
345
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
346
|
+
await fs.rename(tmp, this.deps.storeFile);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Load, change one experiment, save.
|
|
350
|
+
*
|
|
351
|
+
* @param id - Experiment id
|
|
352
|
+
* @param fn - Change (mutates the experiment)
|
|
353
|
+
* @returns The experiment after the change
|
|
354
|
+
* @throws ExperimentError(404) when not found
|
|
355
|
+
*/
|
|
356
|
+
async mutate(id, fn) {
|
|
357
|
+
return this.serial(async () => {
|
|
358
|
+
const data = await this.load();
|
|
359
|
+
const e = data.experiments.find((x) => x.id.toLowerCase() === String(id).trim().toLowerCase());
|
|
360
|
+
if (!e)
|
|
361
|
+
throw new ExperimentError(404, `Experiment not found: ${id}`);
|
|
362
|
+
await fn(e);
|
|
363
|
+
e.updatedAt = this.now().toISOString();
|
|
364
|
+
await this.save(data);
|
|
365
|
+
return e;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Add a timeline entry.
|
|
370
|
+
*
|
|
371
|
+
* @param e - Experiment
|
|
372
|
+
* @param event - Event name
|
|
373
|
+
* @param detail - Optional detail
|
|
374
|
+
*/
|
|
375
|
+
record(e, event, detail) {
|
|
376
|
+
e.timeline.push({ at: this.now().toISOString(), event, ...(detail ? { detail: detail.slice(0, 500) } : {}) });
|
|
377
|
+
traceExperimentEvent(e.traceId, e.id, event, detail);
|
|
378
|
+
if (e.timeline.length > EXPERIMENT_CONSTANTS.MAX_TIMELINE)
|
|
379
|
+
e.timeline.splice(0, e.timeline.length - EXPERIMENT_CONSTANTS.MAX_TIMELINE);
|
|
380
|
+
}
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Reads
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
/**
|
|
385
|
+
* All experiments, newest first.
|
|
386
|
+
*
|
|
387
|
+
* @param filter - Optional status / ticket filter
|
|
388
|
+
* @returns Experiments
|
|
389
|
+
*/
|
|
390
|
+
async list(filter = {}) {
|
|
391
|
+
const data = await this.serial(() => this.load());
|
|
392
|
+
return data.experiments
|
|
393
|
+
.filter((e) => !filter.status || e.status === filter.status)
|
|
394
|
+
.filter((e) => !filter.ticket || (e.ticket && (e.ticket.id === filter.ticket || ticketLabel(e.ticket) === filter.ticket)))
|
|
395
|
+
.reverse();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* One experiment.
|
|
399
|
+
*
|
|
400
|
+
* @param id - EXP-n
|
|
401
|
+
* @returns Experiment or null
|
|
402
|
+
*/
|
|
403
|
+
async get(id) {
|
|
404
|
+
const data = await this.serial(() => this.load());
|
|
405
|
+
return data.experiments.find((e) => e.id.toLowerCase() === String(id).trim().toLowerCase()) ?? null;
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Writes
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
/**
|
|
411
|
+
* Create an experiment card.
|
|
412
|
+
*
|
|
413
|
+
* @param input - Hypothesis, metric, window, ticket, confidence
|
|
414
|
+
* @param caller - Agent session, or `owner`
|
|
415
|
+
* @returns The new experiment (status planned)
|
|
416
|
+
* @throws ExperimentError(400) on invalid input
|
|
417
|
+
*/
|
|
418
|
+
async create(input, caller) {
|
|
419
|
+
const C = EXPERIMENT_CONSTANTS;
|
|
420
|
+
const hypothesis = text(input.hypothesis);
|
|
421
|
+
if (!hypothesis)
|
|
422
|
+
throw new ExperimentError(400, 'hypothesis is required: "change X → metric Y from a to b"');
|
|
423
|
+
const metric = validateMetric(input.metric);
|
|
424
|
+
if (this.deps.fileExists && !(await this.deps.fileExists(metric.config))) {
|
|
425
|
+
throw new ExperimentError(400, `seo-ops config not found: ${metric.config}`);
|
|
426
|
+
}
|
|
427
|
+
let windowDays = C.DEFAULT_WINDOW_DAYS;
|
|
428
|
+
if (input.windowDays !== undefined && input.windowDays !== null && input.windowDays !== '') {
|
|
429
|
+
const n = Number(input.windowDays);
|
|
430
|
+
if (!Number.isInteger(n) || n < C.MIN_WINDOW_DAYS || n > C.MAX_WINDOW_DAYS) {
|
|
431
|
+
throw new ExperimentError(400, `windowDays must be a whole number from ${C.MIN_WINDOW_DAYS} to ${C.MAX_WINDOW_DAYS}`);
|
|
432
|
+
}
|
|
433
|
+
windowDays = n;
|
|
434
|
+
}
|
|
435
|
+
let direction = defaultDirection(metric.measure);
|
|
436
|
+
if (input.direction !== undefined && input.direction !== null && input.direction !== '') {
|
|
437
|
+
if (input.direction !== 'increase' && input.direction !== 'decrease')
|
|
438
|
+
throw new ExperimentError(400, 'direction must be increase or decrease');
|
|
439
|
+
direction = input.direction;
|
|
440
|
+
}
|
|
441
|
+
let expected;
|
|
442
|
+
if (input.expected && typeof input.expected === 'object') {
|
|
443
|
+
const raw = input.expected;
|
|
444
|
+
const from = raw.from === undefined || raw.from === null || raw.from === '' ? undefined : Number(raw.from);
|
|
445
|
+
const to = raw.to === undefined || raw.to === null || raw.to === '' ? undefined : Number(raw.to);
|
|
446
|
+
if ((from !== undefined && !Number.isFinite(from)) || (to !== undefined && !Number.isFinite(to))) {
|
|
447
|
+
throw new ExperimentError(400, 'expected.from / expected.to must be numbers');
|
|
448
|
+
}
|
|
449
|
+
if (from !== undefined || to !== undefined)
|
|
450
|
+
expected = { ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}) };
|
|
451
|
+
}
|
|
452
|
+
let confidence = C.DEFAULT_CONFIDENCE;
|
|
453
|
+
if (input.confidence !== undefined && input.confidence !== null && input.confidence !== '') {
|
|
454
|
+
const c = Number(input.confidence);
|
|
455
|
+
if (!Number.isFinite(c) || c < 0 || c > 1)
|
|
456
|
+
throw new ExperimentError(400, 'confidence must be between 0 and 1');
|
|
457
|
+
confidence = c;
|
|
458
|
+
}
|
|
459
|
+
const ticket = validateTicketLink(input.ticket);
|
|
460
|
+
const title = text(input.title) ?? hypothesis.slice(0, 80);
|
|
461
|
+
const explicitShip = input.shippedAt === undefined || input.shippedAt === null || input.shippedAt === '' ? undefined : parseShipTime(String(input.shippedAt), this.now());
|
|
462
|
+
// A ticket that is already done ships the card right away. Its done
|
|
463
|
+
// transition dates the ship; with no recorded done time the caller must
|
|
464
|
+
// say when the change went live (guessing would skew the baseline).
|
|
465
|
+
let ticketDoneAt;
|
|
466
|
+
if (ticket && this.deps.ticketShipState && !explicitShip) {
|
|
467
|
+
const state = await this.deps.ticketShipState(ticket).catch((err) => {
|
|
468
|
+
this.logger.debug('Could not read the linked ticket (non-fatal)', { ticket: ticketLabel(ticket), error: errText(err) });
|
|
469
|
+
return { done: false };
|
|
470
|
+
});
|
|
471
|
+
if (state.done && !state.at) {
|
|
472
|
+
throw new ExperimentError(400, `Ticket ${ticketLabel(ticket)} is already done but has no recorded done time; pass shippedAt (the ISO time the change went live)`);
|
|
473
|
+
}
|
|
474
|
+
if (state.done && state.at)
|
|
475
|
+
ticketDoneAt = new Date(Math.min(Date.parse(state.at), this.now().getTime())).toISOString();
|
|
476
|
+
}
|
|
477
|
+
const created = await this.serial(async () => {
|
|
478
|
+
const data = await this.load();
|
|
479
|
+
const id = `${C.ID_PREFIX}${data.nextNumber}`;
|
|
480
|
+
data.nextNumber += 1;
|
|
481
|
+
const at = this.now().toISOString();
|
|
482
|
+
// The card's run: its ticket's trace, else a new `experiment` root
|
|
483
|
+
// (specs/2026-10-03-run-traces.md). `exp:EXP-n` only when tracing failed.
|
|
484
|
+
const traceId = startExperimentTrace({ id, title, ...(ticket ? { ticket: { kind: ticket.kind, id: ticket.id } } : {}) }, caller) ?? `exp:${id}`;
|
|
485
|
+
const e = {
|
|
486
|
+
id,
|
|
487
|
+
traceId,
|
|
488
|
+
title,
|
|
489
|
+
hypothesis,
|
|
490
|
+
direction,
|
|
491
|
+
...(expected ? { expected } : {}),
|
|
492
|
+
metric,
|
|
493
|
+
windowDays,
|
|
494
|
+
...(ticket ? { ticket } : {}),
|
|
495
|
+
createdBy: caller,
|
|
496
|
+
confidence,
|
|
497
|
+
status: 'planned',
|
|
498
|
+
createdAt: at,
|
|
499
|
+
updatedAt: at,
|
|
500
|
+
timeline: [],
|
|
501
|
+
};
|
|
502
|
+
this.record(e, 'created', `by ${caller}${ticket ? ` on ${ticketLabel(ticket)}` : ''}`);
|
|
503
|
+
data.experiments.push(e);
|
|
504
|
+
await this.save(data);
|
|
505
|
+
return e;
|
|
506
|
+
});
|
|
507
|
+
if (ticket && this.deps.noteOnTicket) {
|
|
508
|
+
await this.deps.noteOnTicket(ticket, `Experiment ${created.id}: ${hypothesis} (metric: ${metricLabel(metric)}, ${windowDays}-day window; measured automatically after ship)`).catch((err) => this.logger.debug('Could not note the experiment on its ticket (non-fatal)', { id: created.id, error: errText(err) }));
|
|
509
|
+
}
|
|
510
|
+
this.logger.info('Experiment created', { id: created.id, caller, ticket: ticket ? ticketLabel(ticket) : undefined });
|
|
511
|
+
if (explicitShip)
|
|
512
|
+
return this.ship(created.id, caller, explicitShip);
|
|
513
|
+
if (ticketDoneAt && ticket)
|
|
514
|
+
return this.ship(created.id, `ticket ${ticketLabel(ticket)} done`, ticketDoneAt);
|
|
515
|
+
return created;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Mark an experiment shipped: fix its windows, record the prediction and
|
|
519
|
+
* capture the baseline.
|
|
520
|
+
*
|
|
521
|
+
* @param id - EXP-n
|
|
522
|
+
* @param caller - Who shipped it
|
|
523
|
+
* @param shippedAt - When it went live (default now; not in the future)
|
|
524
|
+
* @returns The experiment
|
|
525
|
+
* @throws ExperimentError(404 / 409 / 400)
|
|
526
|
+
*/
|
|
527
|
+
async ship(id, caller, shippedAt) {
|
|
528
|
+
const t = Date.parse(parseShipTime(shippedAt ?? this.now().toISOString(), this.now()));
|
|
529
|
+
await this.mutate(id, (e) => {
|
|
530
|
+
if (e.status !== 'planned')
|
|
531
|
+
throw new ExperimentError(409, `${e.id} is ${e.status}; only a planned experiment can ship`);
|
|
532
|
+
const w = experimentWindows(new Date(t).toISOString(), e.windowDays, e.metric.source);
|
|
533
|
+
e.status = 'running';
|
|
534
|
+
e.shippedAt = new Date(t).toISOString();
|
|
535
|
+
e.dueAt = w.dueAt;
|
|
536
|
+
e.fetchAttempts = 0;
|
|
537
|
+
e.stuckReported = false;
|
|
538
|
+
delete e.lastFetchAt;
|
|
539
|
+
this.record(e, 'shipped', `by ${caller}; baseline ${w.baseline.start}..${w.baseline.end}, result ${w.observation.start}..${w.observation.end}, due ${w.dueAt.slice(0, 10)}`);
|
|
540
|
+
});
|
|
541
|
+
await this.recordPrediction(id);
|
|
542
|
+
await this.captureBaseline(id);
|
|
543
|
+
return (await this.get(id));
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Cancel an experiment that has not been measured.
|
|
547
|
+
*
|
|
548
|
+
* @param id - EXP-n
|
|
549
|
+
* @param caller - Who
|
|
550
|
+
* @param reason - Why
|
|
551
|
+
* @returns The experiment
|
|
552
|
+
* @throws ExperimentError(404 / 409)
|
|
553
|
+
*/
|
|
554
|
+
async cancel(id, caller, reason) {
|
|
555
|
+
return this.mutate(id, (e) => {
|
|
556
|
+
if (e.status === 'done' || e.status === 'cancelled')
|
|
557
|
+
throw new ExperimentError(409, `${e.id} is already ${e.status}`);
|
|
558
|
+
e.status = 'cancelled';
|
|
559
|
+
this.record(e, 'cancelled', `by ${caller}${reason ? `: ${reason}` : ''}`);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Measure a due experiment now (instead of waiting for the tick).
|
|
564
|
+
*
|
|
565
|
+
* @param id - EXP-n
|
|
566
|
+
* @returns The experiment
|
|
567
|
+
* @throws ExperimentError(404 / 409) when it is not running or not yet due
|
|
568
|
+
*/
|
|
569
|
+
async measureNow(id) {
|
|
570
|
+
const e = await this.get(id);
|
|
571
|
+
if (!e)
|
|
572
|
+
throw new ExperimentError(404, `Experiment not found: ${id}`);
|
|
573
|
+
if (e.status !== 'running')
|
|
574
|
+
throw new ExperimentError(409, `${e.id} is ${e.status}; only a running experiment can be measured`);
|
|
575
|
+
if (e.dueAt && Date.parse(e.dueAt) > this.now().getTime()) {
|
|
576
|
+
throw new ExperimentError(409, `${e.id} is not due until ${e.dueAt.slice(0, 10)} (the window's numbers have not settled)`);
|
|
577
|
+
}
|
|
578
|
+
if (!e.baseline)
|
|
579
|
+
await this.captureBaseline(id);
|
|
580
|
+
await this.measure(id);
|
|
581
|
+
await this.followUp(id);
|
|
582
|
+
return (await this.get(id));
|
|
583
|
+
}
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
// The tick
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
/**
|
|
588
|
+
* Move every experiment forward: ship planned ones whose ticket shipped,
|
|
589
|
+
* retry missing baselines, measure due ones, finish follow-ups.
|
|
590
|
+
*
|
|
591
|
+
* @returns Counts of what happened (zeros when a tick is already running)
|
|
592
|
+
*/
|
|
593
|
+
async tick() {
|
|
594
|
+
if (this.ticking)
|
|
595
|
+
return { shipped: 0, measured: 0 };
|
|
596
|
+
this.ticking = true;
|
|
597
|
+
try {
|
|
598
|
+
return await this.runTick();
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
this.ticking = false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* One pass over all experiments (see {@link tick}).
|
|
606
|
+
*
|
|
607
|
+
* @returns Counts of what happened
|
|
608
|
+
*/
|
|
609
|
+
async runTick() {
|
|
610
|
+
const all = await this.serial(() => this.load());
|
|
611
|
+
let shipped = 0;
|
|
612
|
+
let measured = 0;
|
|
613
|
+
const nowMs = this.now().getTime();
|
|
614
|
+
for (const e of all.experiments) {
|
|
615
|
+
try {
|
|
616
|
+
if (e.status === 'planned' && e.ticket && this.deps.ticketShipState) {
|
|
617
|
+
const state = await this.deps.ticketShipState(e.ticket);
|
|
618
|
+
if (state.done && state.at) {
|
|
619
|
+
const t = Math.min(Date.parse(state.at) || nowMs, nowMs);
|
|
620
|
+
await this.ship(e.id, `ticket ${ticketLabel(e.ticket)} done`, new Date(t).toISOString());
|
|
621
|
+
shipped += 1;
|
|
622
|
+
}
|
|
623
|
+
else if (state.done) {
|
|
624
|
+
await this.shipTimeUnknown(e.id);
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (e.status === 'running') {
|
|
629
|
+
// After MAX_FETCH_ATTEMPTS failures in a row, retry once a day.
|
|
630
|
+
if (this.backingOff(e, nowMs))
|
|
631
|
+
continue;
|
|
632
|
+
if (!e.baseline)
|
|
633
|
+
await this.captureBaseline(e.id);
|
|
634
|
+
if (e.dueAt && Date.parse(e.dueAt) <= nowMs) {
|
|
635
|
+
const after = await this.get(e.id);
|
|
636
|
+
if (after?.baseline) {
|
|
637
|
+
await this.measure(e.id);
|
|
638
|
+
if ((await this.get(e.id))?.status === 'done')
|
|
639
|
+
measured += 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (e.status === 'done' || (await this.get(e.id))?.status === 'done')
|
|
644
|
+
await this.followUp(e.id);
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
this.logger.warn('Experiment step failed (non-fatal)', { id: e.id, error: errText(err) });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return { shipped, measured };
|
|
651
|
+
}
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Steps
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
/**
|
|
656
|
+
* Whether a running experiment's fetches are backing off: after
|
|
657
|
+
* MAX_FETCH_ATTEMPTS failures in a row the tick retries once every
|
|
658
|
+
* FETCH_BACKOFF_MS instead of every tick.
|
|
659
|
+
*
|
|
660
|
+
* @param e - Experiment
|
|
661
|
+
* @param nowMs - Now
|
|
662
|
+
* @returns True to skip this tick
|
|
663
|
+
*/
|
|
664
|
+
backingOff(e, nowMs) {
|
|
665
|
+
if ((e.fetchAttempts ?? 0) < EXPERIMENT_CONSTANTS.MAX_FETCH_ATTEMPTS || !e.lastFetchAt)
|
|
666
|
+
return false;
|
|
667
|
+
return nowMs - Date.parse(e.lastFetchAt) < EXPERIMENT_CONSTANTS.FETCH_BACKOFF_MS;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* The linked ticket is done but its done time is not recorded: the card
|
|
671
|
+
* cannot be dated, so it waits for an explicit ship. Recorded (and the
|
|
672
|
+
* owner told) once.
|
|
673
|
+
*
|
|
674
|
+
* @param id - EXP-n
|
|
675
|
+
*/
|
|
676
|
+
async shipTimeUnknown(id) {
|
|
677
|
+
let first = null;
|
|
678
|
+
await this.mutate(id, (e) => {
|
|
679
|
+
if (e.status !== 'planned' || e.timeline.some((t) => t.event === 'ship_time_unknown'))
|
|
680
|
+
return;
|
|
681
|
+
this.record(e, 'ship_time_unknown', 'the linked ticket is done but has no recorded done time; ship it with shippedAt');
|
|
682
|
+
first = { ...e };
|
|
683
|
+
});
|
|
684
|
+
if (!first)
|
|
685
|
+
return;
|
|
686
|
+
const e = first;
|
|
687
|
+
this.logger.warn('Experiment ticket is done without a done time; waiting for an explicit ship', { id });
|
|
688
|
+
if (this.deps.notifyOwner && e.ticket) {
|
|
689
|
+
await this.deps
|
|
690
|
+
.notifyOwner({
|
|
691
|
+
title: `Experiment ${e.id} needs its ship time`,
|
|
692
|
+
message: `${e.title}\nTicket ${ticketLabel(e.ticket)} is done, but its log has no done time, so the baseline can't be dated. Ship it with the time the change went live: experiment-card ship --id ${e.id} --shipped-at <ISO time>.`,
|
|
693
|
+
urgent: false,
|
|
694
|
+
})
|
|
695
|
+
.catch(() => false);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Record the creating agent's prediction that the hypothesis holds.
|
|
700
|
+
*
|
|
701
|
+
* @param id - EXP-n
|
|
702
|
+
*/
|
|
703
|
+
async recordPrediction(id) {
|
|
704
|
+
const e = await this.get(id);
|
|
705
|
+
if (!e || !this.deps.predictions || e.predictionId || e.createdBy === 'owner' || !e.dueAt)
|
|
706
|
+
return;
|
|
707
|
+
try {
|
|
708
|
+
const p = await this.deps.predictions.make(e.createdBy, `${e.id}: ${e.hypothesis}`, e.confidence, e.dueAt.slice(0, 10));
|
|
709
|
+
await this.mutate(id, (x) => {
|
|
710
|
+
x.predictionId = p.id;
|
|
711
|
+
this.record(x, 'prediction_recorded', `${p.id} for ${x.createdBy} at ${Math.round(x.confidence * 100)}%`);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
this.logger.debug('Could not record the experiment prediction (non-fatal)', { id, error: errText(err) });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* A fetch failed: count it, and tell the owner once when it keeps failing.
|
|
720
|
+
*
|
|
721
|
+
* @param id - EXP-n
|
|
722
|
+
* @param step - baseline / result
|
|
723
|
+
* @param err - The error
|
|
724
|
+
*/
|
|
725
|
+
async fetchFailed(id, step, err) {
|
|
726
|
+
let tell = null;
|
|
727
|
+
await this.mutate(id, (e) => {
|
|
728
|
+
e.fetchAttempts = (e.fetchAttempts ?? 0) + 1;
|
|
729
|
+
e.lastFetchAt = this.now().toISOString();
|
|
730
|
+
e.lastError = errText(err).slice(0, 500);
|
|
731
|
+
this.record(e, 'fetch_failed', `${step}: ${e.lastError}`);
|
|
732
|
+
if (e.fetchAttempts >= EXPERIMENT_CONSTANTS.MAX_FETCH_ATTEMPTS && !e.stuckReported) {
|
|
733
|
+
e.stuckReported = true;
|
|
734
|
+
tell = { ...e };
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
if (tell && this.deps.notifyOwner) {
|
|
738
|
+
const e = tell;
|
|
739
|
+
const sent = await this.deps
|
|
740
|
+
.notifyOwner({
|
|
741
|
+
title: `Experiment ${e.id} can't fetch its ${step}`,
|
|
742
|
+
message: `${e.title}\nMetric: ${metricLabel(e.metric)}\nThe last ${EXPERIMENT_CONSTANTS.MAX_FETCH_ATTEMPTS} tries failed: ${e.lastError}\nIt now retries once a day; fix the seo-ops config or credentials (${e.metric.config}).`,
|
|
743
|
+
urgent: false,
|
|
744
|
+
})
|
|
745
|
+
.catch(() => false);
|
|
746
|
+
// Not delivered: try again on the next failure.
|
|
747
|
+
if (!sent)
|
|
748
|
+
await this.mutate(id, (x) => { x.stuckReported = false; }).catch(() => undefined);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Fetch the baseline window.
|
|
753
|
+
*
|
|
754
|
+
* @param id - EXP-n
|
|
755
|
+
*/
|
|
756
|
+
async captureBaseline(id) {
|
|
757
|
+
const e = await this.get(id);
|
|
758
|
+
if (!e || e.status !== 'running' || e.baseline || !e.shippedAt)
|
|
759
|
+
return;
|
|
760
|
+
const w = experimentWindows(e.shippedAt, e.windowDays, e.metric.source);
|
|
761
|
+
let m;
|
|
762
|
+
try {
|
|
763
|
+
m = await this.deps.fetchMetric(e.metric, w.baseline);
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
await this.fetchFailed(id, 'baseline', err);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
await this.mutate(id, (x) => {
|
|
770
|
+
// A concurrent measureNow / tick may have captured it meanwhile.
|
|
771
|
+
if (x.status !== 'running' || x.baseline)
|
|
772
|
+
return;
|
|
773
|
+
x.baseline = m;
|
|
774
|
+
x.fetchAttempts = 0;
|
|
775
|
+
x.stuckReported = false;
|
|
776
|
+
delete x.lastError;
|
|
777
|
+
delete x.lastFetchAt;
|
|
778
|
+
this.record(x, 'baseline_captured', `${formatValue(m.total, x.metric.measure)} over ${m.start}..${m.end} (volume ${m.volume})`);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Fetch the observation window and label the result.
|
|
783
|
+
*
|
|
784
|
+
* @param id - EXP-n
|
|
785
|
+
*/
|
|
786
|
+
async measure(id) {
|
|
787
|
+
if (this.measuring.has(id))
|
|
788
|
+
return;
|
|
789
|
+
this.measuring.add(id);
|
|
790
|
+
try {
|
|
791
|
+
await this.measureOnce(id);
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
this.measuring.delete(id);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* One measurement (see {@link measure}).
|
|
799
|
+
*
|
|
800
|
+
* @param id - EXP-n
|
|
801
|
+
*/
|
|
802
|
+
async measureOnce(id) {
|
|
803
|
+
const e = await this.get(id);
|
|
804
|
+
if (!e || e.status !== 'running' || !e.baseline || !e.shippedAt)
|
|
805
|
+
return;
|
|
806
|
+
const w = experimentWindows(e.shippedAt, e.windowDays, e.metric.source);
|
|
807
|
+
let m;
|
|
808
|
+
try {
|
|
809
|
+
m = await this.deps.fetchMetric(e.metric, w.observation);
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
await this.fetchFailed(id, 'result', err);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
await this.mutate(id, (x) => {
|
|
816
|
+
// Re-checked under the store lock: a racing measureNow / tick may
|
|
817
|
+
// have measured it already.
|
|
818
|
+
if (x.status !== 'running' || !x.baseline)
|
|
819
|
+
return;
|
|
820
|
+
const v = decideVerdict(x.metric.measure, x.direction, x.baseline, m);
|
|
821
|
+
x.result = m;
|
|
822
|
+
x.verdict = v.verdict;
|
|
823
|
+
x.verdictReason = v.reason;
|
|
824
|
+
x.status = 'done';
|
|
825
|
+
x.fetchAttempts = 0;
|
|
826
|
+
delete x.lastError;
|
|
827
|
+
delete x.lastFetchAt;
|
|
828
|
+
this.record(x, 'measured', `${v.verdict}: ${v.reason}`);
|
|
829
|
+
});
|
|
830
|
+
this.logger.info('Experiment measured', { id });
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* After a result: resolve the prediction, write the wiki log, tell the
|
|
834
|
+
* owner. Each step is retried by later ticks until it lands.
|
|
835
|
+
*
|
|
836
|
+
* @param id - EXP-n
|
|
837
|
+
*/
|
|
838
|
+
async followUp(id) {
|
|
839
|
+
const e = await this.get(id);
|
|
840
|
+
if (!e || e.status !== 'done' || !e.verdict)
|
|
841
|
+
return;
|
|
842
|
+
const summary = resultSummary(e);
|
|
843
|
+
if (e.predictionId && this.deps.predictions && !e.timeline.some((t) => t.event === 'prediction_resolved') && e.verdict !== 'inconclusive') {
|
|
844
|
+
try {
|
|
845
|
+
await this.deps.predictions.resolve(e.createdBy, e.predictionId, `${verdictWord(e)}: ${e.verdictReason ?? ''}`, e.verdict === 'worked');
|
|
846
|
+
await this.mutate(id, (x) => this.record(x, 'prediction_resolved', `${x.predictionId}: ${x.verdict === 'worked' ? 'accurate' : 'inaccurate'}`));
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
this.logger.debug('Could not resolve the experiment prediction (non-fatal)', { id, error: errText(err) });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!e.loggedAt && this.deps.writeLog) {
|
|
853
|
+
const ok = await this.deps.writeLog(e, summary).catch((err) => {
|
|
854
|
+
this.logger.debug('Wiki experiment log write failed (non-fatal)', { id, error: errText(err) });
|
|
855
|
+
return false;
|
|
856
|
+
});
|
|
857
|
+
if (ok)
|
|
858
|
+
await this.mutate(id, (x) => {
|
|
859
|
+
x.loggedAt = this.now().toISOString();
|
|
860
|
+
this.record(x, 'logged', EXPERIMENT_CONSTANTS.WIKI_LOG_PATH);
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
if (!e.reportedAt && this.deps.notifyOwner) {
|
|
864
|
+
// Claim the report under the store lock before sending, so a racing
|
|
865
|
+
// followUp cannot send it twice; release the claim when not delivered.
|
|
866
|
+
const claimAt = this.now().toISOString();
|
|
867
|
+
let claimed = false;
|
|
868
|
+
await this.mutate(id, (x) => {
|
|
869
|
+
if (x.reportedAt)
|
|
870
|
+
return;
|
|
871
|
+
x.reportedAt = claimAt;
|
|
872
|
+
claimed = true;
|
|
873
|
+
});
|
|
874
|
+
if (claimed) {
|
|
875
|
+
const sent = await this.deps
|
|
876
|
+
.notifyOwner({ title: `Experiment ${e.id} ${verdictWord(e)}`, message: summary, urgent: false })
|
|
877
|
+
.catch(() => false);
|
|
878
|
+
await this.mutate(id, (x) => {
|
|
879
|
+
if (x.reportedAt !== claimAt)
|
|
880
|
+
return;
|
|
881
|
+
if (sent)
|
|
882
|
+
this.record(x, 'reported', 'owner');
|
|
883
|
+
else
|
|
884
|
+
delete x.reportedAt;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (e.ticket && this.deps.noteOnTicket && !e.timeline.some((t) => t.event === 'ticket_noted')) {
|
|
889
|
+
try {
|
|
890
|
+
await this.deps.noteOnTicket(e.ticket, `Experiment ${e.id} ${verdictWord(e)}: ${e.verdictReason ?? ''}`);
|
|
891
|
+
await this.mutate(id, (x) => this.record(x, 'ticket_noted', ticketLabel(e.ticket)));
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
this.logger.debug('Could not note the result on the ticket (non-fatal)', { id, error: errText(err) });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Is this a status filter value?
|
|
901
|
+
*
|
|
902
|
+
* @param v - Query value
|
|
903
|
+
* @returns The status or undefined
|
|
904
|
+
*/
|
|
905
|
+
export function statusFilter(v) {
|
|
906
|
+
return isExperimentStatus(v) ? v : undefined;
|
|
907
|
+
}
|
|
908
|
+
//# sourceMappingURL=experiment.service.js.map
|