@bugmole/cli 0.4.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/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Alert rules: which events go to which Slack channel or email address, and
|
|
2
|
+
// the outbox handlers that deliver them.
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { emailSender, escapeHtml, type EmailMessage } from "../email/sender.js";
|
|
5
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
6
|
+
import { onUsageAlert } from "../billing/thresholds.js";
|
|
7
|
+
import { runChangedHooks } from "../hooks.js";
|
|
8
|
+
import { enqueue, registerOutboxHandler } from "./outbox.js";
|
|
9
|
+
import { openSecret } from "./secrets.js";
|
|
10
|
+
import { postSlackMessage, slackText, SlackRevokedError, type SlackBlock } from "./slack.js";
|
|
11
|
+
import { postTeamsMessage, teamsFetchEnv, TeamsRevokedError } from "./teams.js";
|
|
12
|
+
import { molesAte, molesFound } from "../../runtime/mole-voice.js";
|
|
13
|
+
|
|
14
|
+
export const ALERT_EVENTS = {
|
|
15
|
+
run_failed: "A run fails",
|
|
16
|
+
run_fixed: "A failing plan passes again",
|
|
17
|
+
usage_80: "80% of an allowance is used",
|
|
18
|
+
usage_100: "An allowance is used up",
|
|
19
|
+
spending_limit: "The spending limit is near or reached",
|
|
20
|
+
} as const;
|
|
21
|
+
export type AlertEvent = keyof typeof ALERT_EVENTS;
|
|
22
|
+
export type AlertChannel = "slack" | "teams" | "email";
|
|
23
|
+
/** Slack and Teams are paid-plan features; email works on every plan. */
|
|
24
|
+
const PAID_CHANNELS = new Set<AlertChannel>(["slack", "teams"]);
|
|
25
|
+
|
|
26
|
+
export function isAlertEvent(value: unknown): value is AlertEvent {
|
|
27
|
+
return typeof value === "string" && Object.hasOwn(ALERT_EVENTS, value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Rule = { id: string; channel: AlertChannel; target: string };
|
|
31
|
+
export type Alert = { text: string; title: string; lines: string[]; link?: { label: string; url: string }; tone: "bad" | "good" | "info" };
|
|
32
|
+
|
|
33
|
+
function dashboard(env: Env): string {
|
|
34
|
+
return (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function matchingRules(env: Env, workspaceId: string, event: AlertEvent, projectId: string | null): Promise<Rule[]> {
|
|
38
|
+
const rows = await env.DB.prepare(`
|
|
39
|
+
SELECT r.id, r.channel, r.target FROM alert_rules r
|
|
40
|
+
LEFT JOIN slack_installations s ON r.channel = 'slack' AND s.id = r.target
|
|
41
|
+
LEFT JOIN teams_installations t ON r.channel = 'teams' AND t.id = r.target
|
|
42
|
+
WHERE r.workspace_id = ? AND r.event = ? AND r.enabled = 1
|
|
43
|
+
AND (r.project_id IS NULL OR r.project_id = ?)
|
|
44
|
+
AND (
|
|
45
|
+
r.channel = 'email'
|
|
46
|
+
OR (r.channel = 'slack' AND s.id IS NOT NULL AND s.revoked_at IS NULL)
|
|
47
|
+
OR (r.channel = 'teams' AND t.id IS NOT NULL AND t.revoked_at IS NULL)
|
|
48
|
+
)
|
|
49
|
+
`).bind(workspaceId, event, projectId).all<Rule>();
|
|
50
|
+
if (!rows.results.some((rule) => PAID_CHANNELS.has(rule.channel))) return rows.results;
|
|
51
|
+
const { features } = await getEntitlements(env, workspaceId);
|
|
52
|
+
return rows.results.filter((rule) => {
|
|
53
|
+
if (rule.channel === "slack") return features.slack;
|
|
54
|
+
if (rule.channel === "teams") return features.teams;
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function slackBlocks(alert: Alert): SlackBlock[] {
|
|
60
|
+
const icon = alert.tone === "bad" ? ":x:" : alert.tone === "good" ? ":white_check_mark:" : ":information_source:";
|
|
61
|
+
const blocks: SlackBlock[] = [
|
|
62
|
+
{ type: "section", text: { type: "mrkdwn", text: `${icon} *${slackText(alert.title)}*` } },
|
|
63
|
+
];
|
|
64
|
+
if (alert.lines.length) blocks.push({ type: "section", text: { type: "mrkdwn", text: alert.lines.map(slackText).join("\n") } });
|
|
65
|
+
if (alert.link) {
|
|
66
|
+
blocks.push({ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: alert.link.label }, url: alert.link.url }] });
|
|
67
|
+
}
|
|
68
|
+
return blocks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Teams Workflows webhooks take plain text, not blocks — the same content as email's text body. */
|
|
72
|
+
export function teamsText(alert: Alert): string {
|
|
73
|
+
const icon = alert.tone === "bad" ? "❌" : alert.tone === "good" ? "✅" : "ℹ️";
|
|
74
|
+
return [`${icon} ${alert.title}`, ...alert.lines, alert.link ? `${alert.link.label}: ${alert.link.url}` : ""].filter(Boolean).join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function alertEmail(to: string, alert: Alert): EmailMessage {
|
|
78
|
+
return {
|
|
79
|
+
to: [{ email: to }],
|
|
80
|
+
subject: alert.title,
|
|
81
|
+
tag: "alert",
|
|
82
|
+
text: [...alert.lines, alert.link ? `${alert.link.label}: ${alert.link.url}` : ""].filter(Boolean).join("\n\n"),
|
|
83
|
+
html: `${alert.lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")}${alert.link ? `<p><a href="${escapeHtml(alert.link.url)}">${escapeHtml(alert.link.label)}</a></p>` : ""}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Queues an alert for every rule that wants it. */
|
|
88
|
+
export async function dispatchAlert(env: Env, workspaceId: string, event: AlertEvent, projectId: string | null, alert: Alert, dedupe: string | null): Promise<number> {
|
|
89
|
+
let queued = 0;
|
|
90
|
+
for (const rule of await matchingRules(env, workspaceId, event, projectId)) {
|
|
91
|
+
const dedupeKey = dedupe ? `alert:${rule.id}:${dedupe}` : undefined;
|
|
92
|
+
const ok = rule.channel === "slack"
|
|
93
|
+
? await enqueue(env, "slack.message", { installationId: rule.target, text: alert.text, blocks: slackBlocks(alert) }, { dedupeKey })
|
|
94
|
+
: rule.channel === "teams"
|
|
95
|
+
? await enqueue(env, "teams.message", { installationId: rule.target, text: teamsText(alert) }, { dedupeKey })
|
|
96
|
+
: await enqueue(env, "email.message", alertEmail(rule.target, alert), { dedupeKey });
|
|
97
|
+
if (ok) queued += 1;
|
|
98
|
+
}
|
|
99
|
+
return queued;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Events ----------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const FAILED = new Set(["error", "partial"]);
|
|
105
|
+
|
|
106
|
+
export async function runAlert(env: Env, runId: string): Promise<{ event: AlertEvent; alert: Alert; projectId: string; workspaceId: string } | null> {
|
|
107
|
+
const run = await env.DB.prepare(`
|
|
108
|
+
SELECT r.id, r.project_id AS projectId, r.workspace_id AS workspaceId, r.plan_id AS planId, r.status,
|
|
109
|
+
r.steps_passed AS passed, r.steps_failed AS failed, r.error_message AS error, r.failure_code AS failureCode,
|
|
110
|
+
r.completed_at AS completedAt, r.execution_mode AS mode, p.name AS projectName,
|
|
111
|
+
e.name AS environmentName, r.environment_base_url AS baseUrl
|
|
112
|
+
FROM runs r JOIN projects p ON p.id = r.project_id
|
|
113
|
+
LEFT JOIN project_environments e ON e.project_id = r.project_id AND e.id = r.environment_id
|
|
114
|
+
WHERE r.id = ?
|
|
115
|
+
`).bind(runId).first<Record<string, string | number | null>>();
|
|
116
|
+
if (!run?.workspaceId) return null;
|
|
117
|
+
const previous = await env.DB.prepare(`
|
|
118
|
+
SELECT status, steps_failed AS failed FROM runs
|
|
119
|
+
WHERE project_id = ? AND plan_id = ? AND id != ?
|
|
120
|
+
AND status IN ('success', 'partial', 'error') AND completed_at IS NOT NULL
|
|
121
|
+
AND (completed_at < ? OR (completed_at = ? AND rowid < (SELECT rowid FROM runs WHERE id = ?)))
|
|
122
|
+
ORDER BY completed_at DESC, rowid DESC LIMIT 1
|
|
123
|
+
`).bind(run.projectId, run.planId, runId, run.completedAt, run.completedAt, runId).first<{ status: string; failed: number | null }>();
|
|
124
|
+
const status = String(run.status);
|
|
125
|
+
const event: AlertEvent | null = FAILED.has(status)
|
|
126
|
+
? "run_failed"
|
|
127
|
+
: status === "success" && previous && FAILED.has(previous.status) ? "run_fixed" : null;
|
|
128
|
+
if (!event) return null;
|
|
129
|
+
const where = run.environmentName ? ` on ${run.environmentName}` : run.baseUrl ? ` on ${String(run.baseUrl).replace(/^https?:\/\//, "")}` : "";
|
|
130
|
+
// A run that errored before any step failed (it couldn't start, say) found no bugs; say it failed.
|
|
131
|
+
const failed = Number(run.failed ?? 0);
|
|
132
|
+
const title = event === "run_failed"
|
|
133
|
+
? failed > 0
|
|
134
|
+
? `${molesFound(failed)} in ${run.planId}${where} — ${run.projectName}`
|
|
135
|
+
: `${run.planId} failed${where} — ${run.projectName}`
|
|
136
|
+
: `${molesAte(previous?.failed)} in ${run.planId}${where} — ${run.projectName}`;
|
|
137
|
+
const lines = event === "run_fixed"
|
|
138
|
+
? [`${run.planId} passes again: ${run.passed ?? 0} steps passed.`]
|
|
139
|
+
: [`${run.passed ?? 0} steps passed, ${failed} failed.`];
|
|
140
|
+
if (event === "run_failed" && run.error) lines.push(String(run.error).slice(0, 300));
|
|
141
|
+
return {
|
|
142
|
+
event,
|
|
143
|
+
projectId: String(run.projectId),
|
|
144
|
+
workspaceId: String(run.workspaceId),
|
|
145
|
+
alert: {
|
|
146
|
+
title,
|
|
147
|
+
text: title,
|
|
148
|
+
lines,
|
|
149
|
+
tone: event === "run_failed" ? "bad" : "good",
|
|
150
|
+
link: { label: "Open the run", url: `${dashboard(env)}/runs?${new URLSearchParams({ project: String(run.projectId), run: runId })}` },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runChangedHooks.push(async (env, event) => {
|
|
156
|
+
if (!event.terminal || !event.workspaceId) return;
|
|
157
|
+
const found = await runAlert(env, event.runId);
|
|
158
|
+
if (found) await dispatchAlert(env, found.workspaceId, found.event, found.projectId, found.alert, `${event.runId}:${found.event}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
onUsageAlert(async (env, workspaceId, usage) => {
|
|
162
|
+
const event: AlertEvent = usage.key.startsWith("spending_limit") ? "spending_limit" : usage.key.endsWith(":100") ? "usage_100" : "usage_80";
|
|
163
|
+
await dispatchAlert(env, workspaceId, event, null, {
|
|
164
|
+
title: usage.subject,
|
|
165
|
+
text: usage.subject,
|
|
166
|
+
lines: [usage.body],
|
|
167
|
+
tone: event === "usage_80" ? "info" : "bad",
|
|
168
|
+
link: { label: "Open billing", url: `${dashboard(env)}/billing?${new URLSearchParams({ workspace: workspaceId })}` },
|
|
169
|
+
}, null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- Delivery --------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
registerOutboxHandler("slack.message", async (env, payload: { installationId: string; text: string; blocks?: SlackBlock[] }) => {
|
|
175
|
+
const install = await env.DB.prepare(`
|
|
176
|
+
SELECT channel_id AS channel, bot_token_ciphertext AS ciphertext, bot_token_iv AS iv, revoked_at AS revokedAt
|
|
177
|
+
FROM slack_installations WHERE id = ?
|
|
178
|
+
`).bind(payload.installationId).first<{ channel: string; ciphertext: string; iv: string; revokedAt: string | null }>();
|
|
179
|
+
if (!install || install.revokedAt) return; // disconnected since it was queued; nothing to send
|
|
180
|
+
const token = await openSecret(env, install.ciphertext, install.iv);
|
|
181
|
+
try {
|
|
182
|
+
await postSlackMessage(env, token, install.channel, { text: payload.text, blocks: payload.blocks });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof SlackRevokedError) {
|
|
185
|
+
await env.DB.prepare("UPDATE slack_installations SET revoked_at = ?, revoked_reason = ? WHERE id = ? AND revoked_at IS NULL")
|
|
186
|
+
.bind(new Date().toISOString(), error.code, payload.installationId).run();
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
registerOutboxHandler("teams.message", async (env, payload: { installationId: string; text: string }) => {
|
|
193
|
+
const install = await env.DB.prepare(`
|
|
194
|
+
SELECT webhook_url_ciphertext AS ciphertext, webhook_url_iv AS iv, revoked_at AS revokedAt
|
|
195
|
+
FROM teams_installations WHERE id = ?
|
|
196
|
+
`).bind(payload.installationId).first<{ ciphertext: string; iv: string; revokedAt: string | null }>();
|
|
197
|
+
if (!install || install.revokedAt) return; // disconnected since it was queued; nothing to send
|
|
198
|
+
const webhookUrl = await openSecret(env, install.ciphertext, install.iv);
|
|
199
|
+
try {
|
|
200
|
+
await postTeamsMessage(teamsFetchEnv(env), webhookUrl, payload.text);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof TeamsRevokedError) {
|
|
203
|
+
await env.DB.prepare("UPDATE teams_installations SET revoked_at = ?, revoked_reason = 'webhook_gone' WHERE id = ? AND revoked_at IS NULL")
|
|
204
|
+
.bind(new Date().toISOString(), payload.installationId).run();
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
registerOutboxHandler("email.message", async (env, payload: EmailMessage) => {
|
|
211
|
+
await emailSender(env).send(payload);
|
|
212
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
|
+
import { recordUsage } from "../billing/ledger.js";
|
|
6
|
+
import { checkUsageThresholds } from "../billing/thresholds.js";
|
|
7
|
+
import { backoffMs, drainOutbox, enqueue, MAX_ATTEMPTS, registerOutboxHandler, PermanentDeliveryError } from "./outbox.js";
|
|
8
|
+
import { signState, verifyState } from "./secrets.js";
|
|
9
|
+
|
|
10
|
+
function fakeSlack() {
|
|
11
|
+
const state = { posts: [] as Array<{ token: string; body: any }>, reply: { ok: true } as Record<string, unknown>, status: 200 };
|
|
12
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
13
|
+
const url = String(input);
|
|
14
|
+
if (url.endsWith("/oauth.v2.access")) {
|
|
15
|
+
const form = new URLSearchParams(String(init?.body));
|
|
16
|
+
if (form.get("code") !== "good-code") return Response.json({ ok: false, error: "invalid_code" });
|
|
17
|
+
return Response.json({ ok: true, access_token: "xoxb-secret-token", team: { id: "T1", name: "Acme" }, incoming_webhook: { channel: "#qa", channel_id: "C1" } });
|
|
18
|
+
}
|
|
19
|
+
if (url.endsWith("/chat.postMessage")) {
|
|
20
|
+
state.posts.push({ token: new Headers(init?.headers).get("authorization") ?? "", body: JSON.parse(String(init?.body)) });
|
|
21
|
+
return Response.json(state.reply, { status: state.status });
|
|
22
|
+
}
|
|
23
|
+
return new Response("not found", { status: 404 });
|
|
24
|
+
}) as unknown as typeof fetch;
|
|
25
|
+
return { state, doFetch };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slackEnv(slack: ReturnType<typeof fakeSlack>, extra: Record<string, unknown> = {}) {
|
|
29
|
+
return createTestEnv({
|
|
30
|
+
FEATURE_SLACK: "on",
|
|
31
|
+
SLACK_CLIENT_ID: "client",
|
|
32
|
+
SLACK_CLIENT_SECRET: "secret",
|
|
33
|
+
SLACK_FETCH: slack.doFetch,
|
|
34
|
+
SECRETS_ENCRYPTION_KEY: "test-encryption-key",
|
|
35
|
+
DASHBOARD_URL: "https://dash.test",
|
|
36
|
+
...extra,
|
|
37
|
+
} as Partial<Env>);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function connected(slack: ReturnType<typeof fakeSlack>, plan = "team") {
|
|
41
|
+
const setup = slackEnv(slack);
|
|
42
|
+
const project = await seedProject(setup.env);
|
|
43
|
+
setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
|
|
44
|
+
const started = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/integrations/slack/install`, { as: project.owner });
|
|
45
|
+
assert.equal(started.status, 200, JSON.stringify(started.json));
|
|
46
|
+
const authorize = new URL(started.json.authorizeUrl);
|
|
47
|
+
assert.equal(authorize.searchParams.get("redirect_uri"), "https://dash.test/api/integrations/slack/callback");
|
|
48
|
+
assert.equal(authorize.searchParams.get("scope"), "chat:write,incoming-webhook");
|
|
49
|
+
const state = authorize.searchParams.get("state")!;
|
|
50
|
+
const done = await call(setup.env, "POST", "/api/integrations/slack/complete", { as: project.owner, body: { code: "good-code", state } });
|
|
51
|
+
assert.equal(done.status, 201, JSON.stringify(done.json));
|
|
52
|
+
return { ...setup, ...project, installationId: done.json.installation.id as string, state };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TEAMS_WEBHOOK = "https://acme.logic.azure.com/workflows/abc123/triggers/manual/paths/invoke";
|
|
56
|
+
|
|
57
|
+
function fakeTeams() {
|
|
58
|
+
const state = { posts: [] as Array<{ text: string }>, status: 200 };
|
|
59
|
+
const doFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
60
|
+
const body = JSON.parse(String(init?.body));
|
|
61
|
+
state.posts.push({ text: body.text });
|
|
62
|
+
return new Response(state.status === 200 ? "1" : "error", { status: state.status });
|
|
63
|
+
}) as unknown as typeof fetch;
|
|
64
|
+
return { state, doFetch };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function teamsEnv(teams: ReturnType<typeof fakeTeams>, extra: Record<string, unknown> = {}) {
|
|
68
|
+
return createTestEnv({
|
|
69
|
+
FEATURE_TEAMS: "on",
|
|
70
|
+
TEAMS_FETCH: teams.doFetch,
|
|
71
|
+
SECRETS_ENCRYPTION_KEY: "test-encryption-key",
|
|
72
|
+
DASHBOARD_URL: "https://dash.test",
|
|
73
|
+
...extra,
|
|
74
|
+
} as Partial<Env>);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function connectedTeams(teams: ReturnType<typeof fakeTeams>, plan = "team") {
|
|
78
|
+
const setup = teamsEnv(teams);
|
|
79
|
+
const project = await seedProject(setup.env);
|
|
80
|
+
setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
|
|
81
|
+
const connected = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/integrations/teams`, { as: project.owner, body: { webhookUrl: TEAMS_WEBHOOK, name: "QA channel" } });
|
|
82
|
+
assert.equal(connected.status, 201, JSON.stringify(connected.json));
|
|
83
|
+
return { ...setup, ...project, installationId: connected.json.installation.id as string };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function finishRun(env: Env, projectId: string, owner: string, key: string, status: string) {
|
|
87
|
+
const run = (await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: key } })).json.run.id;
|
|
88
|
+
await call(env, "POST", `/api/runs/${run}`, { as: owner, body: { status: "running" } });
|
|
89
|
+
await call(env, "POST", `/api/runs/${run}`, { as: owner, body: { status, stepsPassed: 3, stepsFailed: status === "success" ? 0 : 1, errorMessage: status === "success" ? undefined : "Expected <Payment> to be visible" } });
|
|
90
|
+
return run;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("the outbox retries with backoff, dead-letters, and never delivers the same key twice", async () => {
|
|
94
|
+
const { env, db } = createTestEnv();
|
|
95
|
+
const seen: string[] = [];
|
|
96
|
+
let failures = 2;
|
|
97
|
+
registerOutboxHandler("test.flaky", async (_env, payload: { n: string }) => {
|
|
98
|
+
if (failures-- > 0) throw new Error("service down");
|
|
99
|
+
seen.push(payload.n);
|
|
100
|
+
});
|
|
101
|
+
registerOutboxHandler("test.broken", async () => {
|
|
102
|
+
throw new PermanentDeliveryError("bad request");
|
|
103
|
+
});
|
|
104
|
+
assert.equal(await enqueue(env, "test.flaky", { n: "a" }, { dedupeKey: "k1" }), true);
|
|
105
|
+
assert.equal(await enqueue(env, "test.flaky", { n: "a" }, { dedupeKey: "k1" }), false);
|
|
106
|
+
await enqueue(env, "test.broken", {});
|
|
107
|
+
await enqueue(env, "test.unknown", {});
|
|
108
|
+
let now = Date.now();
|
|
109
|
+
assert.deepEqual(await drainOutbox(env, new Date(now)), { delivered: 0, retried: 1, dead: 2 });
|
|
110
|
+
assert.deepEqual(await drainOutbox(env, new Date(now)), { delivered: 0, retried: 0, dead: 0 }, "waits out the backoff");
|
|
111
|
+
now += backoffMs(1) + 1;
|
|
112
|
+
await drainOutbox(env, new Date(now));
|
|
113
|
+
now += backoffMs(2) + 1;
|
|
114
|
+
assert.equal((await drainOutbox(env, new Date(now))).delivered, 1);
|
|
115
|
+
assert.deepEqual(seen, ["a"]);
|
|
116
|
+
assert.equal(backoffMs(20), 60 * 60_000);
|
|
117
|
+
|
|
118
|
+
registerOutboxHandler("test.always", async () => {
|
|
119
|
+
throw new Error("still down");
|
|
120
|
+
});
|
|
121
|
+
await enqueue(env, "test.always", {});
|
|
122
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
123
|
+
now += 2 * 60 * 60_000;
|
|
124
|
+
await drainOutbox(env, new Date(now));
|
|
125
|
+
}
|
|
126
|
+
const row = db.db.prepare("SELECT status, attempts FROM outbox WHERE kind = 'test.always'").get() as any;
|
|
127
|
+
assert.deepEqual({ ...row }, { status: "dead", attempts: MAX_ATTEMPTS });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("OAuth state is signed, scoped to its purpose, and expires", async () => {
|
|
131
|
+
const { env } = createTestEnv({ SECRETS_ENCRYPTION_KEY: "k" } as Partial<Env>);
|
|
132
|
+
const state = await signState(env, "slack-install", { workspaceId: "acme" });
|
|
133
|
+
assert.equal((await verifyState(env, "slack-install", state))?.workspaceId, "acme");
|
|
134
|
+
assert.equal(await verifyState(env, "github-install", state), null);
|
|
135
|
+
assert.equal(await verifyState(env, "slack-install", `${state.split(".")[0]}.00`), null);
|
|
136
|
+
const expired = await signState(env, "slack-install", {}, -1);
|
|
137
|
+
assert.equal(await verifyState(env, "slack-install", expired), null);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("connecting Slack stores the token encrypted, posts a welcome, and sets default alerts", async () => {
|
|
141
|
+
const slack = fakeSlack();
|
|
142
|
+
const { env, db, workspaceId, owner, installationId, state } = await connected(slack);
|
|
143
|
+
const stored = db.db.prepare("SELECT bot_token_ciphertext AS c, channel_name AS channel FROM slack_installations WHERE id = ?").get(installationId) as any;
|
|
144
|
+
assert.equal(stored.channel, "#qa");
|
|
145
|
+
assert.ok(!String(stored.c).includes("xoxb"), "not stored in plain text");
|
|
146
|
+
assert.equal(slack.state.posts.length, 1, "welcome message sent right away");
|
|
147
|
+
assert.equal(slack.state.posts[0].token, "Bearer xoxb-secret-token");
|
|
148
|
+
assert.equal(slack.state.posts[0].body.channel, "C1");
|
|
149
|
+
|
|
150
|
+
const view = await call(env, "GET", `/api/workspaces/${workspaceId}/notifications`, { as: owner });
|
|
151
|
+
assert.equal(view.json.slack.available, true);
|
|
152
|
+
assert.deepEqual(view.json.rules.map((rule: { event: string }) => rule.event).sort(), ["run_failed", "run_fixed"]);
|
|
153
|
+
assert.ok(!JSON.stringify(view.json).includes("xoxb"), "the token never leaves the registry");
|
|
154
|
+
|
|
155
|
+
// The state is bound to the person who started the install.
|
|
156
|
+
assert.equal((await call(env, "POST", "/api/integrations/slack/complete", { as: "user_other", body: { code: "good-code", state } })).status, 400);
|
|
157
|
+
assert.equal((await call(env, "POST", "/api/integrations/slack/complete", { as: owner, body: { code: "bad-code", state } })).status, 502);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("Slack is a paid feature, and only owners and admins manage it", async () => {
|
|
161
|
+
const slack = fakeSlack();
|
|
162
|
+
const { env } = slackEnv(slack);
|
|
163
|
+
const project = await seedProject(env);
|
|
164
|
+
const free = await call(env, "POST", `/api/workspaces/${project.workspaceId}/integrations/slack/install`, { as: project.owner });
|
|
165
|
+
assert.equal(free.status, 402);
|
|
166
|
+
assert.match(free.json.error, /Team and Business/);
|
|
167
|
+
assert.equal((await call(env, "POST", `/api/workspaces/${project.workspaceId}/integrations/slack/install`, { as: "user_stranger" })).status, 403);
|
|
168
|
+
|
|
169
|
+
const unconfigured = slackEnv(slack, { SLACK_CLIENT_SECRET: "" });
|
|
170
|
+
const other = await seedProject(unconfigured.env);
|
|
171
|
+
const view = await call(unconfigured.env, "GET", `/api/workspaces/${other.workspaceId}/notifications`, { as: other.owner });
|
|
172
|
+
assert.equal(view.json.slack.available, false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a failed run and its fix reach Slack once each", async () => {
|
|
176
|
+
const slack = fakeSlack();
|
|
177
|
+
const { env, projectId, owner } = await connected(slack);
|
|
178
|
+
await seedPlan(env, projectId, owner);
|
|
179
|
+
slack.state.posts.length = 0;
|
|
180
|
+
|
|
181
|
+
const failed = await finishRun(env, projectId, owner, "r1", "error");
|
|
182
|
+
await drainOutbox(env);
|
|
183
|
+
assert.equal(slack.state.posts.length, 1);
|
|
184
|
+
const message = slack.state.posts[0].body;
|
|
185
|
+
assert.match(message.text, /Our moles found 1 bug in checkout/);
|
|
186
|
+
assert.ok(JSON.stringify(message.blocks).includes("Expected <Payment> to be visible"), "text is escaped");
|
|
187
|
+
assert.ok(JSON.stringify(message.blocks).includes(`run=${failed}`));
|
|
188
|
+
|
|
189
|
+
// A late duplicate update doesn't send again.
|
|
190
|
+
await call(env, "POST", `/api/runs/${failed}`, { as: owner, body: { status: "error" } });
|
|
191
|
+
await finishRun(env, projectId, owner, "r2", "success");
|
|
192
|
+
await finishRun(env, projectId, owner, "r3", "success");
|
|
193
|
+
await drainOutbox(env);
|
|
194
|
+
assert.equal(slack.state.posts.length, 2, "one failure, one fix, nothing for an ordinary pass");
|
|
195
|
+
assert.match(slack.state.posts[1].body.text, /Our moles ate (1 bug|the bugs) in checkout/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a revoked Slack token disconnects the channel instead of retrying forever", async () => {
|
|
199
|
+
const slack = fakeSlack();
|
|
200
|
+
const { env, db, installationId, workspaceId, owner } = await connected(slack);
|
|
201
|
+
slack.state.reply = { ok: false, error: "token_revoked" };
|
|
202
|
+
const sent = await call(env, "POST", `/api/workspaces/${workspaceId}/integrations/slack/${installationId}/test`, { as: owner });
|
|
203
|
+
assert.equal(sent.status, 202);
|
|
204
|
+
const row = db.db.prepare("SELECT revoked_reason AS reason FROM slack_installations WHERE id = ?").get(installationId) as any;
|
|
205
|
+
assert.equal(row.reason, "token_revoked");
|
|
206
|
+
const dead = db.db.prepare("SELECT COUNT(*) AS c FROM outbox WHERE status = 'dead'").get() as any;
|
|
207
|
+
assert.equal(dead.c, 1);
|
|
208
|
+
const view = await call(env, "GET", `/api/workspaces/${workspaceId}/notifications`, { as: owner });
|
|
209
|
+
assert.ok(view.json.slack.installations[0].revokedAt);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("email alerts go only to workspace members; usage alerts follow the rules", async () => {
|
|
213
|
+
const slack = fakeSlack();
|
|
214
|
+
const { env, email, workspaceId, owner, installationId, projectId } = await connected(slack);
|
|
215
|
+
await call(env, "GET", "/api/workspaces", { as: owner, headers: { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" } });
|
|
216
|
+
const rule = (body: Record<string, unknown>) => call(env, "POST", `/api/workspaces/${workspaceId}/alert-rules`, { as: owner, body });
|
|
217
|
+
assert.equal((await rule({ event: "usage_80", channel: "email", target: "someone@elsewhere.test" })).status, 422);
|
|
218
|
+
assert.equal((await rule({ event: "usage_80", channel: "email", target: "Owner@Acme.test" })).status, 201);
|
|
219
|
+
assert.equal((await rule({ event: "usage_80", channel: "email", target: "owner@acme.test" })).status, 409);
|
|
220
|
+
assert.equal((await rule({ event: "usage_80", channel: "slack", target: installationId })).status, 201);
|
|
221
|
+
assert.equal((await rule({ event: "nope", channel: "slack", target: installationId })).status, 422);
|
|
222
|
+
assert.equal((await rule({ event: "run_failed", channel: "slack", target: "slk_other" })).status, 422);
|
|
223
|
+
assert.equal((await rule({ event: "run_failed", channel: "slack", target: installationId, projectId: "not-mine" })).status, 422);
|
|
224
|
+
const scoped = await rule({ event: "run_failed", channel: "email", target: "owner@acme.test", projectId });
|
|
225
|
+
assert.equal(scoped.status, 201);
|
|
226
|
+
assert.equal((await call(env, "PATCH", `/api/workspaces/${workspaceId}/alert-rules/${scoped.json.rule.id}`, { as: owner, body: { enabled: false } })).status, 200);
|
|
227
|
+
|
|
228
|
+
slack.state.posts.length = 0;
|
|
229
|
+
const before = email.sent.length;
|
|
230
|
+
await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 2_500, source: "cloud_run", idempotencyKey: "u" });
|
|
231
|
+
await checkUsageThresholds(env, workspaceId);
|
|
232
|
+
await drainOutbox(env);
|
|
233
|
+
assert.equal(slack.state.posts.length, 1);
|
|
234
|
+
assert.match(slack.state.posts[0].body.text, /80% of this month's cloud minutes/);
|
|
235
|
+
// The billing email itself, plus the rule's copy.
|
|
236
|
+
assert.equal(email.sent.length - before, 2);
|
|
237
|
+
|
|
238
|
+
assert.equal((await call(env, "DELETE", `/api/workspaces/${workspaceId}/integrations/slack/${installationId}`, { as: owner })).status, 200);
|
|
239
|
+
const view = await call(env, "GET", `/api/workspaces/${workspaceId}/notifications`, { as: owner });
|
|
240
|
+
assert.ok(view.json.rules.every((item: { channel: string }) => item.channel === "email"), "Slack rules go with the connection");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("connecting Teams verifies the webhook, stores it encrypted, and sets default alerts", async () => {
|
|
244
|
+
const teams = fakeTeams();
|
|
245
|
+
const { env, db, workspaceId, owner, installationId } = await connectedTeams(teams);
|
|
246
|
+
assert.equal(teams.state.posts.length, 1, "a verification message is sent right away");
|
|
247
|
+
assert.match(teams.state.posts[0].text, /Bugmole is connected/);
|
|
248
|
+
const stored = db.db.prepare("SELECT webhook_url_ciphertext AS c FROM teams_installations WHERE id = ?").get(installationId) as any;
|
|
249
|
+
assert.ok(!String(stored.c).includes("logic.azure.com"), "not stored in plain text");
|
|
250
|
+
|
|
251
|
+
const view = await call(env, "GET", `/api/workspaces/${workspaceId}/notifications`, { as: owner });
|
|
252
|
+
assert.equal(view.json.teams.available, true);
|
|
253
|
+
assert.deepEqual(view.json.rules.filter((r: { channel: string }) => r.channel === "teams").map((r: { event: string }) => r.event).sort(), ["run_failed", "run_fixed"]);
|
|
254
|
+
assert.ok(!JSON.stringify(view.json).includes("logic.azure.com"), "the webhook URL never leaves the registry");
|
|
255
|
+
|
|
256
|
+
assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/integrations/teams`, { as: owner, body: { webhookUrl: "https://example.com/not-a-workflow" } })).status, 422);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("Teams is a paid feature, and only owners and admins manage it", async () => {
|
|
260
|
+
const teams = fakeTeams();
|
|
261
|
+
const { env } = teamsEnv(teams);
|
|
262
|
+
const project = await seedProject(env);
|
|
263
|
+
const free = await call(env, "POST", `/api/workspaces/${project.workspaceId}/integrations/teams`, { as: project.owner, body: { webhookUrl: TEAMS_WEBHOOK } });
|
|
264
|
+
assert.equal(free.status, 402);
|
|
265
|
+
assert.match(free.json.error, /Team and Business/);
|
|
266
|
+
assert.equal((await call(env, "POST", `/api/workspaces/${project.workspaceId}/integrations/teams`, { as: "user_stranger", body: { webhookUrl: TEAMS_WEBHOOK } })).status, 403);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("a failed run reaches both Slack and Teams; a revoked Teams webhook disconnects instead of retrying forever", async () => {
|
|
270
|
+
const slack = fakeSlack();
|
|
271
|
+
const teams = fakeTeams();
|
|
272
|
+
const setup = slackEnv(slack, { FEATURE_TEAMS: "on", TEAMS_FETCH: teams.doFetch });
|
|
273
|
+
const project = await seedProject(setup.env);
|
|
274
|
+
setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, "team");
|
|
275
|
+
await seedPlan(setup.env, project.projectId, project.owner);
|
|
276
|
+
const slackInstall = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/integrations/slack/install`, { as: project.owner });
|
|
277
|
+
const authorize = new URL(slackInstall.json.authorizeUrl);
|
|
278
|
+
await call(setup.env, "POST", "/api/integrations/slack/complete", { as: project.owner, body: { code: "good-code", state: authorize.searchParams.get("state") } });
|
|
279
|
+
const teamsInstall = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/integrations/teams`, { as: project.owner, body: { webhookUrl: TEAMS_WEBHOOK } });
|
|
280
|
+
const teamsInstallationId = teamsInstall.json.installation.id as string;
|
|
281
|
+
slack.state.posts.length = 0;
|
|
282
|
+
teams.state.posts.length = 0;
|
|
283
|
+
|
|
284
|
+
await finishRun(setup.env, project.projectId, project.owner, "r1", "error");
|
|
285
|
+
await drainOutbox(setup.env);
|
|
286
|
+
assert.equal(slack.state.posts.length, 1);
|
|
287
|
+
assert.equal(teams.state.posts.length, 1);
|
|
288
|
+
assert.match(teams.state.posts[0].text, /Our moles found 1 bug in checkout/);
|
|
289
|
+
|
|
290
|
+
teams.state.status = 404;
|
|
291
|
+
await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/integrations/teams/${teamsInstallationId}/test`, { as: project.owner });
|
|
292
|
+
await drainOutbox(setup.env);
|
|
293
|
+
const row = setup.db.db.prepare("SELECT revoked_reason AS reason FROM teams_installations WHERE id = ?").get(teamsInstallationId) as any;
|
|
294
|
+
assert.equal(row.reason, "webhook_gone");
|
|
295
|
+
|
|
296
|
+
// Already revoked by the failed delivery above, so there's nothing left to disconnect.
|
|
297
|
+
assert.equal((await call(setup.env, "DELETE", `/api/workspaces/${project.workspaceId}/integrations/teams/${teamsInstallationId}`, { as: project.owner })).status, 404);
|
|
298
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A durable queue for calls to other services. A request (or a hook) adds a
|
|
2
|
+
// row; the minute job delivers it, retrying with backoff, and gives up after
|
|
3
|
+
// MAX_ATTEMPTS so one broken destination can't clog the queue.
|
|
4
|
+
import type { Env } from "../core.js";
|
|
5
|
+
import { newId } from "../core.js";
|
|
6
|
+
import { registerJob } from "../jobs/index.js";
|
|
7
|
+
|
|
8
|
+
export const MAX_ATTEMPTS = 8;
|
|
9
|
+
const BATCH = 50;
|
|
10
|
+
const LOCK_MS = 2 * 60_000;
|
|
11
|
+
|
|
12
|
+
/** A handler decides whether a failure is worth retrying. */
|
|
13
|
+
export class PermanentDeliveryError extends Error {}
|
|
14
|
+
|
|
15
|
+
type Handler = (env: Env, payload: any) => Promise<void>;
|
|
16
|
+
const handlers = new Map<string, Handler>();
|
|
17
|
+
|
|
18
|
+
export function registerOutboxHandler(kind: string, handler: Handler): void {
|
|
19
|
+
handlers.set(kind, handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Queues work. A repeated `dedupeKey` is ignored, so hooks that fire twice send once. */
|
|
23
|
+
export async function enqueue(env: Env, kind: string, payload: unknown, options: { dedupeKey?: string; notBefore?: Date } = {}): Promise<boolean> {
|
|
24
|
+
const result = await env.DB.prepare(`
|
|
25
|
+
INSERT INTO outbox (id, kind, payload_json, dedupe_key, next_attempt_at) VALUES (?, ?, ?, ?, ?)
|
|
26
|
+
ON CONFLICT(dedupe_key) DO NOTHING
|
|
27
|
+
`).bind(newId("out"), kind, JSON.stringify(payload), options.dedupeKey ?? null, (options.notBefore ?? new Date()).toISOString()).run();
|
|
28
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function backoffMs(attempts: number): number {
|
|
32
|
+
return Math.min(30_000 * 2 ** Math.max(0, attempts - 1), 60 * 60_000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DrainResult = { delivered: number; retried: number; dead: number };
|
|
36
|
+
|
|
37
|
+
export async function drainOutbox(env: Env, now = new Date(), limit = BATCH): Promise<DrainResult> {
|
|
38
|
+
const result: DrainResult = { delivered: 0, retried: 0, dead: 0 };
|
|
39
|
+
const nowIso = now.toISOString();
|
|
40
|
+
const due = await env.DB.prepare(`
|
|
41
|
+
SELECT id, kind, payload_json AS payload, attempts FROM outbox
|
|
42
|
+
WHERE status = 'pending' AND next_attempt_at <= ? AND (locked_until IS NULL OR locked_until <= ?)
|
|
43
|
+
ORDER BY next_attempt_at ASC LIMIT ?
|
|
44
|
+
`).bind(nowIso, nowIso, limit).all<{ id: string; kind: string; payload: string; attempts: number }>();
|
|
45
|
+
for (const item of due.results) {
|
|
46
|
+
// Claim it so an overlapping run doesn't deliver it twice.
|
|
47
|
+
const claimed = await env.DB.prepare(`
|
|
48
|
+
UPDATE outbox SET locked_until = ?, attempts = attempts + 1
|
|
49
|
+
WHERE id = ? AND status = 'pending' AND (locked_until IS NULL OR locked_until <= ?)
|
|
50
|
+
`).bind(new Date(now.getTime() + LOCK_MS).toISOString(), item.id, nowIso).run();
|
|
51
|
+
if ((claimed.meta?.changes ?? 0) !== 1) continue;
|
|
52
|
+
const attempts = item.attempts + 1;
|
|
53
|
+
const handler = handlers.get(item.kind);
|
|
54
|
+
try {
|
|
55
|
+
if (!handler) throw new PermanentDeliveryError(`No handler for ${item.kind}`);
|
|
56
|
+
await handler(env, JSON.parse(item.payload));
|
|
57
|
+
await env.DB.prepare("UPDATE outbox SET status = 'done', done_at = ?, locked_until = NULL, last_error = NULL WHERE id = ?")
|
|
58
|
+
.bind(nowIso, item.id).run();
|
|
59
|
+
result.delivered += 1;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
62
|
+
if (error instanceof PermanentDeliveryError || attempts >= MAX_ATTEMPTS) {
|
|
63
|
+
await env.DB.prepare("UPDATE outbox SET status = 'dead', locked_until = NULL, last_error = ? WHERE id = ?").bind(message, item.id).run();
|
|
64
|
+
console.error(`[outbox] giving up on ${item.kind} ${item.id}: ${message}`);
|
|
65
|
+
result.dead += 1;
|
|
66
|
+
} else {
|
|
67
|
+
await env.DB.prepare("UPDATE outbox SET locked_until = NULL, last_error = ?, next_attempt_at = ? WHERE id = ?")
|
|
68
|
+
.bind(message, new Date(now.getTime() + backoffMs(attempts)).toISOString(), item.id).run();
|
|
69
|
+
result.retried += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
registerJob("minute", "outbox", async (env, _ctx, now) => {
|
|
77
|
+
await drainOutbox(env, now);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Delivers queued work right after the request that queued it, instead of waiting for the next minute. */
|
|
81
|
+
export function drainSoon(env: Env, ctx: ExecutionContext): void {
|
|
82
|
+
ctx.waitUntil(drainOutbox(env).then(() => undefined));
|
|
83
|
+
}
|