mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
Agents were writing runtime-local paths into deliverables as clickable links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both fixed here. A. The brief never stated the delivery contract. Add an always-on delivery invariant (outside writeOutput's kind switch, so no task kind can inherit none) plus a per-surface file-delivery line for each of the five surfaces. Chat splits into two: `attachment upload` works only on web/mobile chat, never on an IM channel, so ChatChannelType is now threaded into TaskContextForEnv. The claim path only ever looked up Slack bindings, so a Feishu session reported as a web chat and got upload guidance for a channel that cannot carry attachments. Probe every channel type. The chat policy is two independent layers and stays that way: delivery keys off "is there a channel at all"; the `chat history` / `chat thread` commands stay Slack-only because both endpoints are hardwired to h.SlackHistory and there is no Feishu reader — ChatInThread only selects between those two commands, so it stays Slack-only too. Add a CLI hard-fail lint on `issue comment add` / `issue create` / `issue update` as the enforcement backstop. Scoped narrowly, since a false positive blocks a real deliverable: agent task context only (a human's PAT run is untouched), real CommonMark link/image/autolink destinations only via goldmark (a path in a code span or fence — how an agent quotes a path it is discussing — is structurally invisible), and three high-confidence signals only (`file://`, inside the workdir, or an existing local file). A bare `/foo` is a valid origin-relative URI and is deliberately allowed. `issue update` has no --attachment flag, so its hint redirects to `comment add` rather than naming an argument it rejects. B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18 desktop_route_error reports were users clicking such a link and being told the app broke and to file a bug. Split the 404 into a first-class Not Found view: no crash framing, no Report error. Its recovery entry comes from the tab store's active workspace, never from the failed pathname — deriving a slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`, a second 404. Also add a will-navigate trusted-origin guard via the shared loadRenderer (main + issue windows). This is origin hardening only, NOT the mechanism for in-app links: client-side routing never fires will-navigate, so app paths never reach it. Issue windows need no 404 work — their router only accepts paths validated by parseIssueWindowPath and they do not listen for multica:navigate, so a bad path cannot reach them. Server-side completion observation is metric/log only and never blocks: it is lexical (`file://` + task work_dir prefix) because the server cannot stat the daemon's filesystem, and the metric label is a closed enum so no path or reply text reaches Prometheus. Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite including new claim-path integration tests. cmd/multica was verified outside the daemon workdir — inside one, 93 of its tests fail identically on origin/main because the suite walks up and finds the runtime's own task marker. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package metrics
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
)
|
|
|
|
// PR3 normalizers. All inputs go through fixed allow-lists so a misbehaving
|
|
// caller cannot inflate metric cardinality. Every "unknown" / "other" bucket
|
|
// keeps the series count bounded even under enum drift.
|
|
|
|
var (
|
|
knownPlatforms = map[string]string{
|
|
"server": "server",
|
|
"web": "web",
|
|
"desktop": "desktop",
|
|
"cli": "cli",
|
|
"mobile": "mobile",
|
|
"ios": "ios",
|
|
"unknown": "unknown",
|
|
}
|
|
|
|
// knownSignupSources is the fixed bucket set for the signup_source
|
|
// metric label. The PostHog event still ships the raw cookie value
|
|
// so analytics keeps the long tail; the Prometheus side gets the
|
|
// bucketed version so cardinality stays bounded even if a misbehaving
|
|
// frontend writes a unique-per-visitor cookie. Empty cookie collapses
|
|
// to "direct" (no attribution = direct visit), unknown channels to
|
|
// "other".
|
|
knownSignupSources = map[string]string{
|
|
"direct": "direct",
|
|
"google": "google",
|
|
"bing": "bing",
|
|
"duckduckgo": "duckduckgo",
|
|
"twitter": "twitter",
|
|
"x": "twitter",
|
|
"linkedin": "linkedin",
|
|
"facebook": "facebook",
|
|
"instagram": "instagram",
|
|
"github": "github",
|
|
"gitlab": "gitlab",
|
|
"hacker_news": "hacker_news",
|
|
"hackernews": "hacker_news",
|
|
"reddit": "reddit",
|
|
"youtube": "youtube",
|
|
"discord": "discord",
|
|
"slack": "slack",
|
|
"product_hunt": "product_hunt",
|
|
"producthunt": "product_hunt",
|
|
"medium": "medium",
|
|
"dev_to": "dev_to",
|
|
"devto": "dev_to",
|
|
"email": "email",
|
|
"newsletter": "email",
|
|
"organic": "organic",
|
|
"referral": "referral",
|
|
"partner": "partner",
|
|
"affiliate": "affiliate",
|
|
"ad": "paid",
|
|
"ads": "paid",
|
|
"paid": "paid",
|
|
"cpc": "paid",
|
|
"sem": "paid",
|
|
"other": "other",
|
|
}
|
|
|
|
knownOnboardingPaths = map[string]string{
|
|
"full": "full",
|
|
"runtime_skipped": "runtime_skipped",
|
|
"cloud_waitlist": "cloud_waitlist",
|
|
"skip_existing": "skip_existing",
|
|
"invite_accept": "invite_accept",
|
|
"unknown": "unknown",
|
|
}
|
|
|
|
knownAutopilotCadences = map[string]string{
|
|
"hourly": "hourly",
|
|
"daily": "daily",
|
|
"weekly": "weekly",
|
|
"monthly": "monthly",
|
|
"manual": "manual",
|
|
"webhook": "webhook",
|
|
"unknown": "unknown",
|
|
}
|
|
|
|
knownAutopilotTriggers = map[string]string{
|
|
"schedule": "schedule",
|
|
"webhook": "webhook",
|
|
"manual": "manual",
|
|
"unknown": "unknown",
|
|
}
|
|
|
|
knownAutopilotSkipReasons = map[string]string{
|
|
"already_running": "already_running",
|
|
"recent_run": "recent_run",
|
|
"runtime_offline": "runtime_offline",
|
|
"throttled": "throttled",
|
|
"max_concurrency": "max_concurrency",
|
|
"trigger_disabled": "trigger_disabled",
|
|
"signature_invalid": "signature_invalid",
|
|
"unknown": "unknown",
|
|
"other": "other",
|
|
}
|
|
|
|
knownWebhookProviders = map[string]string{
|
|
"github": "github",
|
|
"generic": "generic",
|
|
"gitlab": "gitlab",
|
|
"stripe": "stripe",
|
|
"other": "other",
|
|
}
|
|
|
|
knownWebhookDeliveryStatuses = map[string]string{
|
|
"queued": "queued",
|
|
"dispatched": "dispatched",
|
|
"failed": "failed",
|
|
"rejected": "rejected",
|
|
"ignored": "ignored",
|
|
"duplicate": "duplicate",
|
|
"other": "other",
|
|
}
|
|
|
|
knownWebhookRateLimitGates = map[string]string{
|
|
"absolute_ip": "absolute_ip",
|
|
"bad_credential_ip": "bad_credential_ip",
|
|
"worker_trigger": "worker_trigger",
|
|
"other": "other",
|
|
}
|
|
|
|
knownGithubEventKinds = map[string]string{
|
|
"pull_request": "pull_request",
|
|
"pull_request_review": "pull_request_review",
|
|
"issues": "issues",
|
|
"issue_comment": "issue_comment",
|
|
"push": "push",
|
|
"installation": "installation",
|
|
"installation_repositories": "installation_repositories",
|
|
"check_run": "check_run",
|
|
"check_suite": "check_suite",
|
|
"ping": "ping",
|
|
"other": "other",
|
|
}
|
|
|
|
knownGithubActions = map[string]string{
|
|
"opened": "opened",
|
|
"closed": "closed",
|
|
"reopened": "reopened",
|
|
"merged": "merged",
|
|
"synchronize": "synchronize",
|
|
"edited": "edited",
|
|
"submitted": "submitted",
|
|
"created": "created",
|
|
"deleted": "deleted",
|
|
"labeled": "labeled",
|
|
"unlabeled": "unlabeled",
|
|
"assigned": "assigned",
|
|
"unassigned": "unassigned",
|
|
"requested": "requested",
|
|
"completed": "completed",
|
|
"none": "none",
|
|
"other": "other",
|
|
}
|
|
|
|
knownGithubPRReviewResults = map[string]string{
|
|
"approved": "approved",
|
|
"changes_requested": "changes_requested",
|
|
"commented": "commented",
|
|
"dismissed": "dismissed",
|
|
"other": "other",
|
|
}
|
|
|
|
knownCloudRuntimeOps = map[string]string{
|
|
"provision": "provision",
|
|
"terminate": "terminate",
|
|
"status": "status",
|
|
"gateway": "gateway",
|
|
"billing": "billing",
|
|
"fleet": "fleet",
|
|
"other": "other",
|
|
}
|
|
|
|
knownCloudRuntimeStatuses = map[string]string{
|
|
"ok": "ok",
|
|
"4xx": "4xx",
|
|
"5xx": "5xx",
|
|
"timeout": "timeout",
|
|
"error": "error",
|
|
}
|
|
|
|
knownDaemonWSKinds = map[string]string{
|
|
"heartbeat": "heartbeat",
|
|
"task_claim": "task_claim",
|
|
"task_complete": "task_complete",
|
|
"task_usage": "task_usage",
|
|
"task_progress": "task_progress",
|
|
"task_messages": "task_messages",
|
|
"log": "log",
|
|
"other": "other",
|
|
}
|
|
|
|
knownFeedbackKinds = map[string]string{
|
|
"bug": "bug",
|
|
"feature": "feature",
|
|
"general": "general",
|
|
"praise": "praise",
|
|
"other": "other",
|
|
}
|
|
|
|
// Evidence kinds for multica_chat_output_local_path_total (MUL-4899). A
|
|
// closed allowlist is what keeps the offending path out of Prometheus: the
|
|
// caller passes a classification, never a fragment of the reply.
|
|
knownChatOutputLocalPathKinds = map[string]string{
|
|
"file_url": "file_url",
|
|
"workdir_path": "workdir_path",
|
|
}
|
|
|
|
knownContactSalesSources = map[string]string{
|
|
"page": "page",
|
|
"onboarding": "onboarding",
|
|
"agents_page": "agents_page",
|
|
"unknown": "unknown",
|
|
"other": "other",
|
|
}
|
|
)
|
|
|
|
func normalizeFromAllowList(value string, allowList map[string]string, fallback string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if normalized, ok := allowList[value]; ok {
|
|
return normalized
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func NormalizePlatform(value string) string {
|
|
return normalizeFromAllowList(value, knownPlatforms, "unknown")
|
|
}
|
|
|
|
// NormalizeSignupSource buckets the raw multica_signup_source cookie payload
|
|
// into the fixed signup channel allow-list. The cookie carries free-form
|
|
// JSON (utm_source / utm_medium / referrer) on the PostHog side; here we
|
|
// only need a bounded label, so we look at utm_source / source / referrer
|
|
// fields when present, otherwise the bare string. Empty -> "direct".
|
|
// Anything not in the allow-list -> "other".
|
|
func NormalizeSignupSource(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "direct"
|
|
}
|
|
// JSON shape: {"utm_source":"...","utm_medium":"...","referrer":"..."}
|
|
if strings.HasPrefix(value, "{") {
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
|
|
for _, key := range []string{"utm_source", "source", "referrer", "ref"} {
|
|
if raw, ok := parsed[key]; ok {
|
|
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
|
value = s
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return normalizeFromAllowList(canonicaliseSignupChannel(value), knownSignupSources, "other")
|
|
}
|
|
|
|
// canonicaliseSignupChannel collapses the raw signup-source string into a
|
|
// shape the allow-list can match: lowercase, trimmed, host-only for URL-ish
|
|
// values, and with a few obvious aliases unified ("twitter.com" -> "twitter").
|
|
// We deliberately keep this defensive — the cookie is set client-side, so any
|
|
// shape is possible.
|
|
func canonicaliseSignupChannel(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
// Strip an optional URL scheme so "https://twitter.com/foo" -> "twitter.com/foo".
|
|
for _, scheme := range []string{"https://", "http://", "//"} {
|
|
if strings.HasPrefix(value, scheme) {
|
|
value = strings.TrimPrefix(value, scheme)
|
|
break
|
|
}
|
|
}
|
|
// Take just the host segment.
|
|
if i := strings.IndexAny(value, "/?#"); i >= 0 {
|
|
value = value[:i]
|
|
}
|
|
value = strings.TrimPrefix(value, "www.")
|
|
// Map well-known hostnames to their channel bucket.
|
|
hostAliases := map[string]string{
|
|
"google.com": "google",
|
|
"google.co.uk": "google",
|
|
"bing.com": "bing",
|
|
"duckduckgo.com": "duckduckgo",
|
|
"twitter.com": "twitter",
|
|
"x.com": "twitter",
|
|
"t.co": "twitter",
|
|
"linkedin.com": "linkedin",
|
|
"lnkd.in": "linkedin",
|
|
"facebook.com": "facebook",
|
|
"fb.com": "facebook",
|
|
"instagram.com": "instagram",
|
|
"github.com": "github",
|
|
"gitlab.com": "gitlab",
|
|
"news.ycombinator.com": "hacker_news",
|
|
"reddit.com": "reddit",
|
|
"old.reddit.com": "reddit",
|
|
"youtube.com": "youtube",
|
|
"youtu.be": "youtube",
|
|
"discord.com": "discord",
|
|
"discord.gg": "discord",
|
|
"slack.com": "slack",
|
|
"producthunt.com": "product_hunt",
|
|
"medium.com": "medium",
|
|
"dev.to": "dev_to",
|
|
}
|
|
if mapped, ok := hostAliases[value]; ok {
|
|
return mapped
|
|
}
|
|
return value
|
|
}
|
|
|
|
func NormalizeOnboardingPath(value string) string {
|
|
return normalizeFromAllowList(value, knownOnboardingPaths, "unknown")
|
|
}
|
|
|
|
func NormalizeAutopilotCadence(value string) string {
|
|
return normalizeFromAllowList(value, knownAutopilotCadences, "unknown")
|
|
}
|
|
|
|
func NormalizeAutopilotTrigger(value string) string {
|
|
return normalizeFromAllowList(value, knownAutopilotTriggers, "unknown")
|
|
}
|
|
|
|
func NormalizeAutopilotSkipReason(value string) string {
|
|
return normalizeFromAllowList(value, knownAutopilotSkipReasons, "other")
|
|
}
|
|
|
|
func NormalizeWebhookProvider(value string) string {
|
|
return normalizeFromAllowList(value, knownWebhookProviders, "other")
|
|
}
|
|
|
|
func NormalizeWebhookDeliveryStatus(value string) string {
|
|
return normalizeFromAllowList(value, knownWebhookDeliveryStatuses, "other")
|
|
}
|
|
|
|
func NormalizeWebhookRateLimitGate(value string) string {
|
|
return normalizeFromAllowList(value, knownWebhookRateLimitGates, "other")
|
|
}
|
|
|
|
func NormalizeGithubEventKind(value string) string {
|
|
return normalizeFromAllowList(value, knownGithubEventKinds, "other")
|
|
}
|
|
|
|
func NormalizeGithubAction(value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "none"
|
|
}
|
|
return normalizeFromAllowList(value, knownGithubActions, "other")
|
|
}
|
|
|
|
func NormalizeGithubPRReviewResult(value string) string {
|
|
return normalizeFromAllowList(value, knownGithubPRReviewResults, "other")
|
|
}
|
|
|
|
func NormalizeCloudRuntimeOp(value string) string {
|
|
return normalizeFromAllowList(value, knownCloudRuntimeOps, "other")
|
|
}
|
|
|
|
// NormalizeCloudRuntimeStatus collapses an HTTP status code or symbolic
|
|
// outcome string into the fixed bucket set {ok, 4xx, 5xx, timeout, error}.
|
|
// Empty / unknown collapses to "error".
|
|
func NormalizeCloudRuntimeStatus(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if normalized, ok := knownCloudRuntimeStatuses[value]; ok {
|
|
return normalized
|
|
}
|
|
if len(value) == 3 {
|
|
switch value[0] {
|
|
case '2':
|
|
return "ok"
|
|
case '4':
|
|
return "4xx"
|
|
case '5':
|
|
return "5xx"
|
|
}
|
|
}
|
|
return "error"
|
|
}
|
|
|
|
// CloudRuntimeStatusForCode maps an HTTP status code to its bucket label.
|
|
// Used by cloudruntime client instrumentation.
|
|
func CloudRuntimeStatusForCode(code int) string {
|
|
switch {
|
|
case code >= 200 && code < 400:
|
|
return "ok"
|
|
case code >= 400 && code < 500:
|
|
return "4xx"
|
|
case code >= 500 && code < 600:
|
|
return "5xx"
|
|
default:
|
|
return "error"
|
|
}
|
|
}
|
|
|
|
func NormalizeDaemonWSKind(value string) string {
|
|
return normalizeFromAllowList(value, knownDaemonWSKinds, "other")
|
|
}
|
|
|
|
func NormalizeFeedbackKind(value string) string {
|
|
return normalizeFromAllowList(value, knownFeedbackKinds, "other")
|
|
}
|
|
|
|
func NormalizeContactSalesSource(value string) string {
|
|
return normalizeFromAllowList(value, knownContactSalesSources, "other")
|
|
}
|
|
|
|
func NormalizeChatOutputLocalPathKind(value string) string {
|
|
return normalizeFromAllowList(value, knownChatOutputLocalPathKinds, "other")
|
|
}
|